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