initial
This commit is contained in:
parent
24fa5a928c
commit
5dd2c798e5
|
@ -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
|
|
@ -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"]
|
|
@ -0,0 +1 @@
|
|||
# Mark directories as Python packages
|
|
@ -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()
|
|
@ -0,0 +1 @@
|
|||
# Mark directories as Python packages
|
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1 @@
|
|||
# Mark directories as Python packages
|
|
@ -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})")
|
|
@ -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"Удачи! ❤️"
|
||||
)
|
|
@ -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")
|
|
@ -0,0 +1 @@
|
|||
# Mark directories as Python packages
|
|
@ -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
|
|
@ -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:")
|
|
@ -0,0 +1 @@
|
|||
# Mark directories as Python packages
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
# Mark directories as Python packages
|
|
@ -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
|
|
@ -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
|
|
@ -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:
|
|
@ -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
|
Loading…
Reference in New Issue