This commit is contained in:
itqop 2025-04-28 15:52:32 +03:00
parent 24fa5a928c
commit 5dd2c798e5
21 changed files with 1002 additions and 0 deletions

88
.gitignore vendored Normal file
View File

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

12
Dockerfile Normal file
View File

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

1
bot/__init__.py Normal file
View File

@ -0,0 +1 @@
# Mark directories as Python packages

10
bot/config.py Normal file
View File

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

1
bot/database/__init__.py Normal file
View File

@ -0,0 +1 @@
# Mark directories as Python packages

318
bot/database/db.py Normal file
View File

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

26
bot/database/models.py Normal file
View File

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

1
bot/handlers/__init__.py Normal file
View File

@ -0,0 +1 @@
# Mark directories as Python packages

251
bot/handlers/game.py Normal file
View File

@ -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"<b>{p1_name}</b> ({p1_choice_ru}) побеждает! 🎉\n"
f"<i>({p2_name} не сделал(а) свой ход)</i>"
)
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"<b>{p2_name}</b> ({p2_choice_ru}) побеждает! 🎉\n"
f"<i>({p1_name} не сделал(а) свой ход)</i>"
)
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"<b>{p1_name}</b> ({p1_choice_ru}) побеждает <b>{p2_name}</b> ({p2_choice_ru})!\n{p1_name} сегодня любит больше! ❤️"
elif winner_relation == 2:
winner_db_id = updated_game.player2_id
result_text = f"<b>{p2_name}</b> ({p2_choice_ru}) побеждает <b>{p1_name}</b> ({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})")

18
bot/handlers/start.py Normal file
View File

@ -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"Удачи! ❤️"
)

58
bot/handlers/stats.py Normal file
View File

@ -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"📊 <b>Статистика \"Кто больше любит?\"</b> ❤️\n\n"
f"<b>Всего сыграно игр:</b> {total_games}\n"
f"<i>(Завершенных игр между вами)</i>\n\n"
f"🏆 <b>Победы:</b>\n"
f" - {user_name}: {user_wins}\n"
f" - {partner_name}: {partner_wins}\n"
f" - Ничьи: {draws}\n\n"
f"🔥 <b>Серии побед (стрики):</b>\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")

View File

@ -0,0 +1 @@
# Mark directories as Python packages

View File

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

40
bot/main.py Normal file
View File

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

View File

@ -0,0 +1 @@
# Mark directories as Python packages

View File

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

1
bot/utils/__init__.py Normal file
View File

@ -0,0 +1 @@
# Mark directories as Python packages

38
bot/utils/game_logic.py Normal file
View File

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

14
bot/utils/helpers.py Normal file
View File

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

14
docker-compose.yml Normal file
View File

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

5
requirements.txt Normal file
View File

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