import logging from fastapi import APIRouter from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser, BotSecretDep from app.core.config import settings from app.core.security import create_telegram_link_token, verify_telegram_link_token from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game logger = logging.getLogger(__name__) router = APIRouter(prefix="/telegram", tags=["telegram"]) # Schemas class TelegramLinkToken(BaseModel): token: str bot_url: str class TelegramConfirmLink(BaseModel): token: str telegram_id: int telegram_username: str | None = None telegram_first_name: str | None = None telegram_last_name: str | None = None telegram_avatar_url: str | None = None class TelegramLinkResponse(BaseModel): success: bool nickname: str | None = None error: str | None = None class TelegramUserResponse(BaseModel): id: int nickname: str login: str avatar_url: str | None = None class Config: from_attributes = True class TelegramMarathonResponse(BaseModel): id: int title: str status: str total_points: int position: int class Config: from_attributes = True class TelegramMarathonDetails(BaseModel): marathon: dict participant: dict position: int active_events: list[dict] current_assignment: dict | None class TelegramStatsResponse(BaseModel): marathons_completed: int marathons_active: int challenges_completed: int total_points: int best_streak: int # Endpoints @router.post("/generate-link-token", response_model=TelegramLinkToken) async def generate_link_token(current_user: CurrentUser): """Generate a one-time token for Telegram account linking.""" logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})") # Create a short token (≤64 chars) for Telegram deep link token = create_telegram_link_token( user_id=current_user.id, expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES ) logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})") bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot" bot_url = f"https://t.me/{bot_username}?start={token}" logger.info(f"[TG_LINK] Bot URL: {bot_url}") return TelegramLinkToken(token=token, bot_url=bot_url) @router.post("/confirm-link", response_model=TelegramLinkResponse) async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep): """Confirm Telegram account linking (called by bot).""" logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========") logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}") logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}") logger.info(f"[TG_CONFIRM] token: {data.token}") # Verify short token and extract user_id user_id = verify_telegram_link_token(data.token) logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}") if user_id is None: logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired") return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела") # Get user result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}") if not user: logger.error(f"[TG_CONFIRM] FAILED: User not found") return TelegramLinkResponse(success=False, error="Пользователь не найден") # Check if telegram_id already linked to another user result = await db.execute( select(User).where(User.telegram_id == data.telegram_id, User.id != user_id) ) existing_user = result.scalar_one_or_none() if existing_user: logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}") return TelegramLinkResponse( success=False, error="Этот Telegram аккаунт уже привязан к другому пользователю" ) # Link account logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}") user.telegram_id = data.telegram_id user.telegram_username = data.telegram_username user.telegram_first_name = data.telegram_first_name user.telegram_last_name = data.telegram_last_name user.telegram_avatar_url = data.telegram_avatar_url await db.commit() logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}") return TelegramLinkResponse(success=True, nickname=user.nickname) @router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None) async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep): """Get user by Telegram ID.""" logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}") result = await db.execute( select(User).where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() if not user: logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}") return None logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})") return TelegramUserResponse( id=user.id, nickname=user.nickname, login=user.login, avatar_url=user.avatar_url ) @router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse) async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep): """Unlink Telegram account.""" result = await db.execute( select(User).where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() if not user: return TelegramLinkResponse(success=False, error="Аккаунт не найден") user.telegram_id = None user.telegram_username = None await db.commit() return TelegramLinkResponse(success=True) @router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse]) async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep): """Get user's marathons by Telegram ID.""" # Get user result = await db.execute( select(User).where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() if not user: return [] # Get participations with marathons result = await db.execute( select(Participant, Marathon) .join(Marathon, Participant.marathon_id == Marathon.id) .where(Participant.user_id == user.id) .order_by(Marathon.created_at.desc()) ) participations = result.all() marathons = [] for participant, marathon in participations: # Calculate position position_result = await db.execute( select(func.count(Participant.id) + 1) .where( Participant.marathon_id == marathon.id, Participant.total_points > participant.total_points ) ) position = position_result.scalar() or 1 marathons.append(TelegramMarathonResponse( id=marathon.id, title=marathon.title, status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status, total_points=participant.total_points, position=position )) return marathons @router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None) async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep): """Get marathon details for user by Telegram ID.""" # Get user result = await db.execute( select(User).where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() if not user: return None # Get marathon result = await db.execute( select(Marathon).where(Marathon.id == marathon_id) ) marathon = result.scalar_one_or_none() if not marathon: return None # Get participant result = await db.execute( select(Participant) .where(Participant.marathon_id == marathon_id, Participant.user_id == user.id) ) participant = result.scalar_one_or_none() if not participant: return None # Calculate position position_result = await db.execute( select(func.count(Participant.id) + 1) .where( Participant.marathon_id == marathon_id, Participant.total_points > participant.total_points ) ) position = position_result.scalar() or 1 # Get active events result = await db.execute( select(Event) .where(Event.marathon_id == marathon_id, Event.is_active == True) ) active_events = result.scalars().all() events_data = [ { "id": e.id, "type": e.type.value if hasattr(e.type, 'value') else e.type, "start_time": e.start_time.isoformat() if e.start_time else None, "end_time": e.end_time.isoformat() if e.end_time else None } for e in active_events ] # Get current assignment result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game) ) .where( Assignment.participant_id == participant.id, Assignment.status == "active" ) .order_by(Assignment.started_at.desc()) .limit(1) ) assignment = result.scalar_one_or_none() assignment_data = None if assignment: challenge = assignment.challenge game = challenge.game if challenge else None assignment_data = { "id": assignment.id, "status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status, "challenge": { "id": challenge.id if challenge else None, "title": challenge.title if challenge else None, "difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None), "points": challenge.points if challenge else None, "game": { "id": game.id if game else None, "title": game.title if game else None } } if challenge else None } return TelegramMarathonDetails( marathon={ "id": marathon.id, "title": marathon.title, "status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status, "description": marathon.description }, participant={ "total_points": participant.total_points, "current_streak": participant.current_streak, "drop_count": participant.drop_count }, position=position, active_events=events_data, current_assignment=assignment_data ) @router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None) async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep): """Get user's overall statistics by Telegram ID.""" # Get user result = await db.execute( select(User).where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() if not user: return None # Get participations result = await db.execute( select(Participant, Marathon) .join(Marathon, Participant.marathon_id == Marathon.id) .where(Participant.user_id == user.id) ) participations = result.all() marathons_completed = 0 marathons_active = 0 total_points = 0 best_streak = 0 for participant, marathon in participations: status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status if status == "finished": marathons_completed += 1 elif status == "active": marathons_active += 1 total_points += participant.total_points if participant.current_streak > best_streak: best_streak = participant.current_streak # Count completed assignments result = await db.execute( select(func.count(Assignment.id)) .join(Participant, Assignment.participant_id == Participant.id) .where(Participant.user_id == user.id, Assignment.status == "completed") ) challenges_completed = result.scalar() or 0 return TelegramStatsResponse( marathons_completed=marathons_completed, marathons_active=marathons_active, challenges_completed=challenges_completed, total_points=total_points, best_streak=best_streak )