Merge pull request 'feat/devproject-deploy' (#1) from feat/devproject-deploy into master

Reviewed-on: http://10.100.100.22:3000/gregoi4957/spa-telegram-bot/pulls/1
This commit is contained in:
gregoi4957
2026-06-18 17:12:15 +04:00
31 changed files with 974 additions and 144 deletions
-1
View File
@@ -1 +0,0 @@
BOT_TOKEN=8918896220:AAHuo36Lo9PEeXVNongKJbr2WLZn5xxy1GQ
+10
View File
@@ -0,0 +1,10 @@
.env
.venv/
__pycache__/
*.py[cod]
.DS_Store
.idea/
.vscode/
*.egg-info/
dist/
build/
View File
+24 -1
View File
@@ -4,6 +4,29 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
if not BOT_TOKEN: if not BOT_TOKEN:
raise ValueError("BOT_TOKEN не найден") 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",
]
View File
+111 -2
View File
@@ -1,2 +1,111 @@
users = {} import json
bookings = [] 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
View File
+2 -1
View File
@@ -1,10 +1,11 @@
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message from aiogram.types import Message
from app.logger import logger
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
router = Router() router = Router()
@router.message(F.text == "О компании") @router.message(F.text == "О нас")
async def about(message: Message, state: FSMContext): async def about(message: Message, state: FSMContext):
await state.clear() # 🔥 СБРОС FSM await state.clear() # 🔥 СБРОС FSM
+81
View File
@@ -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("Запись отменена")
+285 -67
View File
@@ -1,15 +1,37 @@
import re
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter 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.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.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() 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() == "отмена") @router.message(F.text.lower() == "отмена")
async def cancel(message: Message, state: FSMContext): async def cancel(message: Message, state: FSMContext):
@@ -24,99 +46,295 @@ async def cancel(message: Message, state: FSMContext):
@router.message(F.text == "📅 Записаться") @router.message(F.text == "📅 Записаться")
async def start_booking(message: Message, state: FSMContext): async def start_booking(message: Message, state: FSMContext):
await state.clear() await state.clear()
await state.set_state(BookingState.service) await state.set_state(BookingState.service)
await message.answer( await message.answer(
"Выберите услугу:\n" "Выберите услугу:",
"1. Массаж\n" reply_markup=get_services_keyboard()
"2. SPA\n"
"3. Косметология",
reply_markup=cancel_menu
) )
# выбор услуги # выбор первой услуги
@router.message(StateFilter(BookingState.service)) @router.callback_query(
async def choose_service(message: Message, state: FSMContext): 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 state.update_data(service=service_name, extra_services=[])
await message.answer( 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 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 state.set_state(BookingState.date)
await message.answer( await callback.message.edit_text(
"Введите дату (например 20.06)" "📅 Выберите дату:",
reply_markup=get_dates_keyboard()
) )
await callback.answer()
# выбор даты # выбор даты
@router.message(StateFilter(BookingState.date)) @router.callback_query(
async def choose_date(message: Message, state: FSMContext): StateFilter(BookingState.date),
F.data.startswith("date_")
)
async def choose_date(callback: CallbackQuery, state: FSMContext):
selected_date = callback.data.replace("date_", "")
forbidden = [ await state.update_data(date=selected_date)
"📅 Записаться", logger.debug("User {uid} selected date: {d}", uid=callback.from_user.id, d=selected_date)
"👤 Профиль",
"📖 Мои записи",
"О компании",
"📞 Контакты"
]
if message.text in forbidden:
await message.answer(
"⚠️ Сначала завершите запись или напишите 'отмена'"
)
return
await state.update_data(date=message.text)
data = await state.get_data() 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 state.set_state(BookingState.confirm)
await message.answer( await callback.message.edit_text(
f"Услуга: {data['service']}\n" "📋 Проверьте запись:\n\n"
f"Дата: {data['date']}\n\n" f"💆 Услуги: {services_str}\n"
f"Напишите 'да' для подтверждения" 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)) @router.callback_query(
async def confirm_booking(message: Message, state: FSMContext): StateFilter(BookingState.confirm),
F.data == "confirm_booking"
if message.text.lower() != "да": )
await state.clear() async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext):
await message.answer(
"❌ Запись отменена",
reply_markup=menu
)
data = await state.get_data() data = await state.get_data()
logger.info("User {uid} confirmed booking: {data}", uid=callback.from_user.id, data=data)
create_booking( services_str = _services_to_str(data)
user_id=message.from_user.id, editing_booking_id = data.get("editing_booking_id")
service=data["service"],
date=data["date"] 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 state.clear()
await callback.message.edit_text(result_text)
await callback.answer()
await message.answer(
"✅ Вы успешно записаны!", @router.callback_query(
reply_markup=menu 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()
+1
View File
@@ -1,5 +1,6 @@
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message from aiogram.types import Message
from app.logger import logger
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
router = Router() router = Router()
+76 -16
View File
@@ -1,28 +1,88 @@
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext 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 = 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 = [ if not bookings:
b for b in bookings if b["user_id"] == user_id await message.answer(
] "У вас нет активных записей"
)
return
if not user_bookings: for booking in bookings:
await message.answer("У вас нет записей") svc = booking["service"]
return 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: @router.callback_query(
text += f"💆 {b['service']} — 📅 {b['date']}\n" 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)
@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(
"Редактирование не поддерживается"
)
+5 -6
View File
@@ -2,18 +2,17 @@ from aiogram import Router, F
from aiogram.types import Message from aiogram.types import Message
from aiogram.fsm.context import FSMContext 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 = Router()
@router.message(F.text == "👤 Профиль") @router.message(F.text == "👤 Профиль")
async def profile(message: Message, state: FSMContext): async def profile(message: Message, state: FSMContext):
await state.clear() await state.clear()
user = get_user(message.from_user.id)
user = users.get(message.from_user.id)
if not user: if not user:
await message.answer("Профиль не найден") await message.answer("Профиль не найден")
return return
await message.answer(f"👤 Имя: {user['name']}")
await message.answer(f"👤 Имя: {user['name']}")
+23 -2
View File
@@ -1,2 +1,23 @@
from aiogram import Router from aiogram import Router, F
router = Router() 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()
)
+8 -6
View File
@@ -3,17 +3,19 @@ from aiogram.filters import CommandStart
from aiogram.types import Message from aiogram.types import Message
from app.keyboards.menu import menu 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 = Router()
@router.message(CommandStart()) @router.message(CommandStart())
async def start(message: Message): async def start(message: Message):
users[message.from_user.id] = { user_id = message.from_user.id
"name": message.from_user.full_name if not get_user(user_id):
} set_user(user_id, {"name": message.from_user.full_name})
await message.answer( await message.answer(
"👋 Добро пожаловать в Demo Bot", "👋 Добро пожаловать в Demo Bot",
reply_markup=menu reply_markup=menu,
) )
View File
+5
View File
@@ -0,0 +1,5 @@
from aiogram.types import InlineKeyboardButton
def get_back_button():
return [InlineKeyboardButton(text="◀ Назад", callback_data="go_back")]
+26
View File
@@ -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
)
+28
View File
@@ -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"
)
]
]
)
+14
View File
@@ -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)
+14 -18
View File
@@ -1,25 +1,21 @@
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
# Главное меню
menu = ReplyKeyboardMarkup( menu = ReplyKeyboardMarkup(
keyboard=[ 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 resize_keyboard=True
) )
# Меню во время записи
cancel_menu = ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="Отмена")]
],
resize_keyboard=True
)
+25
View File
@@ -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}"
)
]
]
)
+33
View File
@@ -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)
+22
View File
@@ -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)
+38
View File
@@ -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="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
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",
)
+77 -13
View File
@@ -1,33 +1,97 @@
"""SPA Telegram Bot — main entry point."""
import asyncio import asyncio
import signal
import sys
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from app.config import BOT_TOKEN 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.start import router as start_router
from app.handlers.booking import router as booking_router from app.handlers.booking import router as booking_router
from app.handlers.profile import router as profile_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.about import router as about_router
from app.handlers.contacts import router as contacts_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(): async def main():
bot = Bot(token=BOT_TOKEN) logger.info("Initializing bot...")
dp = Dispatcher()
# подключаем все роутеры dp.startup.register(on_startup)
dp.include_router(start_router) dp.shutdown.register(on_shutdown)
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)
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__": if __name__ == "__main__":
asyncio.run(main()) 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)
View File
+62 -10
View File
@@ -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) async def create_booking(user_id, service, master, date, time, user_name=""):
return booking 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)
View File
+4 -1
View File
@@ -1,6 +1,9 @@
from aiogram.fsm.state import StatesGroup, State from aiogram.fsm.state import StatesGroup, State
class BookingState(StatesGroup): class BookingState(StatesGroup):
service = State() service = State() # первая услуга
extra_services = State() # дополнительные услуги
master = State()
date = State() date = State()
time = State()
confirm = State() confirm = State()
View File