diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 3caaa34..54f46c2 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/assignments.py b/backend/app/api/v1/assignments.py index d9dbdb2..7ce7bfb 100644 --- a/backend/app/api/v1/assignments.py +++ b/backend/app/api/v1/assignments.py @@ -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) diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index d113ce8..f71b74e 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -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) diff --git a/backend/app/api/v1/telegram.py b/backend/app/api/v1/telegram.py new file mode 100644 index 0000000..e441b38 --- /dev/null +++ b/backend/app/api/v1/telegram.py @@ -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 + ) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 4622876..a263a2f 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0099836..cea12b1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/core/security.py b/backend/app/core/security.py index f64fce8..6d0c6a6 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 2395583..c4b9b62 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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: diff --git a/backend/app/services/disputes.py b/backend/app/services/disputes.py index 65b90b4..4cda1e3 100644 --- a/backend/app/services/disputes.py +++ b/backend/app/services/disputes.py @@ -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. diff --git a/backend/app/services/events.py b/backend/app/services/events.py index 90fe663..51e6acb 100644 --- a/backend/app/services/events.py +++ b/backend/app/services/events.py @@ -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) diff --git a/backend/app/services/telegram_notifier.py b/backend/app/services/telegram_notifier.py new file mode 100644 index 0000000..3f3def7 --- /dev/null +++ b/backend/app/services/telegram_notifier.py @@ -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"🌟 Начался Golden Hour в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!", + "jackpot": f"🎰 JACKPOT в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!", + "double_risk": f"⚡ Double Risk в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!", + "common_enemy": f"👥 Common Enemy в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!", + "swap": f"🔄 Swap в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!", + "game_choice": f"🎲 Выбор игры в «{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"⏰ {event_name} в «{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"🚀 Марафон «{marathon_title}» начался!\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"🏆 Марафон «{marathon_title}» завершён!\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"⚠️ На твоё задание подан спор\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"❌ Спор признан обоснованным\n\n" + f"Марафон: {marathon_title}\n" + f"Задание: {challenge_title}\n\n" + f"Задание возвращено. Выполни его заново." + ) + else: + message = ( + f"✅ Спор отклонён\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() diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..7b3f32c --- /dev/null +++ b/bot/Dockerfile @@ -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"] diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..ef8a17b --- /dev/null +++ b/bot/config.py @@ -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() diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..8bd76d9 --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1 @@ +# Bot handlers diff --git a/bot/handlers/link.py b/bot/handlers/link.py new file mode 100644 index 0000000..7fb059d --- /dev/null +++ b/bot/handlers/link.py @@ -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( + "Аккаунт отвязан\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"Статус аккаунта\n\n" + f"✅ Аккаунт привязан\n" + f"👤 Никнейм: {user.get('nickname', 'N/A')}\n" + f"🆔 ID: {user.get('id', 'N/A')}", + reply_markup=get_main_menu() + ) + else: + await message.answer( + "Статус аккаунта\n\n" + "❌ Аккаунт не привязан\n\n" + "Привяжи его через настройки профиля на сайте.", + reply_markup=get_main_menu() + ) diff --git a/bot/handlers/marathons.py b/bot/handlers/marathons.py new file mode 100644 index 0000000..5d894cd --- /dev/null +++ b/bot/handlers/marathons.py @@ -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( + "Мои марафоны\n\n" + "У тебя пока нет активных марафонов.\n" + "Присоединись к марафону на сайте!", + reply_markup=get_main_menu() + ) + return + + text = "📊 Мои марафоны\n\n" + + for m in marathons: + status_emoji = { + "preparing": "⏳", + "active": "🎮", + "finished": "🏁" + }.get(m.get("status"), "❓") + + text += f"{status_emoji} {m.get('title')}\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"{marathon.get('title')}\n" + text += f"Статус: {status_text}\n\n" + + text += f"📈 Твоя статистика:\n" + text += f"• Очки: {participant.get('total_points', 0)}\n" + text += f"• Место: #{details.get('position', '?')}\n" + text += f"• Стрик: {participant.get('current_streak', 0)} 🔥\n" + text += f"• Дропов: {participant.get('drop_count', 0)}\n\n" + + if active_events: + text += "⚡ Активные события:\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"🎯 Текущее задание:\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( + "Мои марафоны\n\n" + "У тебя пока нет активных марафонов." + ) + await callback.answer() + return + + text = "📊 Мои марафоны\n\n" + + for m in marathons: + status_emoji = { + "preparing": "⏳", + "active": "🎮", + "finished": "🏁" + }.get(m.get("status"), "❓") + + text += f"{status_emoji} {m.get('title')}\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( + "📈 Статистика\n\n" + "Пока нет данных для отображения.\n" + "Начни участвовать в марафонах!", + reply_markup=get_main_menu() + ) + return + + text = f"📈 Общая статистика\n\n" + text += f"👤 {user.get('nickname', 'Игрок')}\n\n" + text += f"🏆 Марафонов завершено: {stats.get('marathons_completed', 0)}\n" + text += f"🎮 Марафонов активно: {stats.get('marathons_active', 0)}\n" + text += f"✅ Заданий выполнено: {stats.get('challenges_completed', 0)}\n" + text += f"💰 Всего очков: {stats.get('total_points', 0)}\n" + text += f"🔥 Лучший стрик: {stats.get('best_streak', 0)}\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( + "⚙️ Настройки\n\n" + "Управление уведомлениями будет доступно в следующем обновлении.\n\n" + "Сейчас ты получаешь все уведомления:\n" + "• 🌟 События (Golden Hour, Jackpot и др.)\n" + "• 🚀 Старт/финиш марафонов\n" + "• ⚠️ Споры по заданиям\n\n" + "Команды:\n" + "/unlink - Отвязать аккаунт\n" + "/status - Проверить привязку", + reply_markup=get_main_menu() + ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..c898422 --- /dev/null +++ b/bot/handlers/start.py @@ -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"Аккаунт успешно привязан!\n\n" + f"Привет, {user_nickname}!\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"Ошибка привязки аккаунта\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"С возвращением, {user.get('nickname', 'игрок')}!\n\n" + f"Твой аккаунт привязан. Используй меню для навигации:", + reply_markup=get_main_menu() + ) + else: + await message.answer( + "Добро пожаловать в Game Marathon Bot!\n\n" + "Этот бот поможет тебе следить за марафонами и " + "получать уведомления о важных событиях.\n\n" + "Для начала работы:\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( + "Справка по командам:\n\n" + "/start - Начать работу с ботом\n" + "/marathons - Мои марафоны\n" + "/stats - Моя статистика\n" + "/settings - Настройки уведомлений\n" + "/help - Эта справка\n\n" + "Уведомления:\n" + "Бот присылает уведомления о:\n" + "• 🌟 Golden Hour - очки x1.5\n" + "• 🎰 Jackpot - очки x3\n" + "• ⚡ Double Risk - половина очков, дропы бесплатны\n" + "• 👥 Common Enemy - общий челлендж\n" + "• 🚀 Старт/финиш марафонов\n" + "• ⚠️ Споры по заданиям", + reply_markup=get_main_menu() + ) diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..429c486 --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1 @@ +# Bot keyboards diff --git a/bot/keyboards/inline.py b/bot/keyboards/inline.py new file mode 100644 index 0000000..fee0f88 --- /dev/null +++ b/bot/keyboards/inline.py @@ -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) diff --git a/bot/keyboards/main_menu.py b/bot/keyboards/main_menu.py new file mode 100644 index 0000000..df76200 --- /dev/null +++ b/bot/keyboards/main_menu.py @@ -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="Выбери действие..." + ) diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..1debf3a --- /dev/null +++ b/bot/main.py @@ -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()) diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py new file mode 100644 index 0000000..8ce6c47 --- /dev/null +++ b/bot/middlewares/__init__.py @@ -0,0 +1 @@ +# Bot middlewares diff --git a/bot/middlewares/logging.py b/bot/middlewares/logging.py new file mode 100644 index 0000000..ee7f95a --- /dev/null +++ b/bot/middlewares/logging.py @@ -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 diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..1591b3b --- /dev/null +++ b/bot/requirements.txt @@ -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 diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..12aeaf1 --- /dev/null +++ b/bot/services/__init__.py @@ -0,0 +1 @@ +# Bot services diff --git a/bot/services/api_client.py b/bot/services/api_client.py new file mode 100644 index 0000000..97d02d1 --- /dev/null +++ b/bot/services/api_client.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index 30cc66b..1893eeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/api/telegram.ts b/frontend/src/api/telegram.ts new file mode 100644 index 0000000..08ef54d --- /dev/null +++ b/frontend/src/api/telegram.ts @@ -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 => { + const response = await client.post('/telegram/generate-link-token') + return response.data + }, + + unlinkTelegram: async (): Promise => { + await client.post('/users/me/telegram/unlink') + }, +} diff --git a/frontend/src/components/TelegramLink.tsx b/frontend/src/components/TelegramLink.tsx new file mode 100644 index 0000000..edb606b --- /dev/null +++ b/frontend/src/components/TelegramLink.tsx @@ -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(null) + const [error, setError] = useState(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 ( + <> + + + {isOpen && ( +
+
+ + +
+
+ +
+
+

Telegram

+

+ {isLinked ? 'Аккаунт привязан' : 'Привяжи аккаунт'} +

+
+
+ + {error && ( +
+ {error} +
+ )} + + {isLinked ? ( +
+
+

Привязан к:

+

+ {user?.telegram_username ? `@${user.telegram_username}` : `ID: ${user?.telegram_id}`} +

+
+ +
+

Ты будешь получать уведомления о:

+
    +
  • Начале и окончании событий
  • +
  • Старте и завершении марафонов
  • +
  • Спорах по заданиям
  • +
+
+ + +
+ ) : botUrl ? ( +
+

+ Нажми кнопку ниже, чтобы открыть бота и завершить привязку: +

+ + + +

+ Ссылка действительна 10 минут +

+
+ ) : ( +
+

+ Привяжи Telegram, чтобы получать уведомления о важных событиях: +

+ +
    +
  • + 🌟 + Golden Hour - очки x1.5 +
  • +
  • + 🎰 + Jackpot - очки x3 +
  • +
  • + + Double Risk и другие события +
  • +
+ + +
+ )} +
+
+ )} + + ) +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index f57437e..8c49708 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -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() { {user?.nickname} + +