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:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
+24
-1
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
+111
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("Запись отменена")
|
||||||
+284
-66
@@ -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,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
@@ -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(
|
||||||
|
"Редактирование не поддерживается"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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']}")
|
||||||
@@ -1,2 +1,23 @@
|
|||||||
from aiogram import Router
|
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 = 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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from aiogram.types import InlineKeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
def get_back_button():
|
||||||
|
return [InlineKeyboardButton(text="◀ Назад", callback_data="go_back")]
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
+9
-13
@@ -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
|
resize_keyboard=True,
|
||||||
|
input_field_placeholder="Выберите действие..."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Меню во время записи
|
|
||||||
cancel_menu = ReplyKeyboardMarkup(
|
cancel_menu = ReplyKeyboardMarkup(
|
||||||
keyboard=[
|
keyboard=[
|
||||||
[KeyboardButton(text="Отмена")]
|
[KeyboardButton(text="❌ Отмена")]
|
||||||
],
|
],
|
||||||
resize_keyboard=True
|
resize_keyboard=True
|
||||||
)
|
)
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user