Files
game-marathon/backend/app/api/v1/telegram.py

388 lines
12 KiB
Python
Raw Normal View History

2025-12-16 20:06:16 +07:00
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
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
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):
"""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
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):
"""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):
"""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):
"""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):
"""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):
"""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
)