diff --git a/.gitignore b/.gitignore index 039cd6e..62c7fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,25 @@ -.env -.venv/ +# Python __pycache__/ *.py[cod] -.DS_Store -.idea/ -.vscode/ *.egg-info/ -dist/ -build/ +.venv/ +venv/ +env/ + +# IDE +.idea/ +*.swp +*.swo +.DS_Store + +# Project +data/*.json +logs/ +backups/ +.env + +# Backups +*.bak + +# Runtime logs +*.log diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/PythonProject.iml b/.idea/PythonProject.iml deleted file mode 100644 index 157f929..0000000 --- a/.idea/PythonProject.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 1b93a53..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index c7ae335..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 5f43230..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e8ad1e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "-m", "app.main"] diff --git a/app/config.py b/app/config.py index e342491..19f910a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,29 +1,60 @@ +"""Configuration for SPA Telegram Bot.""" import os from dotenv import load_dotenv load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") -ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") +ADMIN_CHAT_ID = int(os.getenv("ADMIN_CHAT_ID", "0")) if os.getenv("ADMIN_CHAT_ID", "") else 0 if not BOT_TOKEN: raise ValueError("BOT_TOKEN not found in environment") +if not ADMIN_PASSWORD: + raise ValueError("ADMIN_PASSWORD not found in environment") -# Services catalog +USE_POSTGRES = bool(os.getenv("DB_HOST", "")) + +# Rich service catalog SERVICES = { - "service_massage": "💪 Массаж", - "service_spa": "🎈 SPA", - "service_cosmetology": "💄 Косметология", + "service_massage": { + "key": "service_massage", + "name": "\u041c\u0430\u0441\u0441\u0430\u0436", + "emoji": "\U0001f4aa", + "desc": "\u0420\u0430\u0441\u0441\u043b\u0430\u0431\u043b\u044f\u044e\u0449\u0438\u0439 \u0438 \u043b\u0435\u0447\u0435\u0431\u043d\u044b\u0439 \u043c\u0430\u0441\u0441\u0430\u0436.\n\u0421\u043d\u0438\u043c\u0430\u0435\u0442 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0443\u043b\u0443\u0447\u0448\u0430\u0435\u0442 \u043a\u0440\u043e\u0432\u043e\u043e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u0435 \u0438 \u0434\u0430\u0440\u0438\u0442 \u043b\u0451\u0433\u043a\u043e\u0441\u0442\u044c \u0432\u043e \u0432\u0441\u0451\u043c \u0442\u0435\u043b\u0435.", + "image": "assets/massage.png", + "duration": 60, + "price": 2500, + "category": "body", + }, + "service_spa": { + "key": "service_spa", + "name": "SPA-\u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441", + "emoji": "\U0001f388", + "desc": "\u041a\u043e\u043c\u043f\u043b\u0435\u043a\u0441\u043d\u044b\u0435 SPA-\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b: \u043e\u0431\u0451\u0440\u0442\u044b\u0432\u0430\u043d\u0438\u044f, \u0430\u0440\u043e\u043c\u0430\u0442\u0435\u0440\u0430\u043f\u0438\u044f, \u0433\u0438\u0434\u0440\u043e\u043c\u0430\u0441\u0441\u0430\u0436.\n\u041f\u043e\u043b\u043d\u043e\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0438 \u0440\u0435\u043b\u0430\u043a\u0441\u0430\u0446\u0438\u044f.", + "image": "assets/spa.png", + "duration": 90, + "price": 4000, + "category": "spa", + }, + "service_cosmetology": { + "key": "service_cosmetology", + "name": "\u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f", + "emoji": "\U0001f484", + "desc": "\u041f\u0440\u043e\u0444\u0435\u0441\u0441\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0443\u0445\u043e\u0434 \u0437\u0430 \u043b\u0438\u0446\u043e\u043c: \u0447\u0438\u0441\u0442\u043a\u0430, \u043f\u0438\u043b\u0438\u043d\u0433, \u043c\u0430\u0441\u043a\u0438, \u043b\u0438\u043c\u0444\u043e\u0434\u0440\u0435\u043d\u0430\u0436.\n\u0421\u0438\u044f\u044e\u0449\u0430\u044f \u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u0430\u044f \u043a\u043e\u0436\u0430.", + "image": "assets/cosmetology.png", + "duration": 45, + "price": 2000, + "category": "face", + }, } -# Masters list MASTERS = { - "master_anna": "👸 Анна", - "master_maria": "💃 Мария", - "master_alex": "🧑‍⚖️ Алексей", + "master_anna": {"key": "master_anna", "name": "\U0001f478 \u0410\u043d\u043d\u0430", "specialty": ["massage", "spa"]}, + "master_maria": {"key": "master_maria", "name": "\U0001f483 \u041c\u0430\u0440\u0438\u044f", "specialty": ["cosmetology"]}, + "master_alex": {"key": "master_alex", "name": "\U0001f9d1\U0000200d\U00002696\U0000fe0f \u0410\u043b\u0435\u043a\u0441\u0435\u0439", "specialty": ["massage", "spa"]}, } -# Available time slots TIME_SLOTS = [ "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", diff --git a/app/db/memory.py b/app/db/memory.py index 85a2e89..c58ec87 100644 --- a/app/db/memory.py +++ b/app/db/memory.py @@ -1,13 +1,17 @@ +"""Fallback JSON-based storage (sync). +Used only when DB_HOST is not set in .env. +""" import json -from app.logger import logger from pathlib import Path +from app.logger import logger 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" +from app.config import USE_POSTGRES + def _load_json(path, default): if path.exists(): @@ -24,6 +28,7 @@ def _save_json(path, data): json.dump(data, fh, ensure_ascii=False, indent=2) +# --- Users --- def load_users(): return _load_json(USERS_FILE, {}) @@ -43,19 +48,19 @@ def set_user(user_id, data): save_users(users) +# --- Bookings --- 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) + logger.debug("create_booking called: uid={uid}, svc={svc}, mst={mst}, dt={dt}, tm={tm}", + uid=user_id, svc=service, mst=master, dt=date, tm=time) bookings = load_bookings() booking = { "id": len(bookings) + 1, diff --git a/app/db/migrate_json_to_pg.py b/app/db/migrate_json_to_pg.py new file mode 100644 index 0000000..4ae6ad1 --- /dev/null +++ b/app/db/migrate_json_to_pg.py @@ -0,0 +1,49 @@ +"""Migrate data from JSON files to PostgreSQL.""" +import asyncio +import json +from pathlib import Path + +from app.db.postgres import PostgresDB + +DB_DIR = Path(__file__).resolve().parent.parent.parent / "data" +USERS_FILE = DB_DIR / "users.json" +BOOKINGS_FILE = DB_DIR / "bookings.json" + + +async def migrate(): + print("Starting migration from JSON to PostgreSQL...") + await PostgresDB.init_pool() + + # Migrate users + if USERS_FILE.exists(): + with open(USERS_FILE) as f: + users = json.load(f) + for uid, data in users.items(): + name = data.get("name", "") + phone = data.get("phone", "") + await PostgresDB.set_user(int(uid), name, phone) + print(f"Migrated {len(users)} users") + + # Migrate bookings + if BOOKINGS_FILE.exists(): + with open(BOOKINGS_FILE) as f: + bookings = json.load(f) + for b in bookings: + # Re-create via raw INSERT to preserve IDs + pool = await PostgresDB.get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + INSERT INTO bookings (id, user_id, user_name, service, master, date, time, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO NOTHING + """, b.get("id"), b.get("user_id"), b.get("user_name", ""), + b.get("service"), b.get("master"), b.get("date"), + b.get("time"), b.get("status", "active")) + print(f"Migrated {len(bookings)} bookings") + + print("Migration complete!") + await PostgresDB.close() + + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/app/db/postgres.py b/app/db/postgres.py new file mode 100644 index 0000000..744147c --- /dev/null +++ b/app/db/postgres.py @@ -0,0 +1,181 @@ +"""PostgreSQL storage for SPA Telegram Bot.""" +import json +import os +from datetime import datetime +from typing import Optional +import asyncpg +from app.logger import logger + +DB_HOST = os.getenv("DB_HOST", "10.0.77.9") +DB_PORT = int(os.getenv("DB_PORT", "5433")) +DB_USER = os.getenv("DB_USER", "spabot") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_NAME = os.getenv("DB_NAME", "spabot") + +if not DB_PASSWORD: + raise ValueError( + "DB_PASSWORD not set in environment. " + "Set it in .env or export DB_PASSWORD=your_password" + ) + +DB_CONFIG = { + "host": DB_HOST, + "port": DB_PORT, + "user": DB_USER, + "password": DB_PASSWORD, + "database": DB_NAME, +} + + +class PostgresDB: + _pool: Optional[asyncpg.Pool] = None + + @classmethod + async def init_pool(cls): + """Initialize connection pool.""" + if cls._pool is None: + cls._pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10) + await cls._migrate() + logger.info("Postgres pool initialized (host={host}, db={db})", + host=DB_CONFIG["host"], db=DB_CONFIG["database"]) + return cls._pool + + @classmethod + async def _migrate(cls): + """Create tables if not exist.""" + async with cls._pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + user_id BIGINT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + phone TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS bookings ( + id SERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(user_id), + user_name TEXT DEFAULT '', + service TEXT NOT NULL, + master TEXT NOT NULL, + date TEXT NOT NULL, + time TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_bookings_user_id ON bookings(user_id); + CREATE INDEX IF NOT EXISTS idx_bookings_master_date ON bookings(master, date); + CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status); + """) + logger.info("Postgres migrations applied") + + @classmethod + async def close(cls): + if cls._pool: + await cls._pool.close() + cls._pool = None + logger.info("Postgres pool closed") + + @classmethod + async def get_pool(cls): + if cls._pool is None: + await cls.init_pool() + return cls._pool + + # --- Users --- + @classmethod + async def get_user(cls, user_id: int) -> Optional[dict]: + pool = await cls.get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM users WHERE user_id = $1", user_id) + if row: + return dict(row) + return None + + @classmethod + async def set_user(cls, user_id: int, name: str, phone: str = ""): + pool = await cls.get_pool() + async with pool.acquire() as conn: + await conn.execute(""" + INSERT INTO users (user_id, name, phone, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id) + DO UPDATE SET name = $2, phone = $3, updated_at = NOW() + """, user_id, name, phone) + + # --- Bookings --- + @classmethod + async def create_booking(cls, user_id: int, service: str, master: str, + date: str, time: str, user_name: str = "") -> dict: + pool = await cls.get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + INSERT INTO bookings (user_id, user_name, service, master, date, time) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + """, user_id, user_name, service, master, date, time) + return dict(row) + + @classmethod + async def get_busy_times(cls, master: str, date: str) -> list: + pool = await cls.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT time FROM bookings WHERE master = $1 AND date = $2 AND status = 'active'", + master, date + ) + return [r["time"] for r in rows] + + @classmethod + async def get_user_bookings(cls, user_id: int, status: str = "active") -> list: + pool = await cls.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM bookings WHERE user_id = $1 AND status = $2 ORDER BY id DESC", + user_id, status + ) + return [dict(r) for r in rows] + + @classmethod + async def cancel_booking(cls, booking_id: int) -> bool: + pool = await cls.get_pool() + async with pool.acquire() as conn: + result = await conn.execute( + "UPDATE bookings SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status = 'active'", + booking_id + ) + return "UPDATE 1" in result + + @classmethod + async def update_booking(cls, booking_id: int, service: str, master: str, + date: str, time: str) -> Optional[dict]: + pool = await cls.get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + UPDATE bookings + SET service = $1, master = $2, date = $3, time = $4, updated_at = NOW() + WHERE id = $5 AND status = 'active' + RETURNING * + """, service, master, date, time, booking_id) + return dict(row) if row else None + + @classmethod + async def get_all_active_bookings(cls) -> list: + pool = await cls.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM bookings WHERE status = 'active' ORDER BY id DESC" + ) + return [dict(r) for r in rows] + + @classmethod + async def get_booking_by_id(cls, booking_id: int) -> Optional[dict]: + pool = await cls.get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM bookings WHERE id = $1", booking_id) + return dict(row) if row else None + + @classmethod + async def get_user_by_id(cls, user_id: int) -> Optional[dict]: + return await cls.get_user(user_id) diff --git a/app/handlers/about.py b/app/handlers/about.py index 17c0ece..eb7d7a1 100644 --- a/app/handlers/about.py +++ b/app/handlers/about.py @@ -1,12 +1,23 @@ +"""About handler - salon info page.""" from aiogram import Router, F from aiogram.types import Message -from app.logger import logger from aiogram.fsm.context import FSMContext +from app.logger import logger router = Router() -@router.message(F.text == "ℹ️ О нас") -async def about(message: Message, state: FSMContext): - await state.clear() # 🔥 СБРОС FSM - await message.answer("Мы demo SPA бот 💆‍♂️") \ No newline at end of file +@router.message(F.text == "\u2139\ufe0f \u041e \u043d\u0430\u0441") +async def about(message: Message, state: FSMContext): + await state.clear() + logger.info("User {uid} opened About", uid=message.from_user.id) + text = ( + "\u262f\ufe0f SPA-\u0441\u0430\u043b\u043e\u043d Demo\n\n" + "\u041c\u044b - \u0443\u044e\u0442\u043d\u044b\u0439 SPA-\u0441\u0430\u043b\u043e\u043d \u0432 \u0446\u0435\u043d\u0442\u0440\u0435 \u0433\u043e\u0440\u043e\u0434\u0430.\n\n" + "\U0001f4aa \u041f\u0440\u043e\u0444\u0435\u0441\u0441\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u043c\u0430\u0441\u0442\u0435\u0440\u0430\n" + "\U0001f9ec \u041d\u0430\u0442\u0443\u0440\u0430\u043b\u044c\u043d\u0430\u044f \u043a\u043e\u0441\u043c\u0435\u0442\u0438\u043a\u0430\n" + "\U0001f3e1 \u0423\u044e\u0442\u043d\u0430\u044f \u0430\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u0430\n\n" + "\u0420\u0430\u0431\u043e\u0442\u0430\u0435\u043c \u0435\u0436\u0435\u0434\u043d\u0435\u0432\u043d\u043e \u0441 09:00 \u0434\u043e 19:00\n\n" + "\U0001f447 \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u00bb \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f!" + ) + await message.answer(text) diff --git a/app/handlers/admin.py b/app/handlers/admin.py index c2320ed..c967304 100644 --- a/app/handlers/admin.py +++ b/app/handlers/admin.py @@ -1,20 +1,28 @@ """Admin handler for spa-telegram-bot — secure with password.""" from aiogram import Router, F -from aiogram.types import Message, CallbackQuery +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.filters import Command, CommandObject -from app.db.memory import load_bookings -from app.config import ADMIN_PASSWORD +from aiogram import Bot + +from app.config import USE_POSTGRES +from app.config import ADMIN_PASSWORD, BOT_TOKEN from app.logger import logger router = Router() ADMIN_IDS = {991309145} -# Store approved sessions — user_ids that passed password check _approved = set() +_notify_bot = None + + +def _get_bot(): + global _notify_bot + if _notify_bot is None: + _notify_bot = Bot(token=BOT_TOKEN) + return _notify_bot 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: @@ -30,52 +38,83 @@ 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 + if not is_admin(uid, password): + return logger.info("Admin {uid} opened admin panel", uid=uid) - bookings = load_bookings() - active = [b for b in bookings if b.get("status") == "active"] + if USE_POSTGRES: + from app.db.postgres import PostgresDB + active = await PostgresDB.get_all_active_bookings() + else: + from app.db.memory import load_bookings + bookings = load_bookings() + active = [b for b in bookings if b.get("status") == "active"] if not active: - await message.answer("📋 Активных записей нет") + await message.answer("\U0001f4cb \u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u0435\u0439 \u043d\u0435\u0442") return - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - for booking in active: buttons = [[ InlineKeyboardButton( - text="❌ Отменить", + text="\u274c \u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c", 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) + text = ( + "\U0001f4cb \u0417\u0430\u043f\u0438\u0441\u044c #" + str(booking['id']) + "\n" + "\U0001f464 \u041a\u043b\u0438\u0435\u043d\u0442: " + user_name + "\n" + "\U0001f486 \u0423\u0441\u043b\u0443\u0433\u0430: " + booking.get('service', '?') + "\n" + "\U0001f464 \u041c\u0430\u0441\u0442\u0435\u0440: " + booking.get('master', '?') + "\n" + "\U0001f4c5 \u0414\u0430\u0442\u0430: " + booking.get('date', '?') + "\n" + "\U0001f552 \u0412\u0440\u0435\u043c\u044f: " + booking.get('time', '?') ) + await message.answer(text, 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("⛔ Нет доступа") + await callback.answer("\u26d4 \u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u0430") 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) + if USE_POSTGRES: + from app.db.postgres import PostgresDB + success = await PostgresDB.cancel_booking(booking_id) + booking = await PostgresDB.get_booking_by_id(booking_id) if success else None + else: + from app.db.memory import cancel_booking as json_cancel, load_bookings + success = json_cancel(booking_id) + bookings = load_bookings() + booking = next((b for b in bookings if b["id"] == booking_id), None) - await callback.message.edit_text( - f"✅ Запись #{booking_id} отменена админом" - ) - await callback.answer("Запись отменена") + if not success: + await callback.message.edit_text("\u0417\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430 \u0438\u043b\u0438 \u0443\u0436\u0435 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430") + await callback.answer() + return + + await callback.message.edit_text("\u2705 \u0417\u0430\u043f\u0438\u0441\u044c #" + str(booking_id) + " \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430 \u0430\u0434\u043c\u0438\u043d\u043e\u043c") + + if booking: + try: + bot = _get_bot() + user_id = booking["user_id"] + notify_text = ( + "\u26a0\ufe0f \u0412\u0430\u0448\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u043e\u043c.\n\n" + "\U0001f486 \u0423\u0441\u043b\u0443\u0433\u0430: " + booking.get('service', '?') + "\n" + "\U0001f464 \u041c\u0430\u0441\u0442\u0435\u0440: " + booking.get('master', '?') + "\n" + "\U0001f4c5 \u0414\u0430\u0442\u0430: " + booking.get('date', '?') + "\n" + "\U0001f552 \u0412\u0440\u0435\u043c\u044f: " + booking.get('time', '?') + "\n\n" + "\U0001f4cb \u0427\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f \u0441\u043d\u043e\u0432\u0430, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f\u00bb" + ) + await bot.send_message(user_id, notify_text) + logger.info("Notified user {uid} about cancellation", uid=user_id) + except Exception as e: + logger.warning("Failed to notify user: {err}", err=e) + + await callback.answer() diff --git a/app/handlers/booking.py b/app/handlers/booking.py index 09157af..7f5df14 100644 --- a/app/handlers/booking.py +++ b/app/handlers/booking.py @@ -1,12 +1,14 @@ +"""Booking handler - full FSM flow for service reservation.""" import re from aiogram import Router, F from aiogram.fsm.context import FSMContext from aiogram.filters import StateFilter -from aiogram.types import Message, CallbackQuery +from aiogram.types import Message, CallbackQuery, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton 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.config import SERVICES as ALL_SERVICES, ADMIN_CHAT_ID from app.services.booking_service import ( create_booking, update_booking, @@ -32,6 +34,17 @@ def _services_to_str(data): return main +def _make_service_card(key): + """Recreate the service card text for 'back' navigation from a photo card.""" + svc = ALL_SERVICES[key] + return ( + svc["emoji"] + " " + svc["name"] + "\n\n" + + svc["desc"] + "\n\n" + + "\u23f1 Продолжительность: " + str(svc["duration"]) + " мин\n" + + "\U0001f4b0 Цена: " + str(svc["price"]) + "\u20bd" + ) + + # отмена записи на любом шаге @router.message(F.text.lower() == "отмена") async def cancel(message: Message, state: FSMContext): @@ -53,15 +66,14 @@ async def start_booking(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 - - service_name = SERVICES[callback.data] + svc_data = ALL_SERVICES[callback.data] + service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name) await state.update_data(service=service_name, extra_services=[]) @@ -82,10 +94,7 @@ async def choose_service(callback: CallbackQuery, state: FSMContext): 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( "👤 Выберите специалиста:", @@ -94,12 +103,12 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext): await callback.answer() return - # Toggle extra service actual_key = callback.data.replace("extra_", "", 1) - service_name = SERVICES.get(actual_key) - if not service_name: + svc_data = ALL_SERVICES.get(actual_key) + if not svc_data: await callback.answer("Услуга не найдена") return + service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data data = await state.get_data() extra = data.get("extra_services", []) @@ -133,7 +142,8 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext): async def choose_master(callback: CallbackQuery, state: FSMContext): from app.config import MASTERS - master_name = MASTERS[callback.data] + master_data = MASTERS[callback.data] + master_name = master_data["name"] if isinstance(master_data, dict) else master_data logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name) await state.update_data(master=master_name) @@ -222,6 +232,14 @@ async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext): date = data.get("date", "?") time = data.get("time", "?") + from app.db.postgres import PostgresDB as _PG + from app.config import USE_POSTGRES as _UPG + if _UPG: + _u = await _PG.get_user(callback.from_user.id) + if not _u: + _n = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id) + await _PG.set_user(callback.from_user.id, _n) + 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("❌ Это время уже занято. Пожалуйста, выберите другое время.") @@ -240,6 +258,26 @@ async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext): cleanup_cancelled() result_text = "✅ Запись успешно создана" + # Notify admin about new booking + try: + if ADMIN_CHAT_ID: + from aiogram import Bot + from app.config import BOT_TOKEN + admin_bot = Bot(token=BOT_TOKEN) + user_str = callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id) + await admin_bot.send_message( + ADMIN_CHAT_ID, + "🆕 Новая запись!\n\n" + f"👤 Клиент: {user_str} (id={callback.from_user.id})\n" + f"💆 Услуги: {services_str}\n" + f"👤 Мастер: {master}\n" + f"📅 Дата: {date}\n" + f"🕒 Время: {time}" + ) + await admin_bot.session.close() + except Exception as e: + logger.warning("Failed to notify admin: {error}", error=e) + await state.clear() await callback.message.edit_text(result_text) await callback.answer() @@ -271,24 +309,80 @@ async def edit_booking_callback(callback: CallbackQuery, state: FSMContext): ) +# прямая запись с карточки услуги (без предварительного выбора) +@router.callback_query(F.data.startswith("service_")) +async def quick_book_service(callback: CallbackQuery, state: FSMContext): + svc_data = ALL_SERVICES[callback.data] + service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data + svc_key = callback.data + logger.info("User {uid} quick book from card: {srv}", uid=callback.from_user.id, srv=service_name) + + await state.clear() + await state.update_data( + service=service_name, + extra_services=[], + _from_card=True, + _card_key=svc_key + ) + await state.set_state(BookingState.extra_services) + + text = ( + "✅ Выбрано: " + service_name + "\n\n" + "Хотите добавить ещё услугу?\n" + "Нажмите нужную или «Готово»:" + ) + + # Карточка с фото — удаляем и отправляем новое сообщение + try: + await callback.message.delete() + await callback.message.answer(text, reply_markup=get_extra_services_keyboard([])) + except Exception: + await callback.message.edit_text(text, reply_markup=get_extra_services_keyboard([])) + + await callback.answer() + + @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) + from_card = data.pop("_from_card", False) + card_key = data.pop("_card_key", None) await state.set_data(data) - await callback.message.edit_text( - "Выберите услугу:", - reply_markup=get_services_keyboard() - ) + + if from_card and card_key and card_key in ALL_SERVICES: + # Возвращаем карточку с фото + svc = ALL_SERVICES[card_key] + card_text = _make_service_card(card_key) + book_btn = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="\U0001f4c5 Записаться", callback_data=card_key) + ]]) + try: + img = FSInputFile(svc["image"]) + await callback.message.answer_photo(photo=img, caption=card_text, reply_markup=book_btn) + try: + await callback.message.delete() + except Exception: + pass + except Exception: + await callback.message.edit_text( + "Выберите услугу:", + reply_markup=get_services_keyboard() + ) + else: + 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", []) diff --git a/app/handlers/contacts.py b/app/handlers/contacts.py index d6ea583..2812873 100644 --- a/app/handlers/contacts.py +++ b/app/handlers/contacts.py @@ -1,12 +1,23 @@ +"""Contacts handler - address, phone, social.""" from aiogram import Router, F from aiogram.types import Message -from app.logger import logger from aiogram.fsm.context import FSMContext +from app.logger import logger router = Router() -@router.message(F.text == "📞 Контакты") + +@router.message(F.text == "\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b") async def contacts(message: Message, state: FSMContext): await state.clear() - - await message.answer("📍 Москва\n☎ +7 999 999 99 99") \ No newline at end of file + logger.info("User {uid} requested contacts", uid=message.from_user.id) + text = ( + "\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b\n\n" + "\U0001f4cd \u0433. \u041c\u043e\u0441\u043a\u0432\u0430, \u0443\u043b. \u0426\u0432\u0435\u0442\u043e\u0447\u043d\u0430\u044f, 15\n\n" + "\U0001f4de +7 (999) 123-45-67\n\n" + "\U0001f4e7 info@spa-demo.ru\n\n" + "\U0001f310 @spa_demo_salon\n\n" + "\u0420\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b:\n" + "\u041f\u043d-\u0412\u0441: 09:00 - 19:00" + ) + await message.answer(text) diff --git a/app/handlers/mybookings.py b/app/handlers/mybookings.py index c6467f8..da03078 100644 --- a/app/handlers/mybookings.py +++ b/app/handlers/mybookings.py @@ -1,88 +1,95 @@ +"""My bookings handler - view, edit, cancel bookings.""" from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.fsm.context import FSMContext -from app.db.memory import get_user_bookings, cancel_booking, update_booking -from app.keyboards.menu import menu +from app.config import USE_POSTGRES +from app.services.booking_service import get_user_bookings, cancel_booking from app.keyboards.my_booking import get_booking_actions +from app.keyboards.services import get_services_keyboard +from app.states.booking import BookingState from app.logger import logger router = Router() -@router.message( - F.text == "📄 Мои записи" -) -async def my_bookings( - message: Message -): +@router.message(F.text == "\U0001f4c4 \u041c\u043e\u0438 \u0437\u0430\u043f\u0438\u0441\u0438") +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 - ) + bookings = await get_user_bookings(message.from_user.id) logger.debug("Found {n} bookings", n=len(bookings)) if not bookings: - await message.answer( - "У вас нет активных записей" - ) + 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"] - ) + # Group all bookings into one message + lines = [] + for b in bookings: + bid = b["id"] + svc = b["service"] + mst = b["master"] + dat = b["date"] + tim = b["time"] + lines.append( + f"#{bid} \U0001f486 {svc}\n" + f"\U0001f464 {mst}\n" + f"\U0001f4c5 {dat}\U0001f552 {tim}" ) + + full_text = "\u0423 \u0432\u0430\u0441 " + str(len(bookings)) + " \u0437\u0430\u043f\u0438\u0441\u0438:\n\n" + "\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n".join(lines) + await message.answer(full_text) + + # Send action buttons for each booking + for b in bookings: + await message.answer( + f"#{b['id']}: {b['service']}", + reply_markup=get_booking_actions(b["id"]) + ) + except Exception as e: logger.exception("Error in my_bookings: {error}", error=e) - await message.answer( - "Произошла ошибка. Попробуйте позже." - ) + await message.answer("Произошла ошибка. Попробуйте позже.") -@router.callback_query( - F.data.startswith("cancel_booking_") -) +@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 cancel_booking(booking_id) + await callback.message.edit_text("\u2705 Запись отменена") await callback.answer() -@router.callback_query( - F.data.startswith("edit_booking_") -) -async def edit_booking_handler( - callback: CallbackQuery, - state: FSMContext -): +@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="" - ) + if USE_POSTGRES: + from app.db.postgres import PostgresDB + booking = await PostgresDB.get_booking_by_id(booking_id) + else: + from app.db.memory import load_bookings + bookings = load_bookings() + booking = next((b for b in bookings if b["id"] == booking_id), None) - await callback.answer( - "Редактирование не поддерживается" + if not booking: + await callback.message.edit_text("Запись не найдена") + await callback.answer() + return + + await state.set_data({ + "editing_booking_id": booking_id, + "service": booking.get("service", ""), + "master": booking.get("master", ""), + "date": booking.get("date", ""), + "time": booking.get("time", ""), + }) + + await state.set_state(BookingState.service) + await callback.message.edit_text( + "\u270f\ufe0f Редактирование записи #" + str(booking_id) + "\n\nВыберите новую услугу:", + reply_markup=get_services_keyboard() ) + await callback.answer() diff --git a/app/handlers/profile.py b/app/handlers/profile.py index d04d06e..66501d5 100644 --- a/app/handlers/profile.py +++ b/app/handlers/profile.py @@ -1,18 +1,34 @@ +"""User profile handler - auto-registers if missing.""" from aiogram import Router, F from aiogram.types import Message from aiogram.fsm.context import FSMContext -from app.db.memory import get_user +from app.db.postgres import PostgresDB +from app.config import USE_POSTGRES from app.logger import logger router = Router() -@router.message(F.text == "👤 Профиль") +@router.message(F.text == "\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c") async def profile(message: Message, state: FSMContext): await state.clear() - user = get_user(message.from_user.id) - if not user: - await message.answer("Профиль не найден") - return - await message.answer(f"👤 Имя: {user['name']}") + user_id = message.from_user.id + name = message.from_user.full_name or "\u0413\u043e\u0441\u0442\u044c" + + if USE_POSTGRES: + user = await PostgresDB.get_user(user_id) + if not user: + logger.info("Auto-registering user {uid} in PG", uid=user_id) + await PostgresDB.set_user(user_id, name) + user = await PostgresDB.get_user(user_id) + user_name = user.get("name") or user.get("full_name", "\u0413\u043e\u0441\u0442\u044c") + else: + from app.db.memory import get_user, set_user + user = get_user(user_id) + if not user: + set_user(user_id, {"name": name}) + user = get_user(user_id) + user_name = user.get("name", "\u0413\u043e\u0441\u0442\u044c") + + await message.answer(f"\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c\n\n\U0001f464 \u0418\u043c\u044f: {user_name}") diff --git a/app/handlers/services.py b/app/handlers/services.py index 931807f..c275370 100644 --- a/app/handlers/services.py +++ b/app/handlers/services.py @@ -1,23 +1,41 @@ +"""Services handler - show rich service cards with images.""" from aiogram import Router, F -from aiogram.types import Message +from aiogram.types import Message, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery from aiogram.fsm.context import FSMContext +from aiogram import Bot -from app.keyboards.menu import menu -from app.keyboards.services import get_services_keyboard +from app.config import SERVICES, BOT_TOKEN from app.logger import logger router = Router() +_bot = None + + +def bot(): + global _bot + if _bot is None: + _bot = Bot(token=BOT_TOKEN) + return _bot + @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() - ) + + for key, svc in SERVICES.items(): + try: + card = ( + svc["emoji"] + " " + svc["name"] + "\n\n" + + svc["desc"] + "\n\n" + + "\u23f1 \u0414\u043b\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c: " + str(svc["duration"]) + " \u043c\u0438\u043d\n" + + "\U0001f4b0 \u0426\u0435\u043d\u0430: " + str(svc["price"]) + "\u20bd" + ) + book_btn = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="\U0001f4c5 \u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f", callback_data=key) + ]]) + img = FSInputFile(svc["image"]) + await bot().send_photo(message.chat.id, photo=img, caption=card, reply_markup=book_btn) + except Exception as e: + logger.warning("Error sending service card for {key}: {err}", key=key, err=e) diff --git a/app/handlers/start.py b/app/handlers/start.py index a1b9e18..b6af7ac 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -1,9 +1,11 @@ +"""Start handler with welcome greeting and prices.""" from aiogram import Router from aiogram.filters import CommandStart -from aiogram.types import Message +from aiogram.types import Message, FSInputFile from app.keyboards.menu import menu -from app.db.memory import get_user, set_user +from app.db.postgres import PostgresDB +from app.config import SERVICES from app.logger import logger router = Router() @@ -12,10 +14,25 @@ router = Router() @router.message(CommandStart()) async def start(message: Message): user_id = message.from_user.id - if not get_user(user_id): - set_user(user_id, {"name": message.from_user.full_name}) + name = message.from_user.full_name or "\u0413\u043e\u0441\u0442\u044c" + from app.config import USE_POSTGRES + if USE_POSTGRES: + await PostgresDB.set_user(user_id, name) + else: + from app.db.memory import get_user, set_user + if not get_user(user_id): + set_user(user_id, {"name": name}) - await message.answer( - "👋 Добро пожаловать в Demo Bot", - reply_markup=menu, + greet = ( + "\U0001f44b \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c, " + name + "!\n\n" + "\u262f\ufe0f SPA-\u0441\u0430\u043b\u043e\u043d Demo - \u0432\u0430\u0448 \u043e\u0441\u0442\u0440\u043e\u0432\u043e\u043a \u0440\u0435\u043b\u0430\u043a\u0441\u0430\u0446\u0438\u0438\n\n" + "\U0001f4aa \u041c\u0430\u0441\u0441\u0430\u0436 - \u043e\u0442 " + str(SERVICES["service_massage"]["price"]) + "\u20bd\n" + "\U0001f388 SPA-\u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441 - \u043e\u0442 " + str(SERVICES["service_spa"]["price"]) + "\u20bd\n" + "\U0001f484 \u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f - \u043e\u0442 " + str(SERVICES["service_cosmetology"]["price"]) + "\u20bd\n\n" + "\U0001f447 \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u00bb \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u0447\u0430\u0442\u044c" ) + try: + img = FSInputFile("assets/welcome.png") + await message.answer_photo(photo=img, caption=greet, reply_markup=menu) + except Exception: + await message.answer(greet, reply_markup=menu) diff --git a/app/health.py b/app/health.py new file mode 100644 index 0000000..452760c --- /dev/null +++ b/app/health.py @@ -0,0 +1,42 @@ +"""Healthcheck endpoint for monitoring.""" +import asyncio +import json +from pathlib import Path +from aiohttp import web +from app.logger import logger + +routes = web.RouteTableDef() + + +@routes.get("/health") +async def health(request): + """Basic health check — returns bot status.""" + health_data = { + "status": "ok", + "service": "spa-telegram-bot", + "timestamp": asyncio.get_event_loop().time(), + } + + # Check if data files exist + data_dir = Path(__file__).resolve().parent.parent / "data" + health_data["data_dir_exists"] = data_dir.exists() + + return web.json_response(health_data, status=200) + + +@routes.get("/health/ready") +async def ready(request): + """Readiness check — can the bot accept requests?""" + return web.json_response({"status": "ready"}, status=200) + + +async def start_health_server(port: int = 8080): + """Start healthcheck HTTP server on given port.""" + app = web.Application() + app.add_routes(routes) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", port) + await site.start() + logger.info("Healthcheck server started on port {port}", port=port) + return runner diff --git a/app/keyboards/calendar.py b/app/keyboards/calendar.py index 3653fe8..ac527d7 100644 --- a/app/keyboards/calendar.py +++ b/app/keyboards/calendar.py @@ -15,7 +15,7 @@ def get_dates_keyboard(): [ InlineKeyboardButton( text=day.strftime("%d.%m"), - callback_data=f"date_{day.strftime('%d.%m')}" + callback_data=f"date_{day.strftime('%d.%m.%Y')}" ) ] ) @@ -23,4 +23,4 @@ def get_dates_keyboard(): buttons.append(get_back_button()) return InlineKeyboardMarkup( inline_keyboard=buttons - ) \ No newline at end of file + ) diff --git a/app/keyboards/masters.py b/app/keyboards/masters.py index 76ff2d8..e8f2f19 100644 --- a/app/keyboards/masters.py +++ b/app/keyboards/masters.py @@ -1,14 +1,21 @@ +"""Masters keyboard with specialty filtering.""" 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() - ] +def get_masters_keyboard(service_key=None): + buttons = [] + svc_cats = { + "service_massage": "massage", + "service_spa": "spa", + "service_cosmetology": "cosmetology", + } + for key, master in MASTERS.items(): + if service_key and "specialty" in master: + needed = svc_cats.get(service_key, "") + if needed and needed not in master["specialty"]: + continue + buttons.append([InlineKeyboardButton(text=master["name"], callback_data=key)]) buttons.append(get_back_button()) return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/app/keyboards/menu.py b/app/keyboards/menu.py index a8022b0..ecea634 100644 --- a/app/keyboards/menu.py +++ b/app/keyboards/menu.py @@ -1,21 +1,22 @@ +"""Main menu keyboard.""" from aiogram.types import ReplyKeyboardMarkup, KeyboardButton menu = ReplyKeyboardMarkup( keyboard=[ - [KeyboardButton(text="📋 Услуги"), - KeyboardButton(text="📅 Записаться")], - [KeyboardButton(text="👤 Профиль"), - KeyboardButton(text="📄 Мои записи")], - [KeyboardButton(text="📞 Контакты"), - KeyboardButton(text="ℹ️ О нас")], + [KeyboardButton(text="\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438"), + KeyboardButton(text="\U0001f4c5 \u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f")], + [KeyboardButton(text="\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c"), + KeyboardButton(text="\U0001f4c4 \u041c\u043e\u0438 \u0437\u0430\u043f\u0438\u0441\u0438")], + [KeyboardButton(text="\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b"), + KeyboardButton(text="\u2139\ufe0f \u041e \u043d\u0430\u0441")], ], resize_keyboard=True, - input_field_placeholder="Выберите действие..." + input_field_placeholder="\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435..." ) cancel_menu = ReplyKeyboardMarkup( keyboard=[ - [KeyboardButton(text="❌ Отмена")] + [KeyboardButton(text="\u274c \u041e\u0442\u043c\u0435\u043d\u0430")] ], resize_keyboard=True ) diff --git a/app/keyboards/services.py b/app/keyboards/services.py index c1df4fe..b60f556 100644 --- a/app/keyboards/services.py +++ b/app/keyboards/services.py @@ -1,33 +1,28 @@ +"""Services keyboard with price & duration.""" 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 = [] + for key, svc in SERVICES.items(): + label = svc["emoji"] + " " + svc["name"] + " - " + str(svc["price"]) + "\u20bd / " + str(svc["duration"]) + "\u043c\u0438\u043d" + buttons.append([InlineKeyboardButton(text=label, callback_data=key)]) 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.""" +def get_extra_services_keyboard(already_selected=None): if already_selected is None: already_selected = [] - buttons = [] - for key, name in SERVICES.items(): - if name in already_selected: - label = "\u2705 " + name + for key, svc in SERVICES.items(): + if svc["name"] in already_selected: + label = "\u2705 " + svc["emoji"] + " " + svc["name"] else: - label = name + label = svc["emoji"] + " " + svc["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/main.py b/app/main.py index ab0537f..6f42a4e 100644 --- a/app/main.py +++ b/app/main.py @@ -4,8 +4,9 @@ import signal import sys from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder -from app.config import BOT_TOKEN +from app.config import BOT_TOKEN, USE_POSTGRES from app.logger import logger # Import routers @@ -19,7 +20,13 @@ 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() + +# Redis FSM storage — survives bot restarts, persistent across polling sessions +redis_storage = RedisStorage.from_url( + "redis://localhost:6379/0", + key_builder=DefaultKeyBuilder(with_destiny=True) +) +dp = Dispatcher(storage=redis_storage) # Register routers dp.include_routers( @@ -33,9 +40,20 @@ dp.include_routers( admin_router, ) +_health_runner = None + async def on_startup(): """Called when bot starts.""" + # Initialize Postgres pool if available + if USE_POSTGRES: + try: + from app.db.postgres import PostgresDB + await PostgresDB.init_pool() + logger.info("PostgreSQL pool initialized") + except Exception as e: + logger.warning("PostgreSQL init failed, falling back to JSON: {error}", error=e) + me = await bot.get_me() logger.info( "Bot started | @{username} (id={id}) | Token: {token_preview}...", @@ -43,12 +61,31 @@ async def on_startup(): id=me.id, token_preview=BOT_TOKEN[:10], ) + # Start healthcheck server + try: + from app.health import start_health_server + global _health_runner + _health_runner = await start_health_server(port=8080) + except Exception as e: + logger.warning("Healthcheck server not started: {error}", error=e) async def on_shutdown(): """Called when bot shuts down.""" logger.info("Bot shutting down...") - # Give running tasks time to finish + if USE_POSTGRES: + try: + from app.db.postgres import PostgresDB + await PostgresDB.close() + logger.info("PostgreSQL pool closed") + except Exception as e: + logger.warning("PostgreSQL pool close error: {error}", error=e) + if _health_runner: + await _health_runner.cleanup() + logger.info("Healthcheck server stopped") + if redis_storage: + await redis_storage.close() + logger.info("Redis storage closed") pending = asyncio.all_tasks() if pending: logger.debug("Cancelling {count} pending tasks", count=len(pending)) @@ -74,7 +111,6 @@ async def main(): try: loop.add_signal_handler(sig, lambda s=sig: _signal_handler(s, None)) except NotImplementedError: - # Windows fallback signal.signal(sig, _signal_handler) try: diff --git a/app/services/booking_service.py b/app/services/booking_service.py index 7dc88a3..c2aab07 100644 --- a/app/services/booking_service.py +++ b/app/services/booking_service.py @@ -1,64 +1,83 @@ +"""Booking service — async layer between handlers and DB.""" +from app.db.postgres import PostgresDB 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, + create_booking as json_create_booking, + get_busy_times as json_get_busy_times, + get_user_bookings as json_get_user_bookings, + cancel_booking as json_cancel_booking, + update_booking as json_update_booking, + load_bookings as json_load_bookings, + save_bookings as json_save_bookings, + USE_POSTGRES, ) from app.logger import logger async def create_booking(user_id, service, master, date, time, user_name=""): - return db_create_booking(user_id, service, master, date, time, user_name) + if USE_POSTGRES: + booking = await PostgresDB.create_booking(user_id, service, master, date, time, user_name) + return {"id": booking["id"], "user_id": booking["user_id"], "service": booking["service"], + "master": booking["master"], "date": booking["date"], "time": booking["time"], + "status": booking["status"]} + return json_create_booking(user_id, service, master, date, time, user_name) async def get_busy_times(master, date): - return db_get_busy_times(master, date) + if USE_POSTGRES: + return await PostgresDB.get_busy_times(master, date) + return json_get_busy_times(master, date) async def get_user_bookings(user_id): - return db_get_user_bookings(user_id) + if USE_POSTGRES: + bookings = await PostgresDB.get_user_bookings(user_id) + return [{"id": b["id"], "user_id": b["user_id"], "service": b["service"], + "master": b["master"], "date": b["date"], "time": b["time"], + "status": b["status"]} for b in bookings] + return json_get_user_bookings(user_id) async def cancel_booking(booking_id): - return db_cancel_booking(booking_id) + if USE_POSTGRES: + return await PostgresDB.cancel_booking(booking_id) + return json_cancel_booking(booking_id) async def update_booking(booking_id, service, master, date, time): - return db_update_booking(booking_id, service, master, date, time) + if USE_POSTGRES: + result = await PostgresDB.update_booking(booking_id, service, master, date, time) + if result: + return {"id": result["id"], "service": result["service"], "master": result["master"], + "date": result["date"], "time": result["time"]} + return None + return json_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) + busy = await get_busy_times(master, date) return time in busy -def cleanup_cancelled(): +async 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) + if USE_POSTGRES: + # Postgres handles this with status column, no cleanup needed + pass + else: + bookings = json_load_bookings() + active = [b for b in bookings if b["status"] == "active"] + cancelled = [b for b in bookings if b["status"] == "cancelled"] + 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(): + user_cancel.sort(key=lambda x: x.get("id", 0), reverse=True) + for b in user_cancel[:5]: + keep_ids.add(b["id"]) + bookings = active + [b for b in cancelled if b["id"] in keep_ids] + json_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/utils/stickers.py b/app/utils/stickers.py new file mode 100644 index 0000000..87ae033 --- /dev/null +++ b/app/utils/stickers.py @@ -0,0 +1,20 @@ +"""Sticker helper.""" +from aiogram import Bot +from app.config import BOT_TOKEN +from app.logger import logger + +_bot = None + + +def _sticker_bot(): + global _bot + if _bot is None: + _bot = Bot(token=BOT_TOKEN) + return _bot + + +async def send_sticker(chat_id, sticker_id: str): + try: + await _sticker_bot().send_sticker(chat_id, sticker=sticker_id) + except Exception as e: + logger.warning("Sticker send failed: {err}", err=e) diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/cosmetology.png b/assets/cosmetology.png new file mode 100644 index 0000000..fe7cc1c Binary files /dev/null and b/assets/cosmetology.png differ diff --git a/assets/massage.png b/assets/massage.png new file mode 100644 index 0000000..db3964f Binary files /dev/null and b/assets/massage.png differ diff --git a/assets/spa.png b/assets/spa.png new file mode 100644 index 0000000..0085fce Binary files /dev/null and b/assets/spa.png differ diff --git a/assets/welcome.png b/assets/welcome.png new file mode 100644 index 0000000..55bcd50 Binary files /dev/null and b/assets/welcome.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d7ae31d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + bot: + build: . + container_name: spa-bot + restart: unless-stopped + env_file: + - .env + volumes: + - ./data:/app/data + - ./logs:/app/logs + ports: + - "8080:8080" + depends_on: + - db + + db: + image: postgres:16-alpine + container_name: spa-bot-db + restart: unless-stopped + environment: + POSTGRES_USER: spabot + POSTGRES_PASSWORD: spabot_password + POSTGRES_DB: spabot + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + +volumes: + pgdata: diff --git a/requirements.txt b/requirements.txt index 0e6b280..88c2619 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -aiogram -python-dotenv \ No newline at end of file +aiogram==3.17.0 +python-dotenv==1.1.0 +loguru==0.7.3 +asyncpg==0.30.0