"""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", "🎲 Игры/Конкурсы\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