initial commit

This commit is contained in:
2025-12-25 15:25:46 +03:00
commit 58827ac124
20 changed files with 1854 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
BOT_TOKEN=
DATABASE_URL=

46
.gitignore vendored Normal file
View File

@@ -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

459
CLAUDE.md Normal file
View File

@@ -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 сможет генерировать код согласно спецификации!
<span style="display:none">[^10][^8][^9]</span>
<div align="center">⁂</div>
[^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 и тд.)

13
config.py Normal file
View File

@@ -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 файле")

15
database/__init__.py Normal file
View File

@@ -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'
]

22
database/connection.py Normal file
View File

@@ -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()

95
database/models.py Normal file
View File

@@ -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")

7
env.example Normal file
View File

@@ -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

5
handlers/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Модуль обработчиков событий бота"""
from handlers import auth, books, favorites
__all__ = ['auth', 'books', 'favorites']

340
handlers/auth.py Normal file
View File

@@ -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)

306
handlers/books.py Normal file
View File

@@ -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"📖 <b>{title}</b>\n\n"
f"✍️ <b>Авторы:</b> {authors}\n"
f"🏷 <b>Жанры:</b> {genres}\n"
f"{stars} <b>Рейтинг:</b> {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(" Текущая страница")

252
handlers/favorites.py Normal file
View File

@@ -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)

19
keyboards/__init__.py Normal file
View File

@@ -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'
]

89
keyboards/inline.py Normal file
View File

@@ -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()

63
main.py Normal file
View File

@@ -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}")

5
middlewares/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Модуль middleware для бота"""
from middlewares.auth import AuthMiddleware, DatabaseMiddleware
__all__ = ['AuthMiddleware', 'DatabaseMiddleware']

80
middlewares/auth.py Normal file
View File

@@ -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)

6
requirements.txt Normal file
View File

@@ -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

5
utils/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Утилиты и вспомогательные классы"""
from utils.states import RegistrationStates, LoginStates, BooksPagination, GenreFilter
__all__ = ['RegistrationStates', 'LoginStates', 'BooksPagination', 'GenreFilter']

25
utils/states.py Normal file
View File

@@ -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()