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

394 lines
13 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
2025-12-18 17:15:21 +07:00
from app.api.deps import DbSession, CurrentUser, BotSecretDep
2025-12-16 20:06:16 +07:00
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
2025-12-16 20:19:45 +07:00
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
2025-12-16 20:06:16 +07:00
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)})")
2025-12-19 02:07:25 +07:00
bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
2025-12-16 20:06:16 +07:00
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)
2025-12-18 17:15:21 +07:00
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
2025-12-16 20:06:16 +07:00
"""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
2025-12-16 20:19:45 +07:00
user.telegram_first_name = data.telegram_first_name
user.telegram_last_name = data.telegram_last_name
user.telegram_avatar_url = data.telegram_avatar_url
2025-12-16 20:06:16 +07:00
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)
2025-12-18 17:15:21 +07:00
async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
2025-12-16 20:06:16 +07:00
"""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)
2025-12-18 17:15:21 +07:00
async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
2025-12-16 20:06:16 +07:00
"""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])
2025-12-18 17:15:21 +07:00
async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
2025-12-16 20:06:16 +07:00
"""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)
2025-12-18 17:15:21 +07:00
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
2025-12-16 20:06:16 +07:00
"""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)
2025-12-18 17:15:21 +07:00
async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
2025-12-16 20:06:16 +07:00
"""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
)