Files
remnawave-bedolaga-telegram…/app/handlers/contests.py
gy9vin 86dd18fbe7 refactor(contests): доработка ежедневных конкурсов
Рефакторинг архитектуры ежедневных конкурсов:

- Создан модуль app/services/contests/ с новой архитектурой:
  - enums.py: GameType, RoundStatus, PrizeType enum классы
  - games.py: паттерн Стратегия для 7 типов игр
  - attempt_service.py: ContestAttemptService для атомарных операций

- Упрощён handlers/contests.py:
  - Удалены отдельные _render_* функции (заменены на стратегии)
  - Логика обработки попыток вынесена в ContestAttemptService
  - Уменьшено с 523 до 342 строк (-35%)

- Обновлён contest_rotation_service.py:
  - Заменена if-elif цепочка на get_game_strategy().build_payload()
  - Используются enum классы вместо магических строк

- Исправлен handlers/admin/daily_contests.py:
  - prize_days → prize_type/prize_value (соответствие модели БД)
  - Обновлены EDITABLE_FIELDS и отображение приза

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:09:11 +03:00

342 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Contest handlers for daily games."""
import logging
from datetime import datetime
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.contest import get_active_rounds, get_attempt
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.database import AsyncSessionLocal
from app.database.models import ContestRound, SubscriptionStatus
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.contests import (
ContestAttemptService,
GameType,
get_game_strategy,
)
from app.states import ContestStates
from app.utils.decorators import auth_required, error_handler
logger = logging.getLogger(__name__)
# Rate limiting storage
_rate_limits: dict = {}
# Service instance
_attempt_service = ContestAttemptService()
def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: int = 5) -> bool:
"""Check if user exceeds rate limit for contest actions."""
key = f"{user_id}_{action}"
now = datetime.utcnow().timestamp()
if key not in _rate_limits:
_rate_limits[key] = []
# Clean old entries
_rate_limits[key] = [t for t in _rate_limits[key] if now - t < window_seconds]
if len(_rate_limits[key]) >= limit:
return False
_rate_limits[key].append(now)
return True
def _validate_callback_data(data: str) -> Optional[list]:
"""Validate and parse callback data safely."""
if not data or not isinstance(data, str):
return None
parts = data.split("_")
if len(parts) < 2 or parts[0] != "contest":
return None
for part in parts:
if not part or len(part) > 50:
return None
return parts
def _user_allowed(subscription) -> bool:
"""Check if user has active or trial subscription."""
if not subscription:
return False
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
}
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
"""Reply that user is not eligible to play."""
texts = get_texts(language)
await callback.answer(
texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."),
show_alert=True,
)
# ---------- Handlers ----------
@auth_required
@error_handler
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
"""Show menu with available contest games."""
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
active_rounds = await get_active_rounds(db)
# Group by template, take one round per template
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ""
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
buttons = []
for tpl_slug, rnd in unique_templates.items():
title = rnd.template.name if rnd.template else tpl_slug
buttons.append([
types.InlineKeyboardButton(
text=f"▶️ {title}",
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
)
])
if not buttons:
buttons.append([
types.InlineKeyboardButton(
text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"),
callback_data="noop",
)
])
buttons.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
await callback.message.edit_text(
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
@auth_required
@error_handler
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
"""Start playing a specific contest."""
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
# Rate limit check
if not _check_rate_limit(db_user.id, "contest_play", limit=2, window_seconds=10):
await callback.answer(
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
show_alert=True,
)
return
# Validate callback data
parts = _validate_callback_data(callback.data)
if not parts or len(parts) < 4 or parts[1] != "play":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[-1]
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
# Get round with template
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."),
show_alert=True,
)
return
if not round_obj.template or not round_obj.template.is_enabled:
await callback.answer(
texts.t("CONTEST_DISABLED", "Игра отключена."),
show_alert=True,
)
return
# Check if user already played
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(
texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."),
show_alert=True,
)
return
# Get game strategy and render
tpl = round_obj.template
strategy = get_game_strategy(tpl.slug)
if not strategy:
await callback.answer(
texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."),
show_alert=True,
)
return
render_result = strategy.render(
round_id=round_obj.id,
payload=round_obj.payload or {},
language=db_user.language,
)
# For text input games, create pending attempt and set FSM state
if render_result.requires_text_input:
await _attempt_service.create_pending_attempt(db2, round_obj.id, db_user.id)
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
render_result.text,
reply_markup=render_result.keyboard,
)
await callback.answer()
@auth_required
@error_handler
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
"""Handle button pick in contest games."""
texts = get_texts(db_user.language)
# Rate limit check
if not _check_rate_limit(db_user.id, "contest_pick", limit=1, window_seconds=3):
await callback.answer(
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
show_alert=True,
)
return
# Validate callback data
parts = _validate_callback_data(callback.data)
if not parts or len(parts) < 4 or parts[1] != "pick":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[2]
pick = "_".join(parts[3:])
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
# Re-check subscription
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await callback.answer(
texts.t("CONTEST_NOT_ELIGIBLE", "Игра недоступна без активной подписки."),
show_alert=True,
)
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
show_alert=True,
)
return
# Process attempt using service
result = await _attempt_service.process_button_attempt(
db=db2,
round_obj=round_obj,
user_id=db_user.id,
pick=pick,
language=db_user.language,
)
await callback.answer(result.message, show_alert=True)
@auth_required
@error_handler
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
"""Handle text answer in contest games."""
texts = get_texts(db_user.language)
data = await state.get_data()
round_id = data.get("contest_round_id")
if not round_id:
await state.clear()
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await message.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
return
# Process attempt using service
text_answer = (message.text or "").strip()
result = await _attempt_service.process_text_attempt(
db=db2,
round_obj=round_obj,
user_id=db_user.id,
text_answer=text_answer,
language=db_user.language,
)
await message.answer(
result.message,
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
def register_handlers(dp: Dispatcher):
"""Register contest handlers."""
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
dp.message.register(handle_text_answer, ContestStates.waiting_for_answer)
dp.message.register(lambda message: None, Command("contests")) # placeholder