Add telegram bot
This commit is contained in:
387
backend/app/api/v1/telegram.py
Normal file
387
backend/app/api/v1/telegram.py
Normal file
@@ -0,0 +1,387 @@
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user