From 5dd2c798e55c4e8b77a210e5ebffcd489057e990 Mon Sep 17 00:00:00 2001 From: itqop Date: Mon, 28 Apr 2025 15:52:32 +0300 Subject: [PATCH] initial --- .gitignore | 88 ++++++++ Dockerfile | 12 ++ bot/__init__.py | 1 + bot/config.py | 10 + bot/database/__init__.py | 1 + bot/database/db.py | 318 +++++++++++++++++++++++++++++ bot/database/models.py | 26 +++ bot/handlers/__init__.py | 1 + bot/handlers/game.py | 251 +++++++++++++++++++++++ bot/handlers/start.py | 18 ++ bot/handlers/stats.py | 58 ++++++ bot/keyboards/__init__.py | 1 + bot/keyboards/game_keyboard.py | 27 +++ bot/main.py | 40 ++++ bot/middlewares/__init__.py | 1 + bot/middlewares/auth_middleware.py | 77 +++++++ bot/utils/__init__.py | 1 + bot/utils/game_logic.py | 38 ++++ bot/utils/helpers.py | 14 ++ docker-compose.yml | 14 ++ requirements.txt | 5 + 21 files changed, 1002 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bot/__init__.py create mode 100644 bot/config.py create mode 100644 bot/database/__init__.py create mode 100644 bot/database/db.py create mode 100644 bot/database/models.py create mode 100644 bot/handlers/__init__.py create mode 100644 bot/handlers/game.py create mode 100644 bot/handlers/start.py create mode 100644 bot/handlers/stats.py create mode 100644 bot/keyboards/__init__.py create mode 100644 bot/keyboards/game_keyboard.py create mode 100644 bot/main.py create mode 100644 bot/middlewares/__init__.py create mode 100644 bot/middlewares/auth_middleware.py create mode 100644 bot/utils/__init__.py create mode 100644 bot/utils/game_logic.py create mode 100644 bot/utils/helpers.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3188cd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,88 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Virtual environments +venv/ +.env/ +.venv/ +ENV/ +env/ + +# Environment variables +*.env + +# PyInstaller +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Django stuff +*.log +local_settings.py +db.sqlite3 + +# Flask stuff +instance/ +.webassets-cache + +# Scrapy stuff +.scrapy + +# Sphinx documentation +docs/_build/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre +.pyre/ + +# VS Code +.vscode/ + +# Pycharm +.idea/ + +# SQL databases +*.sqlite3 + +# Backup files +*.bak +*.swp +*.swo + +# Mac OS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e2ea616 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "-m", "bot.main"] \ No newline at end of file diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..693e92f --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ +# Mark directories as Python packages \ No newline at end of file diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..0aa9088 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,10 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import SecretStr + +class Settings(BaseSettings): + bot_token: SecretStr + secret_password: SecretStr + database_name: str = "data/love_bot.db" + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore') + +settings = Settings() diff --git a/bot/database/__init__.py b/bot/database/__init__.py new file mode 100644 index 0000000..693e92f --- /dev/null +++ b/bot/database/__init__.py @@ -0,0 +1 @@ +# Mark directories as Python packages \ No newline at end of file diff --git a/bot/database/db.py b/bot/database/db.py new file mode 100644 index 0000000..3eca991 --- /dev/null +++ b/bot/database/db.py @@ -0,0 +1,318 @@ +import aiosqlite +from typing import List, Optional +from datetime import date + +from bot.config import settings +from .models import User, Game, Streak, GameChoice + +DATABASE_URL = settings.database_name + +async def init_db(): + """Инициализирует базу данных и создает таблицы, если их нет.""" + async with aiosqlite.connect(DATABASE_URL) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER UNIQUE NOT NULL, + username TEXT + ) + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_date DATE NOT NULL, + player1_id INTEGER NOT NULL, + player1_choice TEXT CHECK(player1_choice IN ('rock', 'scissors', 'paper')), + player2_id INTEGER NOT NULL, + player2_choice TEXT CHECK(player2_choice IN ('rock', 'scissors', 'paper')), + winner_id INTEGER, + is_finished BOOLEAN DEFAULT FALSE, + FOREIGN KEY (player1_id) REFERENCES users (id), + FOREIGN KEY (player2_id) REFERENCES users (id), + FOREIGN KEY (winner_id) REFERENCES users (id), + UNIQUE (game_date, player1_id, player2_id) -- Гарантирует одну игру в день для пары + ) + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS streaks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + current_streak INTEGER DEFAULT 0, + max_streak INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users (id) + ) + """) + await db.commit() + +async def add_user(telegram_id: int, username: Optional[str]) -> Optional[User]: + """Добавляет нового пользователя, если его еще нет. Возвращает объект User.""" + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute("SELECT id, telegram_id, username FROM users WHERE telegram_id = ?", (telegram_id,)) as cursor: + user_row = await cursor.fetchone() + if user_row: + return User(id=user_row[0], telegram_id=user_row[1], username=user_row[2]) + + try: + await db.execute( + "INSERT INTO users (telegram_id, username) VALUES (?, ?)", + (telegram_id, username) + ) + await db.commit() + user_id = (await db.execute("SELECT last_insert_rowid()")).fetchone()[0] + + await ensure_streak_record(user_id) + + return User(id=user_id, telegram_id=telegram_id, username=username) + except aiosqlite.IntegrityError: + async with db.execute("SELECT id, telegram_id, username FROM users WHERE telegram_id = ?", (telegram_id,)) as cursor: + user_row = await cursor.fetchone() + if user_row: + return User(id=user_row[0], telegram_id=user_row[1], username=user_row[2]) + return None + +async def get_user_by_telegram_id(telegram_id: int) -> Optional[User]: + """Ищет пользователя по его Telegram ID.""" + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute("SELECT id, telegram_id, username FROM users WHERE telegram_id = ?", (telegram_id,)) as cursor: + user_row = await cursor.fetchone() + if user_row: + return User(id=user_row[0], telegram_id=user_row[1], username=user_row[2]) + return None + +async def get_user_by_id(user_id: int) -> Optional[User]: + """Ищет пользователя по его ID в базе данных.""" + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute("SELECT id, telegram_id, username FROM users WHERE id = ?", (user_id,)) as cursor: + user_row = await cursor.fetchone() + if user_row: + return User(id=user_row[0], telegram_id=user_row[1], username=user_row[2]) + return None + + +async def get_all_users() -> List[User]: + """Возвращает список всех зарегистрированных пользователей.""" + users = [] + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute("SELECT id, telegram_id, username FROM users") as cursor: + async for row in cursor: + users.append(User(id=row[0], telegram_id=row[1], username=row[2])) + return users + +# --- Функции для работы со стриками (заглушка ensure_streak_record добавлена для add_user) --- + +async def ensure_streak_record(user_id: int) -> Streak: + """ + Гарантирует наличие записи о стриках для пользователя. + Если записи нет, создает ее с нулевыми значениями. + Возвращает объект Streak. + """ + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute("SELECT id, user_id, current_streak, max_streak FROM streaks WHERE user_id = ?", (user_id,)) as cursor: + streak_row = await cursor.fetchone() + if streak_row: + return Streak(id=streak_row[0], user_id=streak_row[1], current_streak=streak_row[2], max_streak=streak_row[3]) + else: + await db.execute("INSERT INTO streaks (user_id) VALUES (?)", (user_id,)) + await db.commit() + async with db.execute("SELECT id, user_id, current_streak, max_streak FROM streaks WHERE user_id = ?", (user_id,)) as new_cursor: + new_streak_row = await new_cursor.fetchone() + if new_streak_row: + return Streak(id=new_streak_row[0], user_id=new_streak_row[1], current_streak=new_streak_row[2], max_streak=new_streak_row[3]) + else: + raise Exception(f"Could not create or find streak record for user_id {user_id}") + + +# --- Функции для работы с играми --- + +def _row_to_game(row: Optional[tuple]) -> Optional[Game]: + """Вспомогательная функция для преобразования строки базы данных в объект Game.""" + if row: + return Game( + id=row[0], + game_date=date.fromisoformat(row[1]), + player1_id=row[2], + player1_choice=row[3], + player2_id=row[4], + player2_choice=row[5], + winner_id=row[6], + is_finished=bool(row[7]) + ) + return None + +async def create_or_get_today_game(player1_id: int, player2_id: int) -> Optional[Game]: + """ + Находит или создает игру для указанной пары игроков на СЕГОДНЯШНЮЮ дату. + Возвращает объект Game или None в случае ошибки. + Гарантирует, что player1_id < player2_id для уникальности записи. + """ + today = date.today() + p1, p2 = sorted((player1_id, player2_id)) + + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute( + """SELECT id, game_date, player1_id, player1_choice, player2_id, player2_choice, winner_id, is_finished + FROM games + WHERE game_date = ? AND player1_id = ? AND player2_id = ?""", + (today, p1, p2) + ) as cursor: + game_row = await cursor.fetchone() + if game_row: + return _row_to_game(game_row) + + try: + await db.execute( + """INSERT INTO games (game_date, player1_id, player2_id) VALUES (?, ?, ?)""", + (today, p1, p2) + ) + await db.commit() + async with db.execute( + """SELECT id, game_date, player1_id, player1_choice, player2_id, player2_choice, winner_id, is_finished + FROM games + WHERE game_date = ? AND player1_id = ? AND player2_id = ?""", + (today, p1, p2) + ) as new_cursor: + new_game_row = await new_cursor.fetchone() + return _row_to_game(new_game_row) + except aiosqlite.IntegrityError: + async with db.execute( + """SELECT id, game_date, player1_id, player1_choice, player2_id, player2_choice, winner_id, is_finished + FROM games + WHERE game_date = ? AND player1_id = ? AND player2_id = ?""", + (today, p1, p2) + ) as cursor: + game_row = await cursor.fetchone() + return _row_to_game(game_row) + except Exception as e: + print(f"Error creating/getting today's game: {e}") + return None + + +async def update_game_choice(game_id: int, player_id: int, choice: GameChoice) -> bool: + """Обновляет выбор игрока в указанной игре.""" + async with aiosqlite.connect(DATABASE_URL) as db: + game = await get_game_by_id(game_id) + if not game: + return False + + column_to_update = None + if game.player1_id == player_id: + column_to_update = "player1_choice" + elif game.player2_id == player_id: + column_to_update = "player2_choice" + + if not column_to_update: + return False + + try: + await db.execute( + f"UPDATE games SET {column_to_update} = ? WHERE id = ?", + (choice, game_id) + ) + await db.commit() + return True + except Exception as e: + print(f"Error updating game choice: {e}") + return False + + +async def get_game_by_id(game_id: int) -> Optional[Game]: + """Получает игру по её ID.""" + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute( + """SELECT id, game_date, player1_id, player1_choice, player2_id, player2_choice, winner_id, is_finished + FROM games + WHERE id = ?""", + (game_id,) + ) as cursor: + game_row = await cursor.fetchone() + return _row_to_game(game_row) + + +async def finish_game(game_id: int, winner_id: Optional[int]) -> bool: + """Отмечает игру как завершенную и указывает победителя (или None для ничьи).""" + async with aiosqlite.connect(DATABASE_URL) as db: + try: + await db.execute( + "UPDATE games SET winner_id = ?, is_finished = TRUE WHERE id = ?", + (winner_id, game_id) + ) + await db.commit() + return True + except Exception as e: + print(f"Error finishing game: {e}") + return False + +async def get_game_on_date(player1_id: int, player2_id: int, game_date: date) -> Optional[Game]: + """Получает игру для пары игроков на указанную дату.""" + p1, p2 = sorted((player1_id, player2_id)) + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute( + """SELECT id, game_date, player1_id, player1_choice, player2_id, player2_choice, winner_id, is_finished + FROM games + WHERE game_date = ? AND player1_id = ? AND player2_id = ?""", + (game_date, p1, p2) + ) as cursor: + game_row = await cursor.fetchone() + return _row_to_game(game_row) + + +# --- Функции для работы со стриками --- + +async def get_streak(user_id: int) -> Optional[Streak]: + """Получает текущую и максимальную серию побед для пользователя.""" + return await ensure_streak_record(user_id) + +async def update_streak(user_id: int, win: bool): + """Обновляет серию побед пользователя.""" + async with aiosqlite.connect(DATABASE_URL) as db: + current_streak_data = await get_streak(user_id) + if not current_streak_data: + print(f"Error: Streak record not found for user {user_id} during update.") + return + + current_streak = current_streak_data.current_streak + max_streak = current_streak_data.max_streak + + if win: + current_streak += 1 + if current_streak > max_streak: + max_streak = current_streak + else: + current_streak = 0 + + await db.execute( + "UPDATE streaks SET current_streak = ?, max_streak = ? WHERE user_id = ?", + (current_streak, max_streak, user_id) + ) + await db.commit() + +# --- Функции для статистики --- + +async def get_wins_count(user_id: int) -> int: + """Возвращает общее количество побед пользователя.""" + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute("SELECT COUNT(*) FROM games WHERE winner_id = ? AND is_finished = TRUE", (user_id,)) as cursor: + result = await cursor.fetchone() + return result[0] if result else 0 + +async def get_total_games_count(player1_id: int, player2_id: int) -> int: + """Возвращает общее количество сыгранных (завершенных) игр между двумя пользователями.""" + p1, p2 = sorted((player1_id, player2_id)) + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute( + "SELECT COUNT(*) FROM games WHERE player1_id = ? AND player2_id = ? AND is_finished = TRUE", + (p1, p2) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else 0 + +async def get_draw_count(player1_id: int, player2_id: int) -> int: + """Возвращает общее количество ничьих между двумя пользователями.""" + p1, p2 = sorted((player1_id, player2_id)) + async with aiosqlite.connect(DATABASE_URL) as db: + async with db.execute( + "SELECT COUNT(*) FROM games WHERE player1_id = ? AND player2_id = ? AND is_finished = TRUE AND winner_id IS NULL", + (p1, p2) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else 0 diff --git a/bot/database/models.py b/bot/database/models.py new file mode 100644 index 0000000..f0d206c --- /dev/null +++ b/bot/database/models.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field +from datetime import date +from typing import Optional, Literal + +GameChoice = Literal["rock", "scissors", "paper"] + +class User(BaseModel): + id: Optional[int] = Field(default=None, description="Primary key") + telegram_id: int = Field(description="Telegram User ID") + username: Optional[str] = Field(default=None, description="Telegram Username") + +class Game(BaseModel): + id: Optional[int] = Field(default=None, description="Primary key") + game_date: date = Field(description="Date of the game") + player1_id: int = Field(description="Foreign key to users table") + player1_choice: Optional[GameChoice] = Field(default=None) + player2_id: int = Field(description="Foreign key to users table") + player2_choice: Optional[GameChoice] = Field(default=None) + winner_id: Optional[int] = Field(default=None, description="Foreign key to users table, NULL if draw") + is_finished: bool = Field(default=False) + +class Streak(BaseModel): + id: Optional[int] = Field(default=None, description="Primary key") + user_id: int = Field(description="Foreign key to users table") + current_streak: int = Field(default=0) + max_streak: int = Field(default=0) \ No newline at end of file diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..693e92f --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1 @@ +# Mark directories as Python packages \ No newline at end of file diff --git a/bot/handlers/game.py b/bot/handlers/game.py new file mode 100644 index 0000000..82c562b --- /dev/null +++ b/bot/handlers/game.py @@ -0,0 +1,251 @@ +import logging +from datetime import date, timedelta +from typing import Optional, Literal + +from aiogram import Router, types, F, Bot +from aiogram.filters import Command + +from bot.database.models import User as DbUser, Game as DbGame, GameChoice +from bot.database.db import ( + create_or_get_today_game, + update_game_choice, + get_user_by_id, + finish_game, + update_streak, + get_game_by_id, + get_game_on_date +) +from bot.keyboards.game_keyboard import get_game_choice_keyboard, GameChoiceCallback +from bot.utils.game_logic import determine_winner, CHOICE_NAMES_RU +from bot.utils.helpers import get_partner + +router = Router() + +async def check_and_resolve_yesterdays_game(user_id: int, partner_id: int, bot: Bot) -> bool: + """ + Проверяет наличие незавершенной игры за вчерашний день и завершает ее по правилам. + Возвращает True, если игра была найдена и обработана, иначе False. + """ + yesterday = date.today() - timedelta(days=1) + game = await get_game_on_date(user_id, partner_id, yesterday) + + if not game or game.is_finished: + return False + + logging.info(f"Found unfinished game from yesterday ({yesterday}) for users {user_id} and {partner_id}. Resolving...") + + p1_id = game.player1_id + p2_id = game.player2_id + p1_choice = game.player1_choice + p2_choice = game.player2_choice + + p1_user = await get_user_by_id(p1_id) + p2_user = await get_user_by_id(p2_id) + + if not p1_user or not p2_user: + logging.error(f"Could not find user data for yesterday's game {game.id}") + await finish_game(game.id, None) + return True + + p1_name = p1_user.username or f"Игрок {p1_user.telegram_id}" + p2_name = p2_user.username or f"Игрок {p2_user.telegram_id}" + + winner_db_id: Optional[int] = None + result_message: str = "" + p1_win: bool = False + p2_win: bool = False + + if p1_choice and not p2_choice: + winner_db_id = p1_id + p1_choice_ru = CHOICE_NAMES_RU.get(p1_choice, p1_choice) + result_message = ( + f"⏳ Вчерашняя игра ({yesterday}):\n" + f"{p1_name} ({p1_choice_ru}) побеждает! 🎉\n" + f"({p2_name} не сделал(а) свой ход)" + ) + p1_win = True + p2_win = False + logging.info(f"Yesterday's game {game.id}: Player 1 ({p1_id}) wins by default.") + + elif not p1_choice and p2_choice: + winner_db_id = p2_id + p2_choice_ru = CHOICE_NAMES_RU.get(p2_choice, p2_choice) + result_message = ( + f"⏳ Вчерашняя игра ({yesterday}):\n" + f"{p2_name} ({p2_choice_ru}) побеждает! 🎉\n" + f"({p1_name} не сделал(а) свой ход)" + ) + p1_win = False + p2_win = True + logging.info(f"Yesterday's game {game.id}: Player 2 ({p2_id}) wins by default.") + + else: + winner_db_id = None + result_message = ( + f"⏳ Вчерашняя игра ({yesterday}):\n" + f"Ничья! Игра аннулирована, так как не все сделали ход. 🤷‍♀️🤷‍♂️" + ) + p1_win = False + p2_win = False + logging.info(f"Yesterday's game {game.id}: Draw (at least one player did not choose).") + + await finish_game(game.id, winner_db_id) + + await update_streak(p1_id, win=p1_win) + await update_streak(p2_id, win=p2_win) + + full_result_message = result_message + "\n\nНачинаем игру на сегодня! /play" + try: + await bot.send_message(p1_user.telegram_id, full_result_message, parse_mode="HTML") + except Exception as e: + logging.error(f"Failed to send yesterday result to user {p1_user.telegram_id}: {e}") + try: + await bot.send_message(p2_user.telegram_id, full_result_message, parse_mode="HTML") + except Exception as e: + logging.error(f"Failed to send yesterday result to user {p2_user.telegram_id}: {e}") + + return True + +@router.message(Command("play")) +async def handle_play(message: types.Message, db_user: DbUser, bot: Bot): + """Обработчик команды /play. Проверяет вчерашнюю игру и предлагает сделать ход.""" + partner = await get_partner(db_user.id) + if not partner: + await message.answer("Для игры нужен партнер. Дождитесь, пока второй пользователь присоединится.") + return + + try: + await check_and_resolve_yesterdays_game(db_user.id, partner.id, bot) + except Exception as e: + logging.exception(f"Error checking/resolving yesterday's game for {db_user.id} and {partner.id}") + + game = await create_or_get_today_game(db_user.id, partner.id) + if not game: + await message.answer("Не удалось начать игру. Попробуйте позже.") + logging.error(f"Failed to create/get game for users {db_user.id} and {partner.id}") + return + + if game.is_finished: + await message.answer("Вы уже сыграли сегодня! Приходите завтра ❤️") + return + + current_player_choice = game.player1_choice if game.player1_id == db_user.id else game.player2_choice + + if current_player_choice: + choice_name_ru = CHOICE_NAMES_RU.get(current_player_choice, current_player_choice) + await message.answer(f"Вы уже сделали свой ход сегодня ({choice_name_ru}). Ожидаем хода партнера! 😉") + return + + await message.answer("Кто больше любит сегодня? 😉 Сделайте свой выбор:", reply_markup=get_game_choice_keyboard()) + + +@router.callback_query(GameChoiceCallback.filter()) +async def handle_game_choice( + callback: types.CallbackQuery, + callback_data: GameChoiceCallback, + db_user: DbUser, + bot: Bot +): + """Обработчик нажатия на кнопку выбора хода.""" + choice: GameChoice = callback_data.choice + choice_name_ru = CHOICE_NAMES_RU.get(choice, choice) + + partner = await get_partner(db_user.id) + if not partner: + await callback.answer("Ошибка: не найден партнер.", show_alert=True) + await callback.message.edit_text("Не удалось обработать ваш ход: партнер не найден.") + return + + game = await create_or_get_today_game(db_user.id, partner.id) + if not game: + await callback.answer("Ошибка: не найдена текущая игра.", show_alert=True) + await callback.message.edit_text("Не удалось обработать ваш ход: игра не найдена.") + logging.error(f"Game not found for users {db_user.id} and {partner.id} during callback") + return + + if game.is_finished: + await callback.answer("Игра уже завершена.", show_alert=True) + await callback.message.edit_text("Вы опоздали, игра на сегодня уже закончилась!" ) + return + + player_field = "player1_choice" if game.player1_id == db_user.id else "player2_choice" + current_choice = getattr(game, player_field) + if current_choice is not None: + current_choice_ru = CHOICE_NAMES_RU.get(current_choice, current_choice) + await callback.answer("Вы уже сделали свой ход.", show_alert=True) + await callback.message.edit_text(f"Вы уже выбрали: {current_choice_ru}\nОжидаем ход партнера... ✨") + return + + updated = await update_game_choice(game.id, db_user.id, choice) + if not updated: + await callback.answer("Ошибка: не удалось сохранить ваш выбор.", show_alert=True) + await callback.message.edit_text("Произошла ошибка при сохранении вашего хода. Попробуйте еще раз.") + logging.error(f"Failed to update choice for game {game.id}, user {db_user.id}") + return + + await callback.answer(f"Вы выбрали: {choice_name_ru}") + await callback.message.edit_text(f"Вы выбрали: {choice_name_ru}\nОжидаем ход партнера... ✨") + + updated_game = await get_game_by_id(game.id) + if not updated_game: + logging.error(f"Failed to fetch updated game {game.id} after choice update.") + return + + if updated_game.player1_choice and updated_game.player2_choice: + winner_relation = determine_winner(updated_game.player1_choice, updated_game.player2_choice) + + winner_db_id: Optional[int] = None + result_text = "" + + p1_choice_ru = CHOICE_NAMES_RU[updated_game.player1_choice] + p2_choice_ru = CHOICE_NAMES_RU[updated_game.player2_choice] + p1_user = await get_user_by_id(updated_game.player1_id) + p2_user = await get_user_by_id(updated_game.player2_id) + + if not p1_user or not p2_user: + logging.error(f"Could not find user data for game {updated_game.id}") + await bot.send_message(db_user.telegram_id, "Ошибка: не удалось получить данные игроков для завершения игры.") + await bot.send_message(partner.telegram_id, "Ошибка: не удалось получить данные игроков для завершения игры.") + return + + p1_name = p1_user.username or f"Игрок {p1_user.telegram_id}" + p2_name = p2_user.username or f"Игрок {p2_user.telegram_id}" + + if winner_relation == 1: + winner_db_id = updated_game.player1_id + result_text = f"{p1_name} ({p1_choice_ru}) побеждает {p2_name} ({p2_choice_ru})!\n{p1_name} сегодня любит больше! ❤️" + elif winner_relation == 2: + winner_db_id = updated_game.player2_id + result_text = f"{p2_name} ({p2_choice_ru}) побеждает {p1_name} ({p1_choice_ru})!\n{p2_name} сегодня любит больше! ❤️" + else: + result_text = f"Ничья! Оба выбрали {p1_choice_ru}.\nСегодня вы любите друг друга одинаково сильно! 🥰" + + await finish_game(updated_game.id, winner_db_id) + + await update_streak(updated_game.player1_id, win=(winner_relation == 1)) + await update_streak(updated_game.player2_id, win=(winner_relation == 2)) + + final_message = f"Игра за {updated_game.game_date.strftime('%d.%m.%Y')} завершена! 🎉\n\n{result_text}\n\nПосмотреть статистику: /stats" + + try: + await bot.send_message(p1_user.telegram_id, final_message, parse_mode="HTML") + except Exception as e: + logging.error(f"Failed to send result to user {p1_user.telegram_id}: {e}") + try: + await bot.send_message(p2_user.telegram_id, final_message, parse_mode="HTML") + except Exception as e: + logging.error(f"Failed to send result to user {p2_user.telegram_id}: {e}") + + else: + partner_user = await get_user_by_id(partner.id) + if partner_user: + current_user_name = db_user.username or f"Игрок {db_user.telegram_id}" + try: + await bot.send_message( + partner_user.telegram_id, + f"Ваш партнер, {current_user_name}, сделал свой ход! 😉\nТеперь ваша очередь решить, кто любит больше! Используйте команду /play, чтобы сделать ход." + ) + except Exception as e: + logging.error(f"Failed to notify partner {partner_user.telegram_id}: {e}") + else: + logging.error(f"Could not find partner user data for notification (partner_id: {partner.id})") \ No newline at end of file diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..c6b63cb --- /dev/null +++ b/bot/handlers/start.py @@ -0,0 +1,18 @@ +from aiogram import Router, types +from aiogram.filters import CommandStart + +from bot.database.models import User as DbUser + +router = Router() + +@router.message(CommandStart()) +async def handle_start(message: types.Message, db_user: DbUser): + """Обработчик команды /start для аутентифицированных пользователей.""" + username = db_user.username or f"Пользователь {db_user.telegram_id}" + await message.answer( + f"Привет, {username}! ✨\n\n" + f"Это бот 'Кто больше любит'. Каждый день вы со своей парой можете сыграть в Камень-Ножницы-Бумага, чтобы определить, кто любит больше! 😉\n\n" + f"Чтобы сделать ход, используй кнопки ниже (они появятся, когда придет время игры).\n" + f"Для просмотра статистики используй команду /stats.\n\n" + f"Удачи! ❤️" + ) diff --git a/bot/handlers/stats.py b/bot/handlers/stats.py new file mode 100644 index 0000000..dd274a0 --- /dev/null +++ b/bot/handlers/stats.py @@ -0,0 +1,58 @@ +import logging +from aiogram import Router, types +from aiogram.filters import Command + +from bot.database.models import User as DbUser +from bot.database.db import ( + get_wins_count, + get_total_games_count, + get_draw_count, + get_streak, + get_user_by_id +) +from bot.utils.helpers import get_partner + +router = Router() + +@router.message(Command("stats")) +async def handle_stats(message: types.Message, db_user: DbUser): + """Обработчик команды /stats. Выводит статистику игры.""" + partner = await get_partner(db_user.id) + if not partner: + await message.answer("Не могу показать статистику, так как не найден партнер.") + return + + try: + total_games = await get_total_games_count(db_user.id, partner.id) + user_wins = await get_wins_count(db_user.id) + partner_wins = await get_wins_count(partner.id) + draws = await get_draw_count(db_user.id, partner.id) + user_streak = await get_streak(db_user.id) + partner_streak = await get_streak(partner.id) + + except Exception as e: + logging.exception(f"Error fetching stats for user {db_user.id} and partner {partner.id}") + await message.answer("Произошла ошибка при получении статистики. Попробуйте позже.") + return + + user_name = db_user.username or f"Игрок {db_user.telegram_id}" + partner_name = partner.username or f"Игрок {partner.telegram_id}" + + stats_text = ( + f"📊 Статистика \"Кто больше любит?\" ❤️\n\n" + f"Всего сыграно игр: {total_games}\n" + f"(Завершенных игр между вами)\n\n" + f"🏆 Победы:\n" + f" - {user_name}: {user_wins}\n" + f" - {partner_name}: {partner_wins}\n" + f" - Ничьи: {draws}\n\n" + f"🔥 Серии побед (стрики):\n" + f" - {user_name}:\n" + f" - Текущий: {user_streak.current_streak if user_streak else 0}\n" + f" - Макс.: {user_streak.max_streak if user_streak else 0}\n" + f" - {partner_name}:\n" + f" - Текущий: {partner_streak.current_streak if partner_streak else 0}\n" + f" - Макс.: {partner_streak.max_streak if partner_streak else 0}" + ) + + await message.answer(stats_text, parse_mode="HTML") \ No newline at end of file diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..693e92f --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1 @@ +# Mark directories as Python packages \ No newline at end of file diff --git a/bot/keyboards/game_keyboard.py b/bot/keyboards/game_keyboard.py new file mode 100644 index 0000000..51ca0af --- /dev/null +++ b/bot/keyboards/game_keyboard.py @@ -0,0 +1,27 @@ +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.filters.callback_data import CallbackData + +class GameChoiceCallback(CallbackData, prefix="game"): + choice: str + + +def get_game_choice_keyboard() -> InlineKeyboardMarkup: + """Возвращает инлайн-клавиатуру с кнопками выбора хода.""" + buttons = [ + [ + InlineKeyboardButton( + text="🗿 Камень", + callback_data=GameChoiceCallback(choice="rock").pack() + ), + InlineKeyboardButton( + text="✂️ Ножницы", + callback_data=GameChoiceCallback(choice="scissors").pack() + ), + InlineKeyboardButton( + text="📄 Бумага", + callback_data=GameChoiceCallback(choice="paper").pack() + ) + ] + ] + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + return keyboard diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..e485fa7 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,40 @@ +import asyncio +import logging +import sys + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode + +from bot.handlers import start, game, stats +from bot.middlewares.auth_middleware import AuthMiddleware, load_initial_users +from bot.database.db import init_db +from bot.config import settings + +async def main() -> None: + await init_db() + logging.info("Database initialized.") + + await load_initial_users() + + bot = Bot(token=settings.bot_token.get_secret_value(), parse_mode=ParseMode.HTML) + dp = Dispatcher() + + dp.update.outer_middleware(AuthMiddleware()) + + dp.include_router(start.router) + dp.include_router(game.router) + dp.include_router(stats.router) + # TODO: Раскомментировать и добавить роутеры, когда они будут готовы + + logging.info("Starting bot...") + await bot.delete_webhook(drop_pending_updates=True) + await dp.start_polling(bot) + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, stream=sys.stdout) + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Bot stopped.") + except Exception as e: + logging.exception("Bot encountered an error:") \ No newline at end of file diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py new file mode 100644 index 0000000..693e92f --- /dev/null +++ b/bot/middlewares/__init__.py @@ -0,0 +1 @@ +# Mark directories as Python packages \ No newline at end of file diff --git a/bot/middlewares/auth_middleware.py b/bot/middlewares/auth_middleware.py new file mode 100644 index 0000000..93291d7 --- /dev/null +++ b/bot/middlewares/auth_middleware.py @@ -0,0 +1,77 @@ +import logging +from typing import Callable, Dict, Any, Awaitable, Set + +from aiogram import BaseMiddleware +from aiogram.types import Message, TelegramObject, User as AiogramUser + +from bot.config import settings +from bot.database.db import get_all_users, add_user, get_user_by_telegram_id +from bot.database.models import User as DbUser + +authenticated_user_ids: Set[int] = set() + +initial_users_loaded = False + +async def load_initial_users(): + """Loads existing user IDs from the DB into memory on startup.""" + global authenticated_user_ids, initial_users_loaded + if not initial_users_loaded: + logging.info("Loading initial users from database...") + existing_users = await get_all_users() + for user in existing_users: + authenticated_user_ids.add(user.telegram_id) + if len(authenticated_user_ids) >= 2: + break + initial_users_loaded = True + logging.info(f"Loaded {len(authenticated_user_ids)} users: {authenticated_user_ids}") + +class AuthMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + + if not initial_users_loaded: + await load_initial_users() + + if not isinstance(event, Message): + return await handler(event, data) + + aiogram_user: AiogramUser = data.get('event_from_user') + + if not aiogram_user: + return await handler(event, data) + + telegram_id = aiogram_user.id + + if telegram_id in authenticated_user_ids: + db_user = await get_user_by_telegram_id(telegram_id) + if db_user: + data['db_user'] = db_user + return await handler(event, data) + else: + logging.warning(f"User {telegram_id} is in authenticated_user_ids but not found in DB.") + return + + if event.text and event.text == settings.secret_password.get_secret_value(): + if len(authenticated_user_ids) < 2: + db_user = await add_user(telegram_id, aiogram_user.username) + if db_user: + authenticated_user_ids.add(telegram_id) + data['db_user'] = db_user + logging.info(f"User {telegram_id} ({aiogram_user.username}) authenticated successfully. Total users: {len(authenticated_user_ids)}") + return + else: + logging.error(f"Failed to add user {telegram_id} to DB after password check.") + await event.answer("Произошла ошибка при регистрации. Попробуйте позже.") + return + else: + logging.info(f"Authentication attempt blocked for user {telegram_id}. Limit of 2 users reached.") + await event.answer("Извините, бот уже используется двумя пользователями.") + return + else: + if event.text != settings.secret_password.get_secret_value(): + await event.answer("Для начала работы введите секретный пароль.") + return diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..693e92f --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1 @@ +# Mark directories as Python packages \ No newline at end of file diff --git a/bot/utils/game_logic.py b/bot/utils/game_logic.py new file mode 100644 index 0000000..3726e42 --- /dev/null +++ b/bot/utils/game_logic.py @@ -0,0 +1,38 @@ +from typing import Optional, Literal +from bot.database.models import GameChoice, User + +WIN_CONDITIONS: dict[GameChoice, GameChoice] = { + "rock": "scissors", + "scissors": "paper", + "paper": "rock", +} + +CHOICE_NAMES_RU: dict[GameChoice, str] = { + "rock": "Камень 🗿", + "scissors": "Ножницы ✂️", + "paper": "Бумага 📄", +} + +def determine_winner(choice1: Optional[GameChoice], choice2: Optional[GameChoice]) -> Optional[Literal[1, 2]]: + """ + Определяет победителя игры Камень-Ножницы-Бумага. + + Args: + choice1: Выбор первого игрока. + choice2: Выбор второго игрока. + + Returns: + 1, если победил игрок 1. + 2, если победил игрок 2. + None, если ничья или кто-то не сделал выбор. + """ + if not choice1 or not choice2: + return None + + if choice1 == choice2: + return None # Ничья + + if WIN_CONDITIONS.get(choice1) == choice2: + return 1 + else: + return 2 \ No newline at end of file diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 0000000..f025055 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,14 @@ +from typing import Optional + +from bot.database.db import get_all_users +from bot.database.models import User as DbUser + +async def get_partner(current_user_id: int) -> Optional[DbUser]: + """Находит второго зарегистрированного пользователя (партнера).""" + users = await get_all_users() + if len(users) != 2: + return None + for user in users: + if user.id is not None and user.id != current_user_id: + return user + return None \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..283096c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + love_bot: + build: . + container_name: love_bot_app + restart: unless-stopped + env_file: + - .env + volumes: + - sqlite_data:/app/data + +volumes: + sqlite_data: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5bb6367 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +aiogram>=3.4.1,<4.0.0 +pydantic>=2.7.1,<3.0.0 +python-dotenv>=1.0.1,<2.0.0 +aiosqlite>=0.19.0,<1.0.0 +pydantic-settings>=2.2.1,<3.0.0 \ No newline at end of file