1 Commits
Author SHA1 Message Date
grigorij d757352b8a Добавлена ветка cosmetics 2026-07-31 13:07:25 +04:00
19 changed files with 108 additions and 337 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
-6
View File
@@ -17,9 +17,3 @@ data/*.json
logs/
backups/
.env
# Backups
*.bak
# Runtime logs
*.log
-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 — делайте что хотите.
+3 -4
View File
@@ -6,7 +6,6 @@ load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN")
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")
@@ -19,7 +18,7 @@ USE_POSTGRES = bool(os.getenv("DB_HOST", ""))
SERVICES = {
"service_massage": {
"key": "service_massage",
"name": "\u041c\u0430\u0441\u0441\u0430\u0436",
"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",
@@ -29,7 +28,7 @@ SERVICES = {
},
"service_spa": {
"key": "service_spa",
"name": "SPA-\u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441",
"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",
@@ -39,7 +38,7 @@ SERVICES = {
},
"service_cosmetology": {
"key": "service_cosmetology",
"name": "\u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f",
"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",
+5 -17
View File
@@ -6,24 +6,12 @@ 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,
"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"),
}
+1 -1
View File
@@ -12,7 +12,7 @@ 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"
"\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"
+14 -108
View File
@@ -1,14 +1,12 @@
"""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, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.types import Message, CallbackQuery
from app.keyboards.services import get_services_keyboard, get_extra_services_keyboard
from app.keyboards.masters import get_masters_keyboard
from app.states.booking import BookingState
from app.config import SERVICES as ALL_SERVICES, ADMIN_CHAT_ID
from app.services.booking_service import (
create_booking,
update_booking,
@@ -34,17 +32,6 @@ 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):
@@ -66,14 +53,15 @@ 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):
svc_data = ALL_SERVICES[callback.data]
service_name = svc_data["name"] if isinstance(svc_data, dict) else svc_data
from app.config import SERVICES
service_name = SERVICES[callback.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=[])
@@ -94,7 +82,10 @@ 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(
"👤 Выберите специалиста:",
@@ -103,12 +94,12 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
await callback.answer()
return
# Toggle extra service
actual_key = callback.data.replace("extra_", "", 1)
svc_data = ALL_SERVICES.get(actual_key)
if not svc_data:
service_name = SERVICES.get(actual_key)
if not service_name:
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", [])
@@ -142,8 +133,7 @@ async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
async def choose_master(callback: CallbackQuery, state: FSMContext):
from app.config import MASTERS
master_data = MASTERS[callback.data]
master_name = master_data["name"] if isinstance(master_data, dict) else master_data
master_name = MASTERS[callback.data]
logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name)
await state.update_data(master=master_name)
@@ -232,14 +222,6 @@ 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("❌ Это время уже занято. Пожалуйста, выберите другое время.")
@@ -258,26 +240,6 @@ 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()
@@ -309,80 +271,24 @@ 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)
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", [])
+1 -1
View File
@@ -12,7 +12,7 @@ async def contacts(message: Message, state: FSMContext):
await state.clear()
logger.info("User {uid} requested contacts", uid=message.from_user.id)
text = (
"\U0001f4de \u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b\n\n"
"\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"
+16 -28
View File
@@ -21,36 +21,24 @@ async def my_bookings(message: Message):
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
# 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}"
for booking in bookings:
svc = booking["service"]
mst = booking["master"]
dat = booking["date"]
tim = booking["time"]
text = (
"\U0001f486 " + svc + "\n"
"\U0001f464 " + mst + "\n"
"\U0001f4c5 " + dat + "\n"
"\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"])
)
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_"))
@@ -58,7 +46,7 @@ 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)
await cancel_booking(booking_id)
await callback.message.edit_text("\u2705 Запись отменена")
await callback.message.edit_text("\u2705 \u0417\u0430\u043f\u0438\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430")
await callback.answer()
@@ -75,7 +63,7 @@ async def edit_booking_handler(callback: CallbackQuery, state: FSMContext):
booking = next((b for b in bookings if b["id"] == booking_id), None)
if not booking:
await callback.message.edit_text("Запись не найдена")
await callback.message.edit_text("\u0417\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430")
await callback.answer()
return
@@ -89,7 +77,7 @@ async def edit_booking_handler(callback: CallbackQuery, state: FSMContext):
await state.set_state(BookingState.service)
await callback.message.edit_text(
"\u270f\ufe0f Редактирование записи #" + str(booking_id) + "\n\nВыберите новую услугу:",
"\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()
+9 -18
View File
@@ -1,4 +1,4 @@
"""User profile handler - auto-registers if missing."""
"""User profile handler."""
from aiogram import Router, F
from aiogram.types import Message
from aiogram.fsm.context import FSMContext
@@ -10,25 +10,16 @@ from app.logger import logger
router = Router()
@router.message(F.text == "\U0001f464 \u041f\u0440\u043e\u0444\u0438\u043b\u044c")
@router.message(F.text == "👤 Профиль")
async def profile(message: Message, state: FSMContext):
await state.clear()
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")
user = await PostgresDB.get_user(message.from_user.id)
else:
from app.db.memory import get_user, set_user
user = get_user(user_id)
from app.db.memory import get_user
user = get_user(message.from_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}")
await message.answer("Профиль не найден")
return
name = user.get("name") or user.get("full_name", "?")
await message.answer(f"👤 Имя: {name}")
+10 -6
View File
@@ -1,10 +1,11 @@
"""Services handler - show rich service cards with images."""
from aiogram import Router, F
from aiogram.types import Message, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.types import Message, FSInputFile
from aiogram.fsm.context import FSMContext
from aiogram import Bot
from app.config import SERVICES, BOT_TOKEN
from app.keyboards.services import get_services_keyboard
from app.logger import logger
router = Router()
@@ -24,18 +25,21 @@ 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"
"\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["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)
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)
+2 -6
View File
@@ -1,7 +1,7 @@
"""Start handler with welcome greeting and prices."""
from aiogram import Router
from aiogram.filters import CommandStart
from aiogram.types import Message, FSInputFile
from aiogram.types import Message
from app.keyboards.menu import menu
from app.db.postgres import PostgresDB
@@ -25,14 +25,10 @@ async def start(message: Message):
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"
"\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)
+1 -1
View File
@@ -19,7 +19,7 @@ def get_extra_services_keyboard(already_selected=None):
buttons = []
for key, svc in SERVICES.items():
if svc["name"] in already_selected:
label = "\u2705 " + svc["emoji"] + " " + svc["name"]
label = "\u2705 " + svc["name"]
else:
label = svc["emoji"] + " " + svc["name"]
buttons.append([InlineKeyboardButton(text=label, callback_data="extra_" + key)])
+1 -11
View File
@@ -4,7 +4,6 @@ import signal
import sys
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
from app.config import BOT_TOKEN, USE_POSTGRES
from app.logger import logger
@@ -20,13 +19,7 @@ from app.handlers.contacts import router as contacts_router
from app.handlers.admin import router as admin_router
bot = Bot(token=BOT_TOKEN)
# 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)
dp = Dispatcher()
# Register routers
dp.include_routers(
@@ -83,9 +76,6 @@ async def on_shutdown():
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))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 179 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB