Add telegram bot

This commit is contained in:
2025-12-16 20:06:16 +07:00
parent 9fd93a185c
commit 412de3bf05
32 changed files with 1721 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
router = APIRouter(prefix="/api/v1")
@@ -14,3 +14,4 @@ router.include_router(feed.router)
router.include_router(admin.router)
router.include_router(events.router)
router.include_router(assignments.router)
router.include_router(telegram.router)

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Assignment, AssignmentStatus, Participant, Challenge, User,
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
Dispute, DisputeStatus, DisputeComment, DisputeVote,
)
from app.schemas import (
@@ -19,6 +19,7 @@ from app.schemas import (
)
from app.schemas.user import UserPublic
from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["assignments"])
@@ -345,6 +346,17 @@ async def create_dispute(
await db.commit()
await db.refresh(dispute)
# Send notification to assignment owner
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_dispute_raised(
db,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=assignment.challenge.title
)
# Load relationships for response
result = await db.execute(
select(Dispute)

View File

@@ -27,6 +27,7 @@ from app.schemas import (
UserPublic,
SetParticipantRole,
)
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/marathons", tags=["marathons"])
@@ -294,6 +295,9 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)
@@ -319,6 +323,9 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)

View 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
)

View File

@@ -106,3 +106,22 @@ async def link_telegram(
await db.commit()
return MessageResponse(message="Telegram account linked successfully")
@router.post("/me/telegram/unlink", response_model=MessageResponse)
async def unlink_telegram(
current_user: CurrentUser,
db: DbSession,
):
if not current_user.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Telegram account is not linked",
)
current_user.telegram_id = None
current_user.telegram_username = None
await db.commit()
return MessageResponse(message="Telegram account unlinked successfully")

View File

@@ -20,6 +20,8 @@ class Settings(BaseSettings):
# Telegram
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
# Uploads
UPLOAD_DIR: str = "uploads"

View File

@@ -1,3 +1,8 @@
import base64
import hashlib
import hmac
import struct
import time
from datetime import datetime, timedelta
from typing import Any
@@ -35,3 +40,71 @@ def decode_access_token(token: str) -> dict | None:
return payload
except jwt.JWTError:
return None
def create_telegram_link_token(user_id: int, expire_minutes: int = 10) -> str:
"""
Create a short token for Telegram account linking.
Format: base64url encoded binary data (no separators).
Structure: user_id (4 bytes) + expire_at (4 bytes) + signature (8 bytes) = 16 bytes -> 22 chars base64url.
"""
expire_at = int(time.time()) + (expire_minutes * 60)
# Pack user_id and expire_at as unsigned 32-bit integers (8 bytes total)
data = struct.pack(">II", user_id, expire_at)
# Create HMAC signature (take first 8 bytes)
signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
# Combine data + signature (16 bytes)
token_bytes = data + signature
# Encode as base64url without padding
token = base64.urlsafe_b64encode(token_bytes).decode().rstrip("=")
return token
def verify_telegram_link_token(token: str) -> int | None:
"""
Verify Telegram link token and return user_id if valid.
Returns None if token is invalid or expired.
"""
try:
# Add padding if needed for base64 decoding
padding = 4 - (len(token) % 4)
if padding != 4:
token += "=" * padding
token_bytes = base64.urlsafe_b64decode(token)
if len(token_bytes) != 16:
return None
# Unpack data
data = token_bytes[:8]
provided_signature = token_bytes[8:]
user_id, expire_at = struct.unpack(">II", data)
# Check expiration
if time.time() > expire_at:
return None
# Verify signature
expected_signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
if not hmac.compare_digest(provided_signature, expected_signature):
return None
return user_id
except (ValueError, struct.error, Exception):
return None

View File

@@ -33,6 +33,8 @@ class UserPublic(UserBase):
login: str
avatar_url: str | None = None
role: str = "user"
telegram_id: int | None = None
telegram_username: str | None = None
created_at: datetime
class Config:

View File

@@ -8,8 +8,9 @@ from sqlalchemy.orm import selectinload
from app.models import (
Dispute, DisputeStatus, DisputeVote,
Assignment, AssignmentStatus, Participant,
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
)
from app.services.telegram_notifier import telegram_notifier
class DisputeService:
@@ -58,8 +59,53 @@ class DisputeService:
await db.commit()
# Send Telegram notification about dispute resolution
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
return result_status, votes_valid, votes_invalid
async def _notify_dispute_resolved(
self,
db: AsyncSession,
dispute: Dispute,
is_valid: bool
) -> None:
"""Send notification about dispute resolution to the assignment owner."""
try:
# Get assignment with challenge and marathon info
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(Assignment.id == dispute.assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
return
participant = assignment.participant
challenge = assignment.challenge
game = challenge.game if challenge else None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
)
marathon = result.scalar_one_or_none()
if marathon and participant:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown",
is_valid=is_valid
)
except Exception as e:
print(f"[DisputeService] Failed to send notification: {e}")
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
"""
Handle the case when proof is determined to be invalid.

View File

@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
from app.services.telegram_notifier import telegram_notifier
class EventService:
@@ -89,6 +90,14 @@ class EventService:
if created_by_id:
await db.refresh(event, ["created_by"])
# Send Telegram notifications
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_start(
db, marathon_id, event_type, marathon.title
)
return event
async def _assign_common_enemy_to_all(
@@ -124,6 +133,9 @@ class EventService:
result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if event:
event_type = event.type
marathon_id = event.marathon_id
event.is_active = False
if not event.end_time:
event.end_time = datetime.utcnow()
@@ -145,6 +157,14 @@ class EventService:
await db.commit()
# Send Telegram notifications about event end
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_end(
db, marathon_id, event_type, marathon.title
)
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
"""Consume jackpot event after one spin"""
await self.end_event(db, event_id)

View File

@@ -0,0 +1,212 @@
import logging
from typing import List
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models import User, Participant, Marathon
logger = logging.getLogger(__name__)
class TelegramNotifier:
"""Service for sending Telegram notifications."""
def __init__(self):
self.bot_token = settings.TELEGRAM_BOT_TOKEN
self.api_url = f"https://api.telegram.org/bot{self.bot_token}"
async def send_message(
self,
chat_id: int,
text: str,
parse_mode: str = "HTML"
) -> bool:
"""Send a message to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_url}/sendMessage",
json={
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode
},
timeout=10.0
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send message: {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram message: {e}")
return False
async def notify_user(
self,
db: AsyncSession,
user_id: int,
message: str
) -> bool:
"""Send notification to a user by user_id."""
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user or not user.telegram_id:
return False
return await self.send_message(user.telegram_id, message)
async def notify_marathon_participants(
self,
db: AsyncSession,
marathon_id: int,
message: str,
exclude_user_id: int | None = None
) -> int:
"""Send notification to all marathon participants with linked Telegram."""
result = await db.execute(
select(User)
.join(Participant, Participant.user_id == User.id)
.where(
Participant.marathon_id == marathon_id,
User.telegram_id.isnot(None)
)
)
users = result.scalars().all()
sent_count = 0
for user in users:
if exclude_user_id and user.id == exclude_user_id:
continue
if await self.send_message(user.telegram_id, message):
sent_count += 1
return sent_count
# Notification templates
async def notify_event_start(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event start."""
event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
"double_risk": f"⚡ <b>Double Risk</b> в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!",
"common_enemy": f"👥 <b>Common Enemy</b> в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!",
"swap": f"🔄 <b>Swap</b> в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!",
"game_choice": f"🎲 <b>Выбор игры</b> в «{marathon_title}»!\n\nВыбери игру и один из 3 челленджей!"
}
message = event_messages.get(
event_type,
f"📌 Новое событие в «{marathon_title}»!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_event_end(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event end."""
event_names = {
"golden_hour": "Golden Hour",
"jackpot": "Jackpot",
"double_risk": "Double Risk",
"common_enemy": "Common Enemy",
"swap": "Swap",
"game_choice": "Выбор игры"
}
event_name = event_names.get(event_type, "Событие")
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_start(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon start."""
message = (
f"🚀 <b>Марафон «{marathon_title}» начался!</b>\n\n"
f"Время крутить колесо и получить первое задание!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_finish(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon finish."""
message = (
f"🏆 <b>Марафон «{marathon_title}» завершён!</b>\n\n"
f"Зайди на сайт, чтобы увидеть итоговую таблицу!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_dispute_raised(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str
) -> bool:
"""Notify user about dispute raised on their assignment."""
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Зайди на сайт, чтобы ответить на спор."
)
return await self.notify_user(db, user_id, message)
async def notify_dispute_resolved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str,
is_valid: bool
) -> bool:
"""Notify user about dispute resolution."""
if is_valid:
message = (
f"❌ <b>Спор признан обоснованным</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Задание возвращено. Выполни его заново."
)
else:
message = (
f"✅ <b>Спор отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Твоё выполнение засчитано!"
)
return await self.notify_user(db, user_id, message)
# Global instance
telegram_notifier = TelegramNotifier()

10
bot/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

14
bot/config.py Normal file
View File

@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str
API_URL: str = "http://backend:8000"
BOT_USERNAME: str = "" # Will be set dynamically on startup
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

1
bot/handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot handlers

60
bot/handlers/link.py Normal file
View File

@@ -0,0 +1,60 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
router = Router()
@router.message(Command("unlink"))
async def cmd_unlink(message: Message):
"""Handle /unlink command to disconnect Telegram account."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Твой аккаунт не привязан к Game Marathon.\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
result = await api_client.unlink_telegram(message.from_user.id)
if result.get("success"):
await message.answer(
"<b>Аккаунт отвязан</b>\n\n"
"Ты больше не будешь получать уведомления.\n"
"Чтобы привязать аккаунт снова, используй кнопку в настройках профиля на сайте.",
reply_markup=get_main_menu()
)
else:
await message.answer(
"Произошла ошибка при отвязке аккаунта.\n"
"Попробуй позже или обратись к администратору.",
reply_markup=get_main_menu()
)
@router.message(Command("status"))
async def cmd_status(message: Message):
"""Check account link status."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if user:
await message.answer(
f"<b>Статус аккаунта</b>\n\n"
f"✅ Аккаунт привязан\n"
f"👤 Никнейм: <b>{user.get('nickname', 'N/A')}</b>\n"
f"🆔 ID: {user.get('id', 'N/A')}",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Статус аккаунта</b>\n\n"
"❌ Аккаунт не привязан\n\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)

211
bot/handlers/marathons.py Normal file
View File

@@ -0,0 +1,211 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from keyboards.main_menu import get_main_menu
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
from services.api_client import api_client
router = Router()
@router.message(Command("marathons"))
@router.message(F.text == "📊 Мои марафоны")
async def cmd_marathons(message: Message):
"""Show user's marathons."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
marathons = await api_client.get_user_marathons(message.from_user.id)
if not marathons:
await message.answer(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов.\n"
"Присоединись к марафону на сайте!",
reply_markup=get_main_menu()
)
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await message.answer(
text,
reply_markup=get_marathons_keyboard(marathons)
)
@router.callback_query(F.data.startswith("marathon:"))
async def marathon_details(callback: CallbackQuery):
"""Show marathon details."""
marathon_id = int(callback.data.split(":")[1])
details = await api_client.get_marathon_details(
marathon_id=marathon_id,
telegram_id=callback.from_user.id
)
if not details:
await callback.answer("Не удалось загрузить данные марафона", show_alert=True)
return
marathon = details.get("marathon", {})
participant = details.get("participant", {})
active_events = details.get("active_events", [])
current_assignment = details.get("current_assignment")
status_text = {
"preparing": "⏳ Подготовка",
"active": "🎮 Активен",
"finished": "🏁 Завершён"
}.get(marathon.get("status"), "")
text = f"<b>{marathon.get('title')}</b>\n"
text += f"Статус: {status_text}\n\n"
text += f"<b>📈 Твоя статистика:</b>\n"
text += f"• Очки: <b>{participant.get('total_points', 0)}</b>\n"
text += f"• Место: <b>#{details.get('position', '?')}</b>\n"
text += f"• Стрик: <b>{participant.get('current_streak', 0)}</b> 🔥\n"
text += f"• Дропов: <b>{participant.get('drop_count', 0)}</b>\n\n"
if active_events:
text += "<b>⚡ Активные события:</b>\n"
for event in active_events:
event_emoji = {
"golden_hour": "🌟",
"jackpot": "🎰",
"double_risk": "",
"common_enemy": "👥",
"swap": "🔄",
"game_choice": "🎲"
}.get(event.get("type"), "📌")
text += f"{event_emoji} {event.get('type', '').replace('_', ' ').title()}\n"
text += "\n"
if current_assignment:
challenge = current_assignment.get("challenge", {})
game = challenge.get("game", {})
text += f"<b>🎯 Текущее задание:</b>\n"
text += f"Игра: {game.get('title', 'N/A')}\n"
text += f"Задание: {challenge.get('title', 'N/A')}\n"
text += f"Сложность: {challenge.get('difficulty', 'N/A')}\n"
text += f"Очки: {challenge.get('points', 0)}\n"
await callback.message.edit_text(
text,
reply_markup=get_marathon_details_keyboard(marathon_id)
)
await callback.answer()
@router.callback_query(F.data == "back_to_marathons")
async def back_to_marathons(callback: CallbackQuery):
"""Go back to marathons list."""
marathons = await api_client.get_user_marathons(callback.from_user.id)
if not marathons:
await callback.message.edit_text(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов."
)
await callback.answer()
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await callback.message.edit_text(
text,
reply_markup=get_marathons_keyboard(marathons)
)
await callback.answer()
@router.message(Command("stats"))
@router.message(F.text == "📈 Статистика")
async def cmd_stats(message: Message):
"""Show user's overall statistics."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
stats = await api_client.get_user_stats(message.from_user.id)
if not stats:
await message.answer(
"<b>📈 Статистика</b>\n\n"
"Пока нет данных для отображения.\n"
"Начни участвовать в марафонах!",
reply_markup=get_main_menu()
)
return
text = f"<b>📈 Общая статистика</b>\n\n"
text += f"👤 <b>{user.get('nickname', 'Игрок')}</b>\n\n"
text += f"🏆 Марафонов завершено: <b>{stats.get('marathons_completed', 0)}</b>\n"
text += f"🎮 Марафонов активно: <b>{stats.get('marathons_active', 0)}</b>\n"
text += f"✅ Заданий выполнено: <b>{stats.get('challenges_completed', 0)}</b>\n"
text += f"💰 Всего очков: <b>{stats.get('total_points', 0)}</b>\n"
text += f"🔥 Лучший стрик: <b>{stats.get('best_streak', 0)}</b>\n"
await message.answer(text, reply_markup=get_main_menu())
@router.message(Command("settings"))
@router.message(F.text == "⚙️ Настройки")
async def cmd_settings(message: Message):
"""Show notification settings."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
await message.answer(
"<b>⚙️ Настройки</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
"Сейчас ты получаешь все уведомления:\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям\n\n"
"Команды:\n"
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
reply_markup=get_main_menu()
)

120
bot/handlers/start.py Normal file
View File

@@ -0,0 +1,120 @@
import logging
from aiogram import Router, F
from aiogram.filters import CommandStart, Command, CommandObject
from aiogram.types import Message
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
logger = logging.getLogger(__name__)
router = Router()
@router.message(CommandStart())
async def cmd_start(message: Message, command: CommandObject):
"""Handle /start command with or without deep link."""
logger.info(f"[START] ==================== START COMMAND ====================")
logger.info(f"[START] Telegram user: id={message.from_user.id}, username=@{message.from_user.username}")
logger.info(f"[START] Full message text: '{message.text}'")
logger.info(f"[START] Deep link args (command.args): '{command.args}'")
# Check if there's a deep link token (for account linking)
token = command.args
if token:
logger.info(f"[START] -------- TOKEN RECEIVED --------")
logger.info(f"[START] Token: {token}")
logger.info(f"[START] Token length: {len(token)} chars")
logger.info(f"[START] -------- CALLING API --------")
logger.info(f"[START] Sending to /telegram/confirm-link:")
logger.info(f"[START] - token: {token}")
logger.info(f"[START] - telegram_id: {message.from_user.id}")
logger.info(f"[START] - telegram_username: {message.from_user.username}")
result = await api_client.confirm_telegram_link(
token=token,
telegram_id=message.from_user.id,
telegram_username=message.from_user.username
)
logger.info(f"[START] -------- API RESPONSE --------")
logger.info(f"[START] Response: {result}")
logger.info(f"[START] Success: {result.get('success')}")
if result.get("success"):
user_nickname = result.get("nickname", "пользователь")
logger.info(f"[START] ✅ LINK SUCCESS! User '{user_nickname}' linked to telegram_id={message.from_user.id}")
await message.answer(
f"<b>Аккаунт успешно привязан!</b>\n\n"
f"Привет, <b>{user_nickname}</b>!\n\n"
f"Теперь ты будешь получать уведомления о:\n"
f"• Начале и окончании событий (Golden Hour, Jackpot и др.)\n"
f"• Старте и завершении марафонов\n"
f"• Спорах по твоим заданиям\n\n"
f"Используй меню ниже для навигации:",
reply_markup=get_main_menu()
)
return
else:
error = result.get("error", "Неизвестная ошибка")
logger.error(f"[START] ❌ LINK FAILED!")
logger.error(f"[START] Error: {error}")
logger.error(f"[START] Token was: {token}")
await message.answer(
f"<b>Ошибка привязки аккаунта</b>\n\n"
f"{error}\n\n"
f"Попробуй получить новую ссылку на сайте.",
reply_markup=get_main_menu()
)
return
# No token - regular start
logger.info(f"[START] No token, checking if user is already linked...")
user = await api_client.get_user_by_telegram_id(message.from_user.id)
logger.info(f"[START] API response: {user}")
if user:
await message.answer(
f"<b>С возвращением, {user.get('nickname', 'игрок')}!</b>\n\n"
f"Твой аккаунт привязан. Используй меню для навигации:",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Добро пожаловать в Game Marathon Bot!</b>\n\n"
"Этот бот поможет тебе следить за марафонами и "
"получать уведомления о важных событиях.\n\n"
"<b>Для начала работы:</b>\n"
"1. Зайди на сайт в настройки профиля\n"
"2. Нажми кнопку «Привязать Telegram»\n"
"3. Перейди по полученной ссылке\n\n"
"После привязки ты сможешь:\n"
"• Смотреть свои марафоны\n"
"• Получать уведомления о событиях\n"
"• Следить за статистикой",
reply_markup=get_main_menu()
)
@router.message(Command("help"))
@router.message(F.text == "❓ Помощь")
async def cmd_help(message: Message):
"""Handle /help command."""
await message.answer(
"<b>Справка по командам:</b>\n\n"
"/start - Начать работу с ботом\n"
"/marathons - Мои марафоны\n"
"/stats - Моя статистика\n"
"/settings - Настройки уведомлений\n"
"/help - Эта справка\n\n"
"<b>Уведомления:</b>\n"
"Бот присылает уведомления о:\n"
"• 🌟 Golden Hour - очки x1.5\n"
"• 🎰 Jackpot - очки x3\n"
"• ⚡ Double Risk - половина очков, дропы бесплатны\n"
"• 👥 Common Enemy - общий челлендж\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям",
reply_markup=get_main_menu()
)

View File

@@ -0,0 +1 @@
# Bot keyboards

42
bot/keyboards/inline.py Normal file
View File

@@ -0,0 +1,42 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def get_marathons_keyboard(marathons: list) -> InlineKeyboardMarkup:
"""Create keyboard with marathon buttons."""
buttons = []
for marathon in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(marathon.get("status"), "")
buttons.append([
InlineKeyboardButton(
text=f"{status_emoji} {marathon.get('title', 'Marathon')}",
callback_data=f"marathon:{marathon.get('id')}"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
"""Create keyboard for marathon details view."""
buttons = [
[
InlineKeyboardButton(
text="🔄 Обновить",
callback_data=f"marathon:{marathon_id}"
)
],
[
InlineKeyboardButton(
text="◀️ Назад к списку",
callback_data="back_to_marathons"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)

View File

@@ -0,0 +1,21 @@
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
def get_main_menu() -> ReplyKeyboardMarkup:
"""Create main menu keyboard."""
keyboard = [
[
KeyboardButton(text="📊 Мои марафоны"),
KeyboardButton(text="📈 Статистика")
],
[
KeyboardButton(text="⚙️ Настройки"),
KeyboardButton(text="❓ Помощь")
]
]
return ReplyKeyboardMarkup(
keyboard=keyboard,
resize_keyboard=True,
input_field_placeholder="Выбери действие..."
)

65
bot/main.py Normal file
View File

@@ -0,0 +1,65 @@
import asyncio
import logging
import sys
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from config import settings
from handlers import start, marathons, link
from middlewares.logging import LoggingMiddleware
# Configure logging to stdout with DEBUG level
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# Set aiogram logging level
logging.getLogger("aiogram").setLevel(logging.INFO)
async def main():
logger.info("="*50)
logger.info("Starting Game Marathon Bot...")
logger.info(f"API_URL: {settings.API_URL}")
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
logger.info("="*50)
bot = Bot(
token=settings.TELEGRAM_BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
# Get bot username for deep links
bot_info = await bot.get_me()
settings.BOT_USERNAME = bot_info.username
logger.info(f"Bot info: @{settings.BOT_USERNAME} (id={bot_info.id})")
dp = Dispatcher()
# Register middleware
dp.message.middleware(LoggingMiddleware())
logger.info("Logging middleware registered")
# Register routers
logger.info("Registering routers...")
dp.include_router(start.router)
dp.include_router(link.router)
dp.include_router(marathons.router)
logger.info("Routers registered: start, link, marathons")
# Start polling
logger.info("Deleting webhook and starting polling...")
await bot.delete_webhook(drop_pending_updates=True)
logger.info("Polling started! Waiting for messages...")
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1 @@
# Bot middlewares

View File

@@ -0,0 +1,28 @@
import logging
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import Message, Update
logger = logging.getLogger(__name__)
class LoggingMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any]
) -> Any:
logger.info("="*60)
logger.info(f"[MIDDLEWARE] Incoming message from user {event.from_user.id}")
logger.info(f"[MIDDLEWARE] Username: @{event.from_user.username}")
logger.info(f"[MIDDLEWARE] Text: {event.text}")
logger.info(f"[MIDDLEWARE] Message ID: {event.message_id}")
logger.info(f"[MIDDLEWARE] Chat ID: {event.chat.id}")
logger.info("="*60)
result = await handler(event, data)
logger.info(f"[MIDDLEWARE] Handler completed for message {event.message_id}")
return result

5
bot/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
aiogram==3.23.0
aiohttp==3.10.5
pydantic==2.9.2
pydantic-settings==2.5.2
python-dotenv==1.0.1

1
bot/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot services

123
bot/services/api_client.py Normal file
View File

@@ -0,0 +1,123 @@
import logging
from typing import Any
import aiohttp
from config import settings
logger = logging.getLogger(__name__)
class APIClient:
"""HTTP client for backend API communication."""
def __init__(self):
self.base_url = settings.API_URL
self._session: aiohttp.ClientSession | None = None
logger.info(f"[APIClient] Initialized with base_url: {self.base_url}")
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
logger.info("[APIClient] Creating new aiohttp session")
self._session = aiohttp.ClientSession()
return self._session
async def _request(
self,
method: str,
endpoint: str,
**kwargs
) -> dict[str, Any] | None:
"""Make HTTP request to backend API."""
session = await self._get_session()
url = f"{self.base_url}/api/v1{endpoint}"
logger.info(f"[APIClient] {method} {url}")
if 'json' in kwargs:
logger.info(f"[APIClient] Request body: {kwargs['json']}")
if 'params' in kwargs:
logger.info(f"[APIClient] Request params: {kwargs['params']}")
try:
async with session.request(method, url, **kwargs) as response:
logger.info(f"[APIClient] Response status: {response.status}")
response_text = await response.text()
logger.info(f"[APIClient] Response body: {response_text[:500]}")
if response.status == 200:
import json
return json.loads(response_text)
elif response.status == 404:
logger.warning(f"[APIClient] 404 Not Found")
return None
else:
logger.error(f"[APIClient] API error {response.status}: {response_text}")
return {"error": response_text}
except aiohttp.ClientError as e:
logger.error(f"[APIClient] Request failed: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"[APIClient] Unexpected error: {e}")
return {"error": str(e)}
async def confirm_telegram_link(
self,
token: str,
telegram_id: int,
telegram_username: str | None
) -> dict[str, Any]:
"""Confirm Telegram account linking."""
result = await self._request(
"POST",
"/telegram/confirm-link",
json={
"token": token,
"telegram_id": telegram_id,
"telegram_username": telegram_username
}
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_by_telegram_id(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user by Telegram ID."""
return await self._request("GET", f"/telegram/user/{telegram_id}")
async def unlink_telegram(self, telegram_id: int) -> dict[str, Any]:
"""Unlink Telegram account."""
result = await self._request(
"POST",
f"/telegram/unlink/{telegram_id}"
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_marathons(self, telegram_id: int) -> list[dict[str, Any]]:
"""Get user's marathons."""
result = await self._request("GET", f"/telegram/marathons/{telegram_id}")
if isinstance(result, list):
return result
return result.get("marathons", []) if result else []
async def get_marathon_details(
self,
marathon_id: int,
telegram_id: int
) -> dict[str, Any] | None:
"""Get marathon details for user."""
return await self._request(
"GET",
f"/telegram/marathon/{marathon_id}",
params={"telegram_id": telegram_id}
)
async def get_user_stats(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user's overall statistics."""
return await self._request("GET", f"/telegram/stats/{telegram_id}")
async def close(self):
"""Close the HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
# Global API client instance
api_client = APIClient()

View File

@@ -27,6 +27,7 @@ services:
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
DEBUG: ${DEBUG:-false}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
@@ -72,5 +73,17 @@ services:
- backend
restart: unless-stopped
bot:
build:
context: ./bot
dockerfile: Dockerfile
container_name: marathon-bot
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- API_URL=http://backend:8000
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:

View File

@@ -0,0 +1,22 @@
import client from './client'
export interface TelegramLinkToken {
token: string
bot_url: string
}
export interface TelegramStatus {
telegram_id: number | null
telegram_username: string | null
}
export const telegramApi = {
generateLinkToken: async (): Promise<TelegramLinkToken> => {
const response = await client.post<TelegramLinkToken>('/telegram/generate-link-token')
return response.data
},
unlinkTelegram: async (): Promise<void> => {
await client.post('/users/me/telegram/unlink')
},
}

View File

@@ -0,0 +1,186 @@
import { useState } from 'react'
import { MessageCircle, ExternalLink, X, Loader2 } from 'lucide-react'
import { telegramApi } from '@/api/telegram'
import { useAuthStore } from '@/store/auth'
export function TelegramLink() {
const { user, updateUser } = useAuthStore()
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [botUrl, setBotUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const isLinked = !!user?.telegram_id
const handleGenerateLink = async () => {
setLoading(true)
setError(null)
try {
const { bot_url } = await telegramApi.generateLinkToken()
setBotUrl(bot_url)
} catch (err) {
setError('Не удалось сгенерировать ссылку')
} finally {
setLoading(false)
}
}
const handleUnlink = async () => {
setLoading(true)
setError(null)
try {
await telegramApi.unlinkTelegram()
updateUser({ telegram_id: null, telegram_username: null })
setIsOpen(false)
} catch (err) {
setError('Не удалось отвязать аккаунт')
} finally {
setLoading(false)
}
}
const handleOpenBot = () => {
if (botUrl) {
window.open(botUrl, '_blank')
setIsOpen(false)
setBotUrl(null)
}
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className={`p-2 rounded-lg transition-colors ${
isLinked
? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700'
: 'text-gray-400 hover:text-white hover:bg-gray-700'
}`}
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
>
<MessageCircle className="w-5 h-5" />
</button>
{isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative">
<button
onClick={() => {
setIsOpen(false)
setBotUrl(null)
setError(null)
}}
className="absolute top-4 right-4 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center">
<MessageCircle className="w-6 h-6 text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Telegram</h2>
<p className="text-sm text-gray-400">
{isLinked ? 'Аккаунт привязан' : 'Привяжи аккаунт'}
</p>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{isLinked ? (
<div className="space-y-4">
<div className="p-4 bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-400 mb-1">Привязан к:</p>
<p className="text-white font-medium">
{user?.telegram_username ? `@${user.telegram_username}` : `ID: ${user?.telegram_id}`}
</p>
</div>
<div className="text-sm text-gray-400">
<p className="mb-2">Ты будешь получать уведомления о:</p>
<ul className="list-disc list-inside space-y-1">
<li>Начале и окончании событий</li>
<li>Старте и завершении марафонов</li>
<li>Спорах по заданиям</li>
</ul>
</div>
<button
onClick={handleUnlink}
disabled={loading}
className="w-full py-3 px-4 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
) : (
'Отвязать аккаунт'
)}
</button>
</div>
) : botUrl ? (
<div className="space-y-4">
<p className="text-gray-300">
Нажми кнопку ниже, чтобы открыть бота и завершить привязку:
</p>
<button
onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram
</button>
<p className="text-sm text-gray-500 text-center">
Ссылка действительна 10 минут
</p>
</div>
) : (
<div className="space-y-4">
<p className="text-gray-300">
Привяжи Telegram, чтобы получать уведомления о важных событиях:
</p>
<ul className="text-sm text-gray-400 space-y-2">
<li className="flex items-center gap-2">
<span className="text-yellow-400">🌟</span>
Golden Hour - очки x1.5
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400">🎰</span>
Jackpot - очки x3
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400"></span>
Double Risk и другие события
</li>
</ul>
<button
onClick={handleGenerateLink}
disabled={loading}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<MessageCircle className="w-5 h-5" />
Привязать Telegram
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</>
)
}

View File

@@ -1,6 +1,7 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink'
export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore()
@@ -38,6 +39,8 @@ export function Layout() {
<span>{user?.nickname}</span>
</div>
<TelegramLink />
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-white transition-colors"

View File

@@ -17,6 +17,7 @@ interface AuthState {
clearError: () => void
setPendingInviteCode: (code: string | null) => void
consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void
}
export const useAuthStore = create<AuthState>()(
@@ -89,6 +90,13 @@ export const useAuthStore = create<AuthState>()(
set({ pendingInviteCode: null })
return code
},
updateUser: (updates) => {
const currentUser = get().user
if (currentUser) {
set({ user: { ...currentUser, ...updates } })
}
},
}),
{
name: 'auth-storage',

View File

@@ -7,6 +7,8 @@ export interface User {
nickname: string
avatar_url: string | null
role: UserRole
telegram_id: number | null
telegram_username: string | null
created_at: string
}