388 lines
12 KiB
Python
388 lines
12 KiB
Python
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
|
|
)
|