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