2 Commits
Author SHA1 Message Date
grigorij d757352b8a Добавлена ветка cosmetics 2026-07-31 13:07:25 +04:00
grigorij f289631645 Добавлена ветка prod_prep 2026-07-31 12:58:23 +04:00
37 changed files with 755 additions and 381 deletions
+7
View File
@@ -0,0 +1,7 @@
BOT_TOKEN=your_t…here
ADMIN_PASSWORD=your_s…word
DB_HOST=10.0.77.9
DB_PORT=5432
DB_USER=spabot
DB_PASSWORD=spabot_password
DB_NAME=spabot
+32
View File
@@ -0,0 +1,32 @@
name: Deploy SPA Bot
on:
push:
branches:
- feat/prod-prep
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy via SSH
env:
SSH_HOST: 10.0.77.8
SSH_USER: devproject
SSH_KEY: \${{ secrets.DEPLOY_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "\$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key \$SSH_USER@\$SSH_HOST << 'EOF'
cd /opt/projects/spa-telegram-bot
git fetch origin
git reset --hard origin/feat/prod-prep
source .venv/bin/activate
pip install -r requirements.txt --quiet
sudo -n systemctl restart spa-telegram-bot
echo "Deploy complete"
EOF
+16 -7
View File
@@ -1,10 +1,19 @@
.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
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.14 (PythonProject) (2)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-13
View File
@@ -1,13 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="aiogram" />
<option value="python-dotenv" />
</list>
</option>
</inspection_tool>
</profile>
</component>
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.14 (PythonProject) (2)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (PythonProject) (2)" project-jdk-type="Python SDK" />
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/PythonProject.iml" filepath="$PROJECT_DIR$/.idea/PythonProject.iml" />
</modules>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+10
View File
@@ -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"]
-124
View File
@@ -1,124 +0,0 @@
# 💆 Demo SPA Bot
Telegram-бот для SPA-салона: запись на услуги, выбор мастера, управление бронированиями и админ-панель.
Построен на **aiogram** с JSON-хранилищем — лёгкий, без БД, готов к запуску.
---
## 🚀 Возможности
- **📋 Услуги** — массаж, SPA, косметология (настраивается в `config.py`)
- **👩‍🔬 Мастера** — выбор специалиста с эмодзи-аватарками
- **📅 Запись** — календарь, слоты по 1 часу, мультиуслуги
- **👤 Профиль** — просмотр имени / ID
- **📄 Мои записи** — просмотр, отмена, редактирование броней
- **🔐 Админ-панель** — просмотр и отмена любых записей
- **📞 Контакты** — адрес и телефон салона
---
## 🧱 Структура
```
demo_spa_bot/
├── app/
│ ├── main.py # Точка входа
│ ├── config.py # Услуги, мастера, слоты
│ ├── logger.py # Логирование
│ ├── db/
│ │ ├── __init__.py
│ │ └── memory.py # JSON-хранилище (users, bookings)
│ ├── handlers/
│ │ ├── admin.py # Админ-панель
│ │ ├── about.py # "О нас"
│ │ ├── booking.py # Процесс записи (FSM)
│ │ ├── contacts.py # Контакты
│ │ ├── mybookings.py # Мои записи
│ │ ├── profile.py # Профиль
│ │ ├── services.py # Список услуг
│ │ └── start.py # Команда /start
│ ├── keyboards/ # Reply-клавиатуры
│ ├── services/
│ │ └── booking_service.py # Бизнес-логика бронирования
│ └── states/
│ └── booking.py # Состояния FSM
└── data/ # JSON-файлы (users.json, bookings.json)
```
---
## 🛠 Установка и запуск
```bash
# 1. Клонировать
git clone https://git.labnethub.ru/grejoi4621/demo_spa_bot.git
cd demo_spa_bot
# 2. Виртуальное окружение
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
# 3. Зависимости
pip install -r requirements.txt
# 4. Переменные окружения
cp .env.example .env # или создать вручную
# BOT_TOKEN=ваш_токен_бота
# 5. Запуск
python -m app.main
```
---
## ⚙️ Конфигурация
В `app/config.py`:
| Переменная | Описание | Значение по умолчанию |
|--------------------|-----------------------------|-----------------------|
| `BOT_TOKEN` | Токен Telegram-бота | из `.env` |
| `ADMIN_PASSWORD` | Пароль для /admin | настраивается |
| `SERVICES` | Словарь услуг | массаж, SPA, косметология |
| `MASTERS` | Словарь мастеров | Анна, Мария, Алексей |
| `TIME_SLOTS` | Доступное время | 09:0018:00, каждый час |
---
## 🔐 Админ-панель
В чате с ботом: `/admin пароль`
Админ видит все бронирования и может отменять любые.
---
## 🗄 Хранилище
Данные хранятся в JSON-файлах в папке `data/`:
- `users.json` — пользователи
- `bookings.json` — бронирования
Файлы создаются автоматически при первом запуске.
---
## 🤝 Разработка
1. Создаёшь ветку от `master`
2. Вносишь изменения
3. Открываешь Pull Request в `master`
```bash
git checkout -b feat/my-feature
# ... работа ...
git push origin feat/my-feature
```
---
## 📄 Лицензия
MIT — делайте что хотите.
+40 -10
View File
@@ -1,29 +1,59 @@
"""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")
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": "\U0001f4aa \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": "\U0001f388 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": "\U0001f484 \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",
+10 -5
View File
@@ -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,
+49
View File
@@ -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())
+169
View File
@@ -0,0 +1,169 @@
"""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_CONFIG = {
"host": os.getenv("DB_HOST", "10.0.77.9"),
"port": int(os.getenv("DB_PORT", "5432")),
"user": os.getenv("DB_USER", "spabot"),
"password": os.getenv("DB_PASSWORD", "spabot_password"),
"database": os.getenv("DB_NAME", "spabot"),
}
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)
+16 -5
View File
@@ -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 бот 💆‍♂️")
@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)
+67 -28
View File
@@ -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()
+15 -4
View File
@@ -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")
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)
+46 -51
View File
@@ -1,32 +1,27 @@
"""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("\u0423 \u0432\u0430\u0441 \u043d\u0435\u0442 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u0435\u0439")
return
for booking in bookings:
@@ -34,55 +29,55 @@ async def my_bookings(
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"]
)
text = (
"\U0001f486 " + svc + "\n"
"\U0001f464 " + mst + "\n"
"\U0001f4c5 " + dat + "\n"
"\U0001f552 " + tim
)
await message.answer(text, reply_markup=get_booking_actions(booking["id"]))
except Exception as e:
logger.exception("Error in my_bookings: {error}", error=e)
await message.answer(
"Произошла ошибка. Попробуйте позже."
)
await message.answer("\u041f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430. \u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u043e\u0437\u0436\u0435.")
@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 \u0417\u0430\u043f\u0438\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430")
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("\u0417\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430")
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 \u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0437\u0430\u043f\u0438\u0441\u0438 #" + str(booking_id) + "\n\n\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043d\u043e\u0432\u0443\u044e \u0443\u0441\u043b\u0443\u0433\u0443:",
reply_markup=get_services_keyboard()
)
await callback.answer()
+10 -3
View File
@@ -1,8 +1,10 @@
"""User profile handler."""
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()
@@ -11,8 +13,13 @@ router = Router()
@router.message(F.text == "👤 Профиль")
async def profile(message: Message, state: FSMContext):
await state.clear()
user = get_user(message.from_user.id)
if USE_POSTGRES:
user = await PostgresDB.get_user(message.from_user.id)
else:
from app.db.memory import get_user
user = get_user(message.from_user.id)
if not user:
await message.answer("Профиль не найден")
return
await message.answer(f"👤 Имя: {user['name']}")
name = user.get("name") or user.get("full_name", "?")
await message.answer(f"👤 Имя: {name}")
+29 -7
View File
@@ -1,23 +1,45 @@
"""Services handler - show rich service cards with images."""
from aiogram import Router, F
from aiogram.types import Message
from aiogram.types import Message, FSInputFile
from aiogram.fsm.context import FSMContext
from aiogram import Bot
from app.keyboards.menu import menu
from app.config import SERVICES, BOT_TOKEN
from app.keyboards.services import get_services_keyboard
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",
"\U0001f4cb **\u041d\u0430\u0448\u0438 \u0443\u0441\u043b\u0443\u0433\u0438**\n\n"
"\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u044e\u0449\u0443\u044e \u0443\u0441\u043b\u0443\u0433\u0443:",
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"
)
img = FSInputFile(svc["image"])
await bot().send_photo(message.chat.id, photo=img, caption=card)
except Exception as e:
logger.warning("Error sending service card for {key}: {err}", key=key, err=e)
+19 -6
View File
@@ -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 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,21 @@ 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"
)
await message.answer(greet, reply_markup=menu)
+42
View File
@@ -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
+2 -2
View File
@@ -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
)
)
+14 -7
View File
@@ -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)
+9 -8
View File
@@ -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
)
+10 -15
View File
@@ -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["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)
+29 -3
View File
@@ -5,7 +5,7 @@ import sys
from aiogram import Bot, Dispatcher
from app.config import BOT_TOKEN
from app.config import BOT_TOKEN, USE_POSTGRES
from app.logger import logger
# Import routers
@@ -33,9 +33,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 +54,28 @@ 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")
pending = asyncio.all_tasks()
if pending:
logger.debug("Cancelling {count} pending tasks", count=len(pending))
@@ -74,7 +101,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:
+58 -39
View File
@@ -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)
+20
View File
@@ -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)
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

+32
View File
@@ -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:
+4 -2
View File
@@ -1,2 +1,4 @@
aiogram
python-dotenv
aiogram==3.17.0
python-dotenv==1.1.0
loguru==0.7.3
asyncpg==0.30.0