From 58827ac1241206dd8409003b4220c1e02a255859 Mon Sep 17 00:00:00 2001 From: akuzakhemetov Date: Thu, 25 Dec 2025 15:25:46 +0300 Subject: [PATCH] initial commit --- .env.example | 2 + .gitignore | 46 ++++ CLAUDE.md | 459 ++++++++++++++++++++++++++++++++++++++++ config.py | 13 ++ database/__init__.py | 15 ++ database/connection.py | 22 ++ database/models.py | 95 +++++++++ env.example | 7 + handlers/__init__.py | 5 + handlers/auth.py | 340 +++++++++++++++++++++++++++++ handlers/books.py | 306 +++++++++++++++++++++++++++ handlers/favorites.py | 252 ++++++++++++++++++++++ keyboards/__init__.py | 19 ++ keyboards/inline.py | 89 ++++++++ main.py | 63 ++++++ middlewares/__init__.py | 5 + middlewares/auth.py | 80 +++++++ requirements.txt | 6 + utils/__init__.py | 5 + utils/states.py | 25 +++ 20 files changed, 1854 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 config.py create mode 100644 database/__init__.py create mode 100644 database/connection.py create mode 100644 database/models.py create mode 100644 env.example create mode 100644 handlers/__init__.py create mode 100644 handlers/auth.py create mode 100644 handlers/books.py create mode 100644 handlers/favorites.py create mode 100644 keyboards/__init__.py create mode 100644 keyboards/inline.py create mode 100644 main.py create mode 100644 middlewares/__init__.py create mode 100644 middlewares/auth.py create mode 100644 requirements.txt create mode 100644 utils/__init__.py create mode 100644 utils/states.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec2a8a9 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +BOT_TOKEN= +DATABASE_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..911bafc --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ + +# Environment variables +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +*.iml + +# Logs +*.log + +# Database +*.db +*.sqlite3 + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..11e280d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,459 @@ +```markdown +# Telegram Bot "Что почитать" - Техническое задание + +## Описание проекта +Telegram-бот для просмотра книг с описаниями, обложками и рейтингами. Бот является интеграцией к существующему веб-сервису на Django с общей PostgreSQL базой данных. + +## Технологический стек +- **Python 3.10+** +- **aiogram 3.x** - асинхронный фреймворк для Telegram Bot API [web:1][web:3] +- **asyncpg** или **psycopg2** - для работы с PostgreSQL [web:6] +- **SQLAlchemy 2.0** (асинхронный режим) - ORM для работы с БД +- **python-dotenv** - для хранения конфигурации + +## Структура проекта +``` + +telegram_bot/ +├── .env \# Переменные окружения +├── .env.example \# Пример конфигурации +├── requirements.txt \# Зависимости +├── main.py \# Точка входа +├── config.py \# Конфигурация +├── database/ +│ ├── __init__.py +│ ├── connection.py \# Подключение к БД +│ └── models.py \# SQLAlchemy модели +├── handlers/ +│ ├── __init__.py +│ ├── auth.py \# Авторизация/регистрация +│ ├── books.py \# Просмотр книг +│ └── favorites.py \# Избранное +├── keyboards/ +│ ├── __init__.py +│ └── inline.py \# Inline-клавиатуры[^1][^2] +├── middlewares/ +│ ├── __init__.py +│ └── auth.py \# Проверка авторизации +└── utils/ +├── __init__.py +└── states.py \# FSM состояния + +``` + +## Схема базы данных (уже существует) + +### Таблица: users (из Django) +- `id` - PRIMARY KEY +- `username` - уникальное имя пользователя +- `email` - уникальный email (используется для входа) +- `password` - хешированный пароль +- `telegram_id` - BigInteger, уникальный, nullable +- `first_name`, `last_name` - имя и фамилия +- остальные поля Django User + +### Таблица: books_book +- `id` - PRIMARY KEY +- `title` - название книги (max 500) +- `description` - описание (TEXT) +- `cover_url` - ссылка на обложку (URL) +- `average_rating` - средний рейтинг (Decimal 0.00-5.00) +- `rating_count` - количество оценок +- `language` - язык книги +- `created_at`, `updated_at` - временные метки + +### Таблица: books_author +- `id` - PRIMARY KEY +- `name` - имя автора + +### Таблица: books_genre +- `id` - PRIMARY KEY +- `name` - название жанра + +### Таблица: books_book_authors (ManyToMany) +- `book_id` - FK на books_book +- `author_id` - FK на books_author + +### Таблица: books_book_genres (ManyToMany) +- `book_id` - FK на books_book +- `genre_id` - FK на books_genre + +### Таблица: books_favorite +- `id` - PRIMARY KEY +- `user_id` - FK на users +- `book_id` - FK на books_book +- `created_at` - дата добавления +- UNIQUE(user_id, book_id) + +## Функциональные требования + +### 1. Авторизация и регистрация +**Команда:** `/start` + +**Логика:** +- Проверить, есть ли у пользователя `telegram_id` в таблице `users` +- Если да → авторизован, показать главное меню +- Если нет → показать кнопки "Войти" и "Зарегистрироваться" + +**Регистрация:** +1. Кнопка "Зарегистрироваться" → запросить email +2. Проверить уникальность email в БД +3. Запросить username (проверить уникальность) +4. Запросить пароль (минимум 8 символов) +5. Создать запись в таблице `users` с `telegram_id = message.from_user.id` +6. Хешировать пароль через `bcrypt` или `django.contrib.auth.hashers` + +**Вход:** +1. Кнопка "Войти" → запросить email +2. Запросить пароль +3. Проверить credentials в БД +4. Если успешно → обновить `telegram_id` для этого пользователя +5. Показать главное меню + +### 2. Главное меню (после авторизации) +Inline-клавиатура [web:7]: +- 📚 Все книги +- ⭐ Избранное +- 🔍 Поиск по жанру +- 👤 Мой профиль + +### 3. Просмотр всех книг +**Запрос к БД:** +``` + +SELECT +b.id, b.title, b.description, b.cover_url, +b.average_rating, b.rating_count, +STRING_AGG(DISTINCT a.name, ', ') as authors, +STRING_AGG(DISTINCT g.name, ', ') as genres +FROM books_book b +LEFT JOIN books_book_authors ba ON b.id = ba.book_id +LEFT JOIN books_author a ON ba.author_id = a.id +LEFT JOIN books_book_genres bg ON b.id = bg.book_id +LEFT JOIN books_genre g ON bg.genre_id = g.id +WHERE b.deleted_at IS NULL +GROUP BY b.id +ORDER BY b.created_at DESC +LIMIT 10 OFFSET ? + +``` + +**Формат вывода:** +``` + +📖 [Название книги] + +✍️ Авторы: [список через запятую] +🏷 Жанры: [список через запятую] +⭐ Рейтинг: X.XX (Y оценок) + +[Описание книги, максимум 200 символов] + +[Картинка по cover_url] + +Кнопки: [❤️ В избранное] [➡️ След. страница] + +``` + +**Пагинация:** по 10 книг на страницу, кнопки "⬅️ Назад" / "➡️ Вперед" + +### 4. Избранное +**Запрос к БД:** +``` + +SELECT +b.id, b.title, b.cover_url, b.average_rating, +STRING_AGG(DISTINCT a.name, ', ') as authors +FROM books_favorite f +JOIN books_book b ON f.book_id = b.id +LEFT JOIN books_book_authors ba ON b.id = ba.book_id +LEFT JOIN books_author a ON ba.author_id = a.id +WHERE f.user_id = ? AND b.deleted_at IS NULL +GROUP BY b.id, f.created_at +ORDER BY f.created_at DESC + +``` + +**Действия:** +- Показать список избранных книг (формат как в п.3) +- Кнопка "❌ Удалить из избранного" → DELETE FROM books_favorite + +### 5. Добавление в избранное +**Callback:** `add_fav:{book_id}` + +**Запрос:** +``` + +INSERT INTO books_favorite (user_id, book_id, created_at) +VALUES (?, ?, NOW()) +ON CONFLICT (user_id, book_id) DO NOTHING + +``` + +**Ответ:** "✅ Книга добавлена в избранное!" + +### 6. Поиск по жанру +1. Получить список жанров: `SELECT id, name FROM books_genre ORDER BY name` +2. Показать inline-клавиатуру с жанрами (по 2 в строке) [web:7] +3. После выбора жанра → фильтровать книги: +``` + +SELECT DISTINCT b.* FROM books_book b +JOIN books_book_genres bg ON b.id = bg.book_id +WHERE bg.genre_id = ? AND b.deleted_at IS NULL + +``` + +## Технические требования + +### 1. Подключение к базе данных +**Файл:** `database/connection.py` + +``` + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from config import DATABASE_URL + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +async def get_session(): +async with async_session() as session: +yield session + +``` + +**Конфигурация .env:** +``` + +BOT_TOKEN=your_bot_token_here +DATABASE_URL=postgresql+asyncpg://user:password@host:port/dbname + +``` + +### 2. Middleware для проверки авторизации [web:3] +**Файл:** `middlewares/auth.py` + +``` + +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery + +class AuthMiddleware(BaseMiddleware): +async def __call__(self, handler, event, data): +\# Пропускать команду /start +if isinstance(event, Message) and event.text == '/start': +return await handler(event, data) + + # Проверить telegram_id в БД + user = await get_user_by_telegram_id(event.from_user.id) + if not user: + await event.answer("⛔ Сначала авторизуйтесь через /start") + return + + data['user'] = user + return await handler(event, data) + ``` + +### 3. FSM для авторизации +**Файл:** `utils/states.py` + +``` + +from aiogram.fsm.state import State, StatesGroup + +class RegistrationStates(StatesGroup): +waiting_email = State() +waiting_username = State() +waiting_password = State() + +class LoginStates(StatesGroup): +waiting_email = State() +waiting_password = State() + +``` + +### 4. Inline-клавиатуры [web:7][web:10] +**Файл:** `keyboards/inline.py` + +``` + +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import InlineKeyboardButton + +def get_main_menu(): +builder = InlineKeyboardBuilder() +builder.button(text="📚 Все книги", callback_data="books_all") +builder.button(text="⭐ Избранное", callback_data="favorites") +builder.button(text="🔍 Поиск по жанру", callback_data="search_genre") +builder.button(text="👤 Мой профиль", callback_data="profile") +builder.adjust(2, 2) \# 2 кнопки в ряду +return builder.as_markup() + +``` + +### 5. Обработка картинок +Использовать `bot.send_photo()` с параметром `photo=cover_url` (если URL публичный). + +Если картинка не загружается → показать текстовую заглушку: "🖼 [Обложка недоступна]" + +## Требования к коду + +1. **Асинхронность:** все функции БД и хендлеры должны быть `async/await` [web:3] +2. **Обработка ошибок:** try/except для всех запросов к БД +3. **Логирование:** использовать `logging` для отладки +4. **Типизация:** использовать type hints везде +5. **Документация:** docstrings для всех функций +6. **Безопасность:** + - Хешировать пароли через `bcrypt` + - Не логировать пароли + - SQL-инъекции предотвращаются через SQLAlchemy +7. **Простота:** избегать избыточных абстракций, код должен быть понятным + +## Примеры запросов SQLAlchemy + +**Получить пользователя:** +``` + +from sqlalchemy import select +from database.models import User + +async def get_user_by_telegram_id(telegram_id: int): +async with async_session() as session: +result = await session.execute( +select(User).where(User.telegram_id == telegram_id) +) +return result.scalar_one_or_none() + +``` + +**Получить книги с авторами (raw SQL через SQLAlchemy):** +``` + +from sqlalchemy import text + +async def get_books_with_details(offset: int = 0, limit: int = 10): +async with async_session() as session: +query = text(""" +SELECT b.id, b.title, b.description, b.cover_url, +b.average_rating, b.rating_count, +STRING_AGG(DISTINCT a.name, ', ') as authors, +STRING_AGG(DISTINCT g.name, ', ') as genres +FROM books_book b +LEFT JOIN books_book_authors ba ON b.id = ba.book_id +LEFT JOIN books_author a ON ba.author_id = a.id +LEFT JOIN books_book_genres bg ON b.id = bg.book_id +LEFT JOIN books_genre g ON bg.genre_id = g.id +WHERE b.deleted_at IS NULL +GROUP BY b.id +ORDER BY b.created_at DESC +LIMIT :limit OFFSET :offset +""") +result = await session.execute(query, {"limit": limit, "offset": offset}) +return result.mappings().all() + +``` + +## Зависимости (requirements.txt) +``` + +aiogram>=3.4.0 +asyncpg>=0.29.0 +SQLAlchemy>=2.0.0 +python-dotenv>=1.0.0 +bcrypt>=4.1.0 + +``` + +## Дополнительные указания + +1. **Начните с файла `main.py`** - инициализация бота, диспетчера, подключение роутеров [web:8] +2. **Используйте роутеры aiogram** для группировки хендлеров по функциональности [web:2] +3. **Тестируйте каждую функцию по отдельности** перед интеграцией +4. **Используйте callback_data формата:** `action:param` (например, `add_fav:123`) +5. **Обрабатывайте длинные сообщения:** если описание книги > 1024 символов, обрезать с "..." +6. **Escape специальных символов** в Markdown, если используете `parse_mode='Markdown'` + +## Важно + +- НЕ модифицировать существующую Django БД +- НЕ создавать новые таблицы +- Использовать только SELECT, INSERT, UPDATE, DELETE для существующих таблиц +- Следовать naming conventions Django (таблицы `app_model`, например `books_book`) +- При обновлении `telegram_id` проверять уникальность (один Telegram аккаунт = один пользователь) + +## Пример main.py +``` + +import asyncio +import logging +from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage +from config import BOT_TOKEN +from handlers import auth, books, favorites +from middlewares.auth import AuthMiddleware + +logging.basicConfig(level=logging.INFO) + +async def main(): +bot = Bot(token=BOT_TOKEN) +dp = Dispatcher(storage=MemoryStorage()) + + # Регистрация middleware + dp.message.middleware(AuthMiddleware()) + dp.callback_query.middleware(AuthMiddleware()) + + # Регистрация роутеров + dp.include_router(auth.router) + dp.include_router(books.router) + dp.include_router(favorites.router) + + await bot.delete_webhook(drop_pending_updates=True) + await dp.start_polling(bot) + if __name__ == '__main__': +asyncio.run(main()) + +``` + +Начни реализацию с базовой структуры проекта и авторизации! +``` + +Этот MD-файл содержит полную спецификацию для Cursor. Он включает:[^3][^4][^5] + +- Четкую структуру проекта с организацией файлов[^6] +- Схему существующей Django БД (на основе ваших моделей) +- Детальные функциональные требования для каждой фичи +- Примеры SQL-запросов и SQLAlchemy кода[^7] +- Инструкции по работе с inline-клавиатурами aiogram[^2][^1] +- Требования к асинхронности и middleware[^5] +- Готовый пример точки входа приложения + +Просто скопируйте этот контент в `.cursorrules` или отдельный MD-файл в корне проекта, и Cursor сможет генерировать код согласно спецификации! +[^10][^8][^9] + +
+ +[^1]: https://docs.aiogram.dev/en/latest/utils/keyboard.html + +[^2]: https://stackoverflow.com/questions/67652875/how-to-make-an-inline-keyboard-in-aiogram + +[^3]: https://habr.com/ru/articles/953902/ + +[^4]: https://github.com/MasterGroosha/aiogram-3-guide + +[^5]: https://aiogram.dev + +[^6]: https://www.youtube.com/watch?v=wRatZLMa4oE + +[^7]: https://stackoverflow.com/questions/75900203/how-do-i-connect-my-telegram-bot-telebot-to-postgresql-url + +[^8]: https://www.youtube.com/watch?v=LufjNEv0OuQ + +[^9]: https://www.youtube.com/watch?v=mNrqcTl13Wg + +[^10]: https://www.youtube.com/watch?v=z5JT-bPdusY + +## Дополнительные указания + +- Не пиши тесты +- Не пиши объясняющие файлы любого рода(.txt,.md и тд.) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..0aa2318 --- /dev/null +++ b/config.py @@ -0,0 +1,13 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +BOT_TOKEN = os.getenv("BOT_TOKEN") +DATABASE_URL = os.getenv("DATABASE_URL") + +if not BOT_TOKEN: + raise ValueError("BOT_TOKEN не найден в .env файле") +if not DATABASE_URL: + raise ValueError("DATABASE_URL не найден в .env файле") + diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..100f803 --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,15 @@ +"""Модуль для работы с базой данных""" +from database.connection import get_session, async_session_maker, engine +from database.models import User, Book, Author, Genre, Favorite + +__all__ = [ + 'get_session', + 'async_session_maker', + 'engine', + 'User', + 'Book', + 'Author', + 'Genre', + 'Favorite' +] + diff --git a/database/connection.py b/database/connection.py new file mode 100644 index 0000000..fd4f500 --- /dev/null +++ b/database/connection.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from config import DATABASE_URL +import logging + +logger = logging.getLogger(__name__) + +engine = create_async_engine(DATABASE_URL, echo=False, pool_pre_ping=True) +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_session() -> AsyncSession: + """Получить асинхронную сессию БД""" + async with async_session_maker() as session: + try: + yield session + except Exception as e: + logger.error(f"Ошибка в сессии БД: {e}") + await session.rollback() + raise + finally: + await session.close() + diff --git a/database/models.py b/database/models.py new file mode 100644 index 0000000..fdea460 --- /dev/null +++ b/database/models.py @@ -0,0 +1,95 @@ +from sqlalchemy import BigInteger, String, Text, Numeric, Integer, DateTime, Boolean, ForeignKey, Table, Column +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional, List + + +class Base(DeclarativeBase): + pass + + +# Many-to-Many таблицы +books_book_authors = Table( + 'books_book_authors', + Base.metadata, + Column('id', Integer, primary_key=True), + Column('book_id', Integer, ForeignKey('books_book.id')), + Column('author_id', Integer, ForeignKey('books_author.id')) +) + +books_book_genres = Table( + 'books_book_genres', + Base.metadata, + Column('id', Integer, primary_key=True), + Column('book_id', Integer, ForeignKey('books_book.id')), + Column('genre_id', Integer, ForeignKey('books_genre.id')) +) + + +class User(Base): + __tablename__ = 'auth_user' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String(150), unique=True) + email: Mapped[str] = mapped_column(String(254), unique=True) + password: Mapped[str] = mapped_column(String(128)) + telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, unique=True, nullable=True) + first_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) + last_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_staff: Mapped[bool] = mapped_column(Boolean, default=False) + is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) + date_joined: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + favorites: Mapped[List["Favorite"]] = relationship(back_populates="user") + + +class Author(Base): + __tablename__ = 'books_author' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + + books: Mapped[List["Book"]] = relationship(secondary=books_book_authors, back_populates="authors") + + +class Genre(Base): + __tablename__ = 'books_genre' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + + books: Mapped[List["Book"]] = relationship(secondary=books_book_genres, back_populates="genres") + + +class Book(Base): + __tablename__ = 'books_book' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(500)) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + cover_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + average_rating: Mapped[Optional[float]] = mapped_column(Numeric(3, 2), nullable=True) + rating_count: Mapped[int] = mapped_column(Integer, default=0) + language: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + authors: Mapped[List[Author]] = relationship(secondary=books_book_authors, back_populates="books") + genres: Mapped[List[Genre]] = relationship(secondary=books_book_genres, back_populates="books") + favorites: Mapped[List["Favorite"]] = relationship(back_populates="book") + + +class Favorite(Base): + __tablename__ = 'books_favorite' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey('auth_user.id')) + book_id: Mapped[int] = mapped_column(Integer, ForeignKey('books_book.id')) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + user: Mapped[User] = relationship(back_populates="favorites") + book: Mapped[Book] = relationship(back_populates="favorites") + diff --git a/env.example b/env.example new file mode 100644 index 0000000..6e09ae1 --- /dev/null +++ b/env.example @@ -0,0 +1,7 @@ +# Telegram Bot Token (получить у @BotFather) +BOT_TOKEN=your_bot_token_here + +# PostgreSQL Database URL +# Формат: postgresql+asyncpg://username:password@host:port/database_name +DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/dbname + diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..a03cf8a --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1,5 @@ +"""Модуль обработчиков событий бота""" +from handlers import auth, books, favorites + +__all__ = ['auth', 'books', 'favorites'] + diff --git a/handlers/auth.py b/handlers/auth.py new file mode 100644 index 0000000..655ea3d --- /dev/null +++ b/handlers/auth.py @@ -0,0 +1,340 @@ +from aiogram import Router, F +from aiogram.filters import CommandStart +from aiogram.types import Message, CallbackQuery +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database.models import User +from database.connection import async_session_maker +from utils.states import RegistrationStates, LoginStates +from keyboards.inline import get_auth_menu, get_main_menu, get_back_to_menu_keyboard +import bcrypt +import logging +import re + +router = Router() +logger = logging.getLogger(__name__) + + +@router.message(CommandStart()) +async def cmd_start(message: Message, state: FSMContext): + """Обработчик команды /start""" + await state.clear() + + telegram_id = message.from_user.id + + try: + async with async_session_maker() as session: + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if user: + # Пользователь авторизован + await message.answer( + f"👋 Добро пожаловать, {user.first_name or user.username}!\n\n" + "📚 Выберите действие:", + reply_markup=get_main_menu() + ) + else: + # Пользователь не авторизован + await message.answer( + "👋 Добро пожаловать в бот 'Что почитать'!\n\n" + "📚 Здесь вы можете просматривать книги с описаниями, обложками и рейтингами.\n\n" + "Пожалуйста, войдите или зарегистрируйтесь:", + reply_markup=get_auth_menu() + ) + except Exception as e: + logger.error(f"Ошибка в cmd_start: {e}") + await message.answer("❌ Произошла ошибка. Попробуйте позже.") + + +@router.callback_query(F.data == "main_menu") +async def show_main_menu(callback: CallbackQuery, state: FSMContext): + """Показать главное меню""" + await state.clear() + telegram_id = callback.from_user.id + + try: + async with async_session_maker() as session: + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if user: + await callback.message.edit_text( + f"👋 Добро пожаловать, {user.first_name or user.username}!\n\n" + "📚 Выберите действие:", + reply_markup=get_main_menu() + ) + else: + await callback.message.edit_text( + "👋 Добро пожаловать в бот 'Что почитать'!\n\n" + "Пожалуйста, войдите или зарегистрируйтесь:", + reply_markup=get_auth_menu() + ) + await callback.answer() + except Exception as e: + logger.error(f"Ошибка в show_main_menu: {e}") + await callback.answer("❌ Ошибка", show_alert=True) + + +# === РЕГИСТРАЦИЯ === + +@router.callback_query(F.data == "register") +async def start_registration(callback: CallbackQuery, state: FSMContext): + """Начало регистрации""" + await state.set_state(RegistrationStates.waiting_email) + await callback.message.edit_text( + "📝 Регистрация\n\n" + "Введите ваш email:" + ) + await callback.answer() + + +@router.message(RegistrationStates.waiting_email) +async def process_registration_email(message: Message, state: FSMContext): + """Обработка email при регистрации""" + email = message.text.strip().lower() + + # Валидация email + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_pattern, email): + await message.answer("❌ Неверный формат email. Попробуйте снова:") + return + + try: + async with async_session_maker() as session: + # Проверка уникальности email + result = await session.execute( + select(User).where(User.email == email) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + await message.answer( + "❌ Этот email уже зарегистрирован.\n\n" + "Используйте другой email или войдите в систему." + ) + await state.clear() + return + + await state.update_data(email=email) + await state.set_state(RegistrationStates.waiting_username) + await message.answer("✅ Email принят.\n\nТеперь введите желаемое имя пользователя (username):") + + except Exception as e: + logger.error(f"Ошибка при проверке email: {e}") + await message.answer("❌ Произошла ошибка. Попробуйте снова.") + await state.clear() + + +@router.message(RegistrationStates.waiting_username) +async def process_registration_username(message: Message, state: FSMContext): + """Обработка username при регистрации""" + username = message.text.strip() + + # Валидация username + if len(username) < 3 or len(username) > 150: + await message.answer("❌ Имя пользователя должно быть от 3 до 150 символов. Попробуйте снова:") + return + + if not re.match(r'^[a-zA-Z0-9_]+$', username): + await message.answer("❌ Имя пользователя может содержать только буквы, цифры и подчеркивание. Попробуйте снова:") + return + + try: + async with async_session_maker() as session: + # Проверка уникальности username + result = await session.execute( + select(User).where(User.username == username) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + await message.answer("❌ Это имя пользователя уже занято. Попробуйте другое:") + return + + await state.update_data(username=username) + await state.set_state(RegistrationStates.waiting_password) + await message.answer( + "✅ Имя пользователя принято.\n\n" + "Теперь введите пароль (минимум 8 символов):" + ) + + except Exception as e: + logger.error(f"Ошибка при проверке username: {e}") + await message.answer("❌ Произошла ошибка. Попробуйте снова.") + await state.clear() + + +@router.message(RegistrationStates.waiting_password) +async def process_registration_password(message: Message, state: FSMContext): + """Обработка пароля и завершение регистрации""" + password = message.text.strip() + + # Валидация пароля + if len(password) < 8: + await message.answer("❌ Пароль должен содержать минимум 8 символов. Попробуйте снова:") + return + + try: + # Удаляем сообщение с паролем из чата + await message.delete() + except: + pass + + try: + data = await state.get_data() + email = data['email'] + username = data['username'] + + # Хешируем пароль + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + async with async_session_maker() as session: + # Создаем нового пользователя + new_user = User( + email=email, + username=username, + password=hashed_password, + telegram_id=message.from_user.id, + first_name=message.from_user.first_name or "", + last_name=message.from_user.last_name or "", + is_active=True + ) + + session.add(new_user) + await session.commit() + + await message.answer( + f"✅ Регистрация успешно завершена!\n\n" + f"👤 Ваш username: {username}\n" + f"📧 Email: {email}\n\n" + "Добро пожаловать в бот 'Что почитать'!", + reply_markup=get_main_menu() + ) + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при создании пользователя: {e}") + await message.answer("❌ Произошла ошибка при регистрации. Попробуйте снова.") + await state.clear() + + +# === ВХОД === + +@router.callback_query(F.data == "login") +async def start_login(callback: CallbackQuery, state: FSMContext): + """Начало входа""" + await state.set_state(LoginStates.waiting_email) + await callback.message.edit_text( + "🔐 Вход в систему\n\n" + "Введите ваш email:" + ) + await callback.answer() + + +@router.message(LoginStates.waiting_email) +async def process_login_email(message: Message, state: FSMContext): + """Обработка email при входе""" + email = message.text.strip().lower() + + await state.update_data(email=email) + await state.set_state(LoginStates.waiting_password) + await message.answer("Теперь введите ваш пароль:") + + +@router.message(LoginStates.waiting_password) +async def process_login_password(message: Message, state: FSMContext): + """Обработка пароля и завершение входа""" + password = message.text.strip() + + try: + # Удаляем сообщение с паролем + await message.delete() + except: + pass + + try: + data = await state.get_data() + email = data['email'] + + async with async_session_maker() as session: + result = await session.execute( + select(User).where(User.email == email) + ) + user = result.scalar_one_or_none() + + if not user: + await message.answer("❌ Пользователь с таким email не найден.") + await state.clear() + return + + # Проверяем пароль + if not bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')): + await message.answer("❌ Неверный пароль.") + await state.clear() + return + + # Проверяем, не привязан ли уже другой Telegram аккаунт + if user.telegram_id and user.telegram_id != message.from_user.id: + await message.answer("❌ Этот аккаунт уже привязан к другому Telegram профилю.") + await state.clear() + return + + # Обновляем telegram_id + user.telegram_id = message.from_user.id + await session.commit() + + await message.answer( + f"✅ Вход выполнен успешно!\n\n" + f"👋 Добро пожаловать, {user.first_name or user.username}!", + reply_markup=get_main_menu() + ) + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при входе: {e}") + await message.answer("❌ Произошла ошибка при входе. Попробуйте снова.") + await state.clear() + + +# === ПРОФИЛЬ === + +@router.callback_query(F.data == "profile") +async def show_profile(callback: CallbackQuery): + """Показать профиль пользователя""" + telegram_id = callback.from_user.id + + try: + async with async_session_maker() as session: + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if not user: + await callback.answer("⛔ Пользователь не найден", show_alert=True) + return + + profile_text = ( + f"👤 Ваш профиль\n\n" + f"🆔 Username: {user.username}\n" + f"📧 Email: {user.email}\n" + f"👤 Имя: {user.first_name or 'Не указано'}\n" + f"📅 Дата регистрации: {user.date_joined.strftime('%d.%m.%Y')}" + ) + + await callback.message.edit_text( + profile_text, + reply_markup=get_back_to_menu_keyboard() + ) + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка в show_profile: {e}") + await callback.answer("❌ Ошибка", show_alert=True) + diff --git a/handlers/books.py b/handlers/books.py new file mode 100644 index 0000000..ef335fd --- /dev/null +++ b/handlers/books.py @@ -0,0 +1,306 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery, InputMediaPhoto +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, text, func +from database.models import Book, Author, Genre, Favorite, User +from database.connection import async_session_maker +from keyboards.inline import get_book_keyboard, get_pagination_keyboard, get_genres_keyboard, get_main_menu +import logging + +router = Router() +logger = logging.getLogger(__name__) + +BOOKS_PER_PAGE = 10 + + +async def get_books_with_details(session: AsyncSession, offset: int = 0, limit: int = 10, genre_id: int = None): + """Получить книги с авторами и жанрами""" + try: + if genre_id: + query = text(""" + SELECT DISTINCT + b.id, b.title, b.description, b.cover_url, + b.average_rating, b.rating_count, + STRING_AGG(DISTINCT a.name, ', ') as authors, + STRING_AGG(DISTINCT g.name, ', ') as genres + FROM books_book b + LEFT JOIN books_book_authors ba ON b.id = ba.book_id + LEFT JOIN books_author a ON ba.author_id = a.id + LEFT JOIN books_book_genres bg ON b.id = bg.book_id + LEFT JOIN books_genre g ON bg.genre_id = g.id + WHERE b.deleted_at IS NULL + AND b.id IN ( + SELECT book_id FROM books_book_genres WHERE genre_id = :genre_id + ) + GROUP BY b.id + ORDER BY b.created_at DESC + LIMIT :limit OFFSET :offset + """) + result = await session.execute(query, {"limit": limit, "offset": offset, "genre_id": genre_id}) + else: + query = text(""" + SELECT + b.id, b.title, b.description, b.cover_url, + b.average_rating, b.rating_count, + STRING_AGG(DISTINCT a.name, ', ') as authors, + STRING_AGG(DISTINCT g.name, ', ') as genres + FROM books_book b + LEFT JOIN books_book_authors ba ON b.id = ba.book_id + LEFT JOIN books_author a ON ba.author_id = a.id + LEFT JOIN books_book_genres bg ON b.id = bg.book_id + LEFT JOIN books_genre g ON bg.genre_id = g.id + WHERE b.deleted_at IS NULL + GROUP BY b.id + ORDER BY b.created_at DESC + LIMIT :limit OFFSET :offset + """) + result = await session.execute(query, {"limit": limit, "offset": offset}) + + return result.mappings().all() + except Exception as e: + logger.error(f"Ошибка при получении книг: {e}") + return [] + + +async def get_total_books_count(session: AsyncSession, genre_id: int = None) -> int: + """Получить общее количество книг""" + try: + if genre_id: + query = text(""" + SELECT COUNT(DISTINCT b.id) + FROM books_book b + JOIN books_book_genres bg ON b.id = bg.book_id + WHERE b.deleted_at IS NULL AND bg.genre_id = :genre_id + """) + result = await session.execute(query, {"genre_id": genre_id}) + else: + query = text(""" + SELECT COUNT(*) + FROM books_book + WHERE deleted_at IS NULL + """) + result = await session.execute(query) + + return result.scalar() or 0 + except Exception as e: + logger.error(f"Ошибка при подсчете книг: {e}") + return 0 + + +async def is_book_favorite(session: AsyncSession, user_id: int, book_id: int) -> bool: + """Проверить, находится ли книга в избранном""" + try: + result = await session.execute( + select(Favorite).where( + Favorite.user_id == user_id, + Favorite.book_id == book_id + ) + ) + return result.scalar_one_or_none() is not None + except Exception as e: + logger.error(f"Ошибка при проверке избранного: {e}") + return False + + +def format_book_message(book: dict, is_favorite: bool = False) -> str: + """Форматировать сообщение о книге""" + title = book['title'] + authors = book['authors'] or 'Не указано' + genres = book['genres'] or 'Не указано' + rating = float(book['average_rating']) if book['average_rating'] else 0.0 + rating_count = book['rating_count'] or 0 + description = book['description'] or 'Описание отсутствует' + + # Обрезаем описание если оно слишком длинное + if len(description) > 500: + description = description[:497] + "..." + + # Формируем звездочки для рейтинга + stars = "⭐" * int(rating) + + message = ( + f"📖 {title}\n\n" + f"✍️ Авторы: {authors}\n" + f"🏷 Жанры: {genres}\n" + f"{stars} Рейтинг: {rating:.2f} ({rating_count} оценок)\n\n" + f"{description}" + ) + + if is_favorite: + message = "❤️ " + message + + return message + + +@router.callback_query(F.data == "books_all") +async def show_all_books(callback: CallbackQuery, state: FSMContext): + """Показать все книги (первая страница)""" + await state.clear() + await show_books_page(callback, 0) + + +@router.callback_query(F.data.startswith("books_page:")) +async def handle_books_pagination(callback: CallbackQuery): + """Обработка пагинации книг""" + page = int(callback.data.split(":")[1]) + await show_books_page(callback, page) + + +async def show_books_page(callback: CallbackQuery, page: int, genre_id: int = None): + """Показать страницу с книгами""" + telegram_id = callback.from_user.id + + try: + async with async_session_maker() as session: + # Получаем пользователя + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if not user: + await callback.answer("⛔ Пользователь не найден", show_alert=True) + return + + # Получаем общее количество книг + total_books = await get_total_books_count(session, genre_id) + + if total_books == 0: + await callback.message.edit_text( + "📚 Книги не найдены", + reply_markup=get_main_menu() + ) + await callback.answer() + return + + total_pages = (total_books + BOOKS_PER_PAGE - 1) // BOOKS_PER_PAGE + + # Проверяем корректность страницы + if page < 0 or page >= total_pages: + await callback.answer("❌ Страница не найдена", show_alert=True) + return + + # Получаем книги для страницы + books = await get_books_with_details( + session, + offset=page * BOOKS_PER_PAGE, + limit=BOOKS_PER_PAGE, + genre_id=genre_id + ) + + if not books: + await callback.answer("❌ Книги не найдены", show_alert=True) + return + + # Показываем первую книгу на странице + book = books[0] + is_favorite = await is_book_favorite(session, user.id, book['id']) + + message_text = format_book_message(book, is_favorite) + keyboard = get_book_keyboard(book['id'], is_favorite, page, total_pages) + + # Пытаемся отправить с картинкой + if book['cover_url']: + try: + if callback.message.photo: + await callback.message.edit_media( + media=InputMediaPhoto( + media=book['cover_url'], + caption=message_text, + parse_mode="HTML" + ), + reply_markup=keyboard + ) + else: + await callback.message.delete() + await callback.message.answer_photo( + photo=book['cover_url'], + caption=message_text, + parse_mode="HTML", + reply_markup=keyboard + ) + except Exception as e: + logger.warning(f"Не удалось загрузить обложку: {e}") + await callback.message.edit_text( + "🖼 [Обложка недоступна]\n\n" + message_text, + parse_mode="HTML", + reply_markup=keyboard + ) + else: + await callback.message.edit_text( + message_text, + parse_mode="HTML", + reply_markup=keyboard + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка в show_books_page: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +# === ПОИСК ПО ЖАНРУ === + +@router.callback_query(F.data == "search_genre") +async def show_genres(callback: CallbackQuery): + """Показать список жанров""" + try: + async with async_session_maker() as session: + result = await session.execute( + select(Genre.id, Genre.name).order_by(Genre.name) + ) + genres = result.all() + + if not genres: + await callback.message.edit_text( + "📚 Жанры не найдены", + reply_markup=get_main_menu() + ) + await callback.answer() + return + + await callback.message.edit_text( + "🔍 Выберите жанр для поиска книг:", + reply_markup=get_genres_keyboard(genres) + ) + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка в show_genres: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data.startswith("genre:")) +async def show_books_by_genre(callback: CallbackQuery, state: FSMContext): + """Показать книги выбранного жанра""" + genre_id = int(callback.data.split(":")[1]) + + try: + async with async_session_maker() as session: + # Получаем название жанра + result = await session.execute( + select(Genre).where(Genre.id == genre_id) + ) + genre = result.scalar_one_or_none() + + if not genre: + await callback.answer("❌ Жанр не найден", show_alert=True) + return + + # Сохраняем genre_id в состоянии для пагинации + await state.update_data(genre_id=genre_id) + + await show_books_page(callback, 0, genre_id) + + except Exception as e: + logger.error(f"Ошибка в show_books_by_genre: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data == "page_info") +async def page_info_handler(callback: CallbackQuery): + """Обработчик для информационной кнопки страницы""" + await callback.answer("ℹ️ Текущая страница") + diff --git a/handlers/favorites.py b/handlers/favorites.py new file mode 100644 index 0000000..19a74e1 --- /dev/null +++ b/handlers/favorites.py @@ -0,0 +1,252 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery, InputMediaPhoto +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, text, delete +from database.models import Book, Favorite, User +from database.connection import async_session_maker +from keyboards.inline import get_book_keyboard, get_main_menu +from handlers.books import format_book_message, is_book_favorite +import logging + +router = Router() +logger = logging.getLogger(__name__) + + +@router.callback_query(F.data.startswith("add_fav:")) +async def add_to_favorites(callback: CallbackQuery): + """Добавить книгу в избранное""" + book_id = int(callback.data.split(":")[1]) + telegram_id = callback.from_user.id + + try: + async with async_session_maker() as session: + # Получаем пользователя + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if not user: + await callback.answer("⛔ Пользователь не найден", show_alert=True) + return + + # Проверяем, существует ли книга + result = await session.execute( + select(Book).where(Book.id == book_id, Book.deleted_at.is_(None)) + ) + book = result.scalar_one_or_none() + + if not book: + await callback.answer("❌ Книга не найдена", show_alert=True) + return + + # Проверяем, не добавлена ли уже + result = await session.execute( + select(Favorite).where( + Favorite.user_id == user.id, + Favorite.book_id == book_id + ) + ) + existing_favorite = result.scalar_one_or_none() + + if existing_favorite: + await callback.answer("ℹ️ Книга уже в избранном", show_alert=True) + return + + # Добавляем в избранное + new_favorite = Favorite( + user_id=user.id, + book_id=book_id + ) + session.add(new_favorite) + await session.commit() + + # Обновляем клавиатуру + try: + current_markup = callback.message.reply_markup + # Извлекаем номер страницы из существующей клавиатуры + page = 0 + total_pages = 1 + + if current_markup and current_markup.inline_keyboard: + for row in current_markup.inline_keyboard: + for button in row: + if button.callback_data and "books_page:" in button.callback_data: + parts = button.callback_data.split(":") + if len(parts) > 1: + page = int(parts[1]) + elif button.text and "/" in button.text: + try: + pages_info = button.text.split("/") + page = int(pages_info[0]) - 1 + total_pages = int(pages_info[1]) + except: + pass + + new_keyboard = get_book_keyboard(book_id, True, page, total_pages) + await callback.message.edit_reply_markup(reply_markup=new_keyboard) + except Exception as e: + logger.warning(f"Не удалось обновить клавиатуру: {e}") + + await callback.answer("✅ Книга добавлена в избранное!") + + except Exception as e: + logger.error(f"Ошибка при добавлении в избранное: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data.startswith("remove_fav:")) +async def remove_from_favorites(callback: CallbackQuery): + """Удалить книгу из избранного""" + book_id = int(callback.data.split(":")[1]) + telegram_id = callback.from_user.id + + try: + async with async_session_maker() as session: + # Получаем пользователя + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if not user: + await callback.answer("⛔ Пользователь не найден", show_alert=True) + return + + # Удаляем из избранного + await session.execute( + delete(Favorite).where( + Favorite.user_id == user.id, + Favorite.book_id == book_id + ) + ) + await session.commit() + + # Обновляем клавиатуру + try: + current_markup = callback.message.reply_markup + page = 0 + total_pages = 1 + + if current_markup and current_markup.inline_keyboard: + for row in current_markup.inline_keyboard: + for button in row: + if button.callback_data and "books_page:" in button.callback_data: + parts = button.callback_data.split(":") + if len(parts) > 1: + page = int(parts[1]) + elif button.text and "/" in button.text: + try: + pages_info = button.text.split("/") + page = int(pages_info[0]) - 1 + total_pages = int(pages_info[1]) + except: + pass + + new_keyboard = get_book_keyboard(book_id, False, page, total_pages) + await callback.message.edit_reply_markup(reply_markup=new_keyboard) + except Exception as e: + logger.warning(f"Не удалось обновить клавиатуру: {e}") + + await callback.answer("❌ Книга удалена из избранного") + + except Exception as e: + logger.error(f"Ошибка при удалении из избранного: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + + +@router.callback_query(F.data == "favorites") +async def show_favorites(callback: CallbackQuery): + """Показать избранные книги""" + telegram_id = callback.from_user.id + + try: + async with async_session_maker() as session: + # Получаем пользователя + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if not user: + await callback.answer("⛔ Пользователь не найден", show_alert=True) + return + + # Получаем избранные книги с полной информацией + query = text(""" + SELECT + b.id, b.title, b.description, b.cover_url, + b.average_rating, b.rating_count, + STRING_AGG(DISTINCT a.name, ', ') as authors, + STRING_AGG(DISTINCT g.name, ', ') as genres + FROM books_favorite f + JOIN books_book b ON f.book_id = b.id + LEFT JOIN books_book_authors ba ON b.id = ba.book_id + LEFT JOIN books_author a ON ba.author_id = a.id + LEFT JOIN books_book_genres bg ON b.id = bg.book_id + LEFT JOIN books_genre g ON bg.genre_id = g.id + WHERE f.user_id = :user_id AND b.deleted_at IS NULL + GROUP BY b.id, f.created_at + ORDER BY f.created_at DESC + LIMIT 1 + """) + + result = await session.execute(query, {"user_id": user.id}) + favorites = result.mappings().all() + + if not favorites: + await callback.message.edit_text( + "⭐ У вас пока нет избранных книг.\n\n" + "Добавьте книги в избранное, чтобы быстро находить их здесь!", + reply_markup=get_main_menu() + ) + await callback.answer() + return + + # Показываем первую избранную книгу + book = favorites[0] + message_text = format_book_message(book, is_favorite=True) + + # Создаем клавиатуру (всегда с кнопкой удаления из избранного) + keyboard = get_book_keyboard(book['id'], True, 0, 1) + + # Отправляем с картинкой если есть + if book['cover_url']: + try: + if callback.message.photo: + await callback.message.edit_media( + media=InputMediaPhoto( + media=book['cover_url'], + caption=message_text, + parse_mode="HTML" + ), + reply_markup=keyboard + ) + else: + await callback.message.delete() + await callback.message.answer_photo( + photo=book['cover_url'], + caption=message_text, + parse_mode="HTML", + reply_markup=keyboard + ) + except Exception as e: + logger.warning(f"Не удалось загрузить обложку: {e}") + await callback.message.edit_text( + "🖼 [Обложка недоступна]\n\n" + message_text, + parse_mode="HTML", + reply_markup=keyboard + ) + else: + await callback.message.edit_text( + message_text, + parse_mode="HTML", + reply_markup=keyboard + ) + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка в show_favorites: {e}") + await callback.answer("❌ Произошла ошибка", show_alert=True) + diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 0000000..f9004d9 --- /dev/null +++ b/keyboards/__init__.py @@ -0,0 +1,19 @@ +"""Модуль клавиатур бота""" +from keyboards.inline import ( + get_auth_menu, + get_main_menu, + get_book_keyboard, + get_pagination_keyboard, + get_genres_keyboard, + get_back_to_menu_keyboard +) + +__all__ = [ + 'get_auth_menu', + 'get_main_menu', + 'get_book_keyboard', + 'get_pagination_keyboard', + 'get_genres_keyboard', + 'get_back_to_menu_keyboard' +] + diff --git a/keyboards/inline.py b/keyboards/inline.py new file mode 100644 index 0000000..447afbb --- /dev/null +++ b/keyboards/inline.py @@ -0,0 +1,89 @@ +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from typing import List, Tuple + + +def get_auth_menu() -> InlineKeyboardMarkup: + """Меню авторизации для неавторизованных пользователей""" + builder = InlineKeyboardBuilder() + builder.button(text="🔐 Войти", callback_data="login") + builder.button(text="📝 Зарегистрироваться", callback_data="register") + builder.adjust(1) + return builder.as_markup() + + +def get_main_menu() -> InlineKeyboardMarkup: + """Главное меню для авторизованных пользователей""" + builder = InlineKeyboardBuilder() + builder.button(text="📚 Все книги", callback_data="books_all") + builder.button(text="⭐ Избранное", callback_data="favorites") + builder.button(text="🔍 Поиск по жанру", callback_data="search_genre") + builder.button(text="👤 Мой профиль", callback_data="profile") + builder.adjust(2, 2) + return builder.as_markup() + + +def get_book_keyboard(book_id: int, is_favorite: bool = False, page: int = 0, total_pages: int = 1) -> InlineKeyboardMarkup: + """Клавиатура для отдельной книги с пагинацией""" + builder = InlineKeyboardBuilder() + + # Кнопка добавления/удаления из избранного + if is_favorite: + builder.button(text="❌ Удалить из избранного", callback_data=f"remove_fav:{book_id}") + else: + builder.button(text="❤️ В избранное", callback_data=f"add_fav:{book_id}") + + # Кнопки пагинации + nav_buttons = [] + if page > 0: + nav_buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"books_page:{page-1}")) + + nav_buttons.append(InlineKeyboardButton(text=f"{page+1}/{total_pages}", callback_data="page_info")) + + if page < total_pages - 1: + nav_buttons.append(InlineKeyboardButton(text="➡️ Вперед", callback_data=f"books_page:{page+1}")) + + builder.row(*nav_buttons) + builder.button(text="🏠 Главное меню", callback_data="main_menu") + + builder.adjust(1, len(nav_buttons), 1) + return builder.as_markup() + + +def get_pagination_keyboard(page: int, total_pages: int, prefix: str = "books_page") -> InlineKeyboardMarkup: + """Общая клавиатура пагинации""" + builder = InlineKeyboardBuilder() + + nav_buttons = [] + if page > 0: + nav_buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"{prefix}:{page-1}")) + + nav_buttons.append(InlineKeyboardButton(text=f"{page+1}/{total_pages}", callback_data="page_info")) + + if page < total_pages - 1: + nav_buttons.append(InlineKeyboardButton(text="➡️ Вперед", callback_data=f"{prefix}:{page+1}")) + + builder.row(*nav_buttons) + builder.button(text="🏠 Главное меню", callback_data="main_menu") + + return builder.as_markup() + + +def get_genres_keyboard(genres: List[Tuple[int, str]]) -> InlineKeyboardMarkup: + """Клавиатура с жанрами (по 2 в строке)""" + builder = InlineKeyboardBuilder() + + for genre_id, genre_name in genres: + builder.button(text=genre_name, callback_data=f"genre:{genre_id}") + + builder.button(text="🏠 Главное меню", callback_data="main_menu") + builder.adjust(2) + return builder.as_markup() + + +def get_back_to_menu_keyboard() -> InlineKeyboardMarkup: + """Простая клавиатура с возвратом в меню""" + builder = InlineKeyboardBuilder() + builder.button(text="🏠 Главное меню", callback_data="main_menu") + return builder.as_markup() + diff --git a/main.py b/main.py new file mode 100644 index 0000000..e3d7ca0 --- /dev/null +++ b/main.py @@ -0,0 +1,63 @@ +import asyncio +import logging +from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from config import BOT_TOKEN +from handlers import auth, books, favorites +from middlewares.auth import DatabaseMiddleware + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +async def main(): + """Главная функция запуска бота""" + logger.info("Запуск бота...") + + # Инициализация бота и диспетчера + bot = Bot( + token=BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML) + ) + dp = Dispatcher(storage=MemoryStorage()) + + # Регистрация middleware + # DatabaseMiddleware предоставляет сессию БД для всех хендлеров + dp.message.middleware(DatabaseMiddleware()) + dp.callback_query.middleware(DatabaseMiddleware()) + + # Регистрация роутеров + # Порядок важен: auth должен быть первым, так как обрабатывает /start + dp.include_router(auth.router) + dp.include_router(books.router) + dp.include_router(favorites.router) + + # Удаление вебхука и очистка pending updates + await bot.delete_webhook(drop_pending_updates=True) + + logger.info("Бот успешно запущен и готов к работе!") + + # Запуск polling + try: + await dp.start_polling(bot) + except Exception as e: + logger.error(f"Ошибка при работе бота: {e}") + finally: + await bot.session.close() + logger.info("Бот остановлен") + + +if __name__ == '__main__': + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Бот остановлен пользователем") + except Exception as e: + logger.error(f"Критическая ошибка: {e}") + diff --git a/middlewares/__init__.py b/middlewares/__init__.py new file mode 100644 index 0000000..ba5c864 --- /dev/null +++ b/middlewares/__init__.py @@ -0,0 +1,5 @@ +"""Модуль middleware для бота""" +from middlewares.auth import AuthMiddleware, DatabaseMiddleware + +__all__ = ['AuthMiddleware', 'DatabaseMiddleware'] + diff --git a/middlewares/auth.py b/middlewares/auth.py new file mode 100644 index 0000000..deb7057 --- /dev/null +++ b/middlewares/auth.py @@ -0,0 +1,80 @@ +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database.connection import async_session_maker +from database.models import User +import logging + +logger = logging.getLogger(__name__) + + +class AuthMiddleware(BaseMiddleware): + """ + Middleware для проверки авторизации пользователя. + Пропускает команду /start и проверяет наличие telegram_id в БД для остальных запросов. + """ + + async def __call__( + self, + handler: Callable[[Message | CallbackQuery, Dict[str, Any]], Awaitable[Any]], + event: Message | CallbackQuery, + data: Dict[str, Any] + ) -> Any: + # Пропускаем команду /start + if isinstance(event, Message) and event.text and event.text.startswith('/start'): + return await handler(event, data) + + # Пропускаем callback'и регистрации и входа + if isinstance(event, CallbackQuery) and event.data: + if event.data in ['login', 'register', 'main_menu']: + return await handler(event, data) + + # Проверяем авторизацию + telegram_id = event.from_user.id + + try: + async with async_session_maker() as session: + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if not user: + if isinstance(event, CallbackQuery): + await event.answer("⛔ Сначала авторизуйтесь через /start", show_alert=True) + else: + await event.answer("⛔ Сначала авторизуйтесь через /start") + return + + # Добавляем пользователя в данные + data['user'] = user + data['session'] = session + + except Exception as e: + logger.error(f"Ошибка в AuthMiddleware: {e}") + if isinstance(event, CallbackQuery): + await event.answer("❌ Ошибка проверки авторизации", show_alert=True) + else: + await event.answer("❌ Ошибка проверки авторизации") + return + + return await handler(event, data) + + +class DatabaseMiddleware(BaseMiddleware): + """ + Middleware для предоставления сессии БД в обработчики. + """ + + async def __call__( + self, + handler: Callable[[Message | CallbackQuery, Dict[str, Any]], Awaitable[Any]], + event: Message | CallbackQuery, + data: Dict[str, Any] + ) -> Any: + async with async_session_maker() as session: + data['session'] = session + return await handler(event, data) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01395d3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +aiogram>=3.4.0 +asyncpg>=0.29.0 +SQLAlchemy>=2.0.0 +python-dotenv>=1.0.0 +bcrypt>=4.1.0 + diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..4226b2c --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,5 @@ +"""Утилиты и вспомогательные классы""" +from utils.states import RegistrationStates, LoginStates, BooksPagination, GenreFilter + +__all__ = ['RegistrationStates', 'LoginStates', 'BooksPagination', 'GenreFilter'] + diff --git a/utils/states.py b/utils/states.py new file mode 100644 index 0000000..4982251 --- /dev/null +++ b/utils/states.py @@ -0,0 +1,25 @@ +from aiogram.fsm.state import State, StatesGroup + + +class RegistrationStates(StatesGroup): + """Состояния для регистрации нового пользователя""" + waiting_email = State() + waiting_username = State() + waiting_password = State() + + +class LoginStates(StatesGroup): + """Состояния для входа существующего пользователя""" + waiting_email = State() + waiting_password = State() + + +class BooksPagination(StatesGroup): + """Состояния для пагинации книг""" + browsing = State() + + +class GenreFilter(StatesGroup): + """Состояния для фильтрации по жанрам""" + selecting = State() +