diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74653b4 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..3d9ac89 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 62c7fa0..9aca733 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,3 @@ data/*.json logs/ backups/ .env - -# Backups -*.bak - -# Runtime logs -*.log diff --git a/README.md b/README.md deleted file mode 100644 index 230aba0..0000000 --- a/README.md +++ /dev/null @@ -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:00–18: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 — делайте что хотите. diff --git a/app/config.py b/app/config.py index 19f910a..ea6a4bc 100644 --- a/app/config.py +++ b/app/config.py @@ -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", diff --git a/app/db/postgres.py b/app/db/postgres.py index 744147c..e26a7f6 100644 --- a/app/db/postgres.py +++ b/app/db/postgres.py @@ -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"), } diff --git a/app/handlers/about.py b/app/handlers/about.py index eb7d7a1..6724f03 100644 --- a/app/handlers/about.py +++ b/app/handlers/about.py @@ -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" diff --git a/app/handlers/booking.py b/app/handlers/booking.py index 7f5df14..09157af 100644 --- a/app/handlers/booking.py +++ b/app/handlers/booking.py @@ -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() - ) + 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 2812873..67782e8 100644 --- a/app/handlers/contacts.py +++ b/app/handlers/contacts.py @@ -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" diff --git a/app/handlers/mybookings.py b/app/handlers/mybookings.py index da03078..9e7324b 100644 --- a/app/handlers/mybookings.py +++ b/app/handlers/mybookings.py @@ -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() diff --git a/app/handlers/profile.py b/app/handlers/profile.py index 66501d5..0b43ded 100644 --- a/app/handlers/profile.py +++ b/app/handlers/profile.py @@ -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) - 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}") + from app.db.memory import get_user + user = get_user(message.from_user.id) + if not user: + await message.answer("Профиль не найден") + return + name = user.get("name") or user.get("full_name", "?") + await message.answer(f"👤 Имя: {name}") diff --git a/app/handlers/services.py b/app/handlers/services.py index c275370..5ad08db 100644 --- a/app/handlers/services.py +++ b/app/handlers/services.py @@ -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) diff --git a/app/handlers/start.py b/app/handlers/start.py index b6af7ac..0d7d3a8 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -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) + await message.answer(greet, reply_markup=menu) diff --git a/app/keyboards/services.py b/app/keyboards/services.py index b60f556..90d3dac 100644 --- a/app/keyboards/services.py +++ b/app/keyboards/services.py @@ -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)]) diff --git a/app/main.py b/app/main.py index 6f42a4e..c12a385 100644 --- a/app/main.py +++ b/app/main.py @@ -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)) diff --git a/assets/cosmetology.png b/assets/cosmetology.png index fe7cc1c..8c6b413 100644 Binary files a/assets/cosmetology.png and b/assets/cosmetology.png differ diff --git a/assets/massage.png b/assets/massage.png index db3964f..7ce8c11 100644 Binary files a/assets/massage.png and b/assets/massage.png differ diff --git a/assets/spa.png b/assets/spa.png index 0085fce..1ceccb5 100644 Binary files a/assets/spa.png and b/assets/spa.png differ diff --git a/assets/welcome.png b/assets/welcome.png deleted file mode 100644 index 55bcd50..0000000 Binary files a/assets/welcome.png and /dev/null differ