Add telegram bot
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -14,3 +14,4 @@ router.include_router(feed.router)
|
|||||||
router.include_router(admin.router)
|
router.include_router(admin.router)
|
||||||
router.include_router(events.router)
|
router.include_router(events.router)
|
||||||
router.include_router(assignments.router)
|
router.include_router(assignments.router)
|
||||||
|
router.include_router(telegram.router)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Assignment, AssignmentStatus, Participant, Challenge, User,
|
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
|
||||||
Dispute, DisputeStatus, DisputeComment, DisputeVote,
|
Dispute, DisputeStatus, DisputeComment, DisputeVote,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -19,6 +19,7 @@ from app.schemas import (
|
|||||||
)
|
)
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(tags=["assignments"])
|
router = APIRouter(tags=["assignments"])
|
||||||
|
|
||||||
@@ -345,6 +346,17 @@ async def create_dispute(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(dispute)
|
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
|
# Load relationships for response
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Dispute)
|
select(Dispute)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.schemas import (
|
|||||||
UserPublic,
|
UserPublic,
|
||||||
SetParticipantRole,
|
SetParticipantRole,
|
||||||
)
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/marathons", tags=["marathons"])
|
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()
|
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)
|
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()
|
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)
|
return await get_marathon(marathon_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
387
backend/app/api/v1/telegram.py
Normal file
387
backend/app/api/v1/telegram.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, CurrentUser
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
||||||
|
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/telegram", tags=["telegram"])
|
||||||
|
|
||||||
|
|
||||||
|
# Schemas
|
||||||
|
class TelegramLinkToken(BaseModel):
|
||||||
|
token: str
|
||||||
|
bot_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfirmLink(BaseModel):
|
||||||
|
token: str
|
||||||
|
telegram_id: int
|
||||||
|
telegram_username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramLinkResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
nickname: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
login: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMarathonResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
total_points: int
|
||||||
|
position: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramMarathonDetails(BaseModel):
|
||||||
|
marathon: dict
|
||||||
|
participant: dict
|
||||||
|
position: int
|
||||||
|
active_events: list[dict]
|
||||||
|
current_assignment: dict | None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramStatsResponse(BaseModel):
|
||||||
|
marathons_completed: int
|
||||||
|
marathons_active: int
|
||||||
|
challenges_completed: int
|
||||||
|
total_points: int
|
||||||
|
best_streak: int
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
||||||
|
async def generate_link_token(current_user: CurrentUser):
|
||||||
|
"""Generate a one-time token for Telegram account linking."""
|
||||||
|
logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})")
|
||||||
|
|
||||||
|
# Create a short token (≤64 chars) for Telegram deep link
|
||||||
|
token = create_telegram_link_token(
|
||||||
|
user_id=current_user.id,
|
||||||
|
expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
||||||
|
|
||||||
|
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
|
||||||
|
bot_url = f"https://t.me/{bot_username}?start={token}"
|
||||||
|
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
||||||
|
|
||||||
|
return TelegramLinkToken(token=token, bot_url=bot_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
||||||
|
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
||||||
|
"""Confirm Telegram account linking (called by bot)."""
|
||||||
|
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
||||||
|
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
||||||
|
logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}")
|
||||||
|
logger.info(f"[TG_CONFIRM] token: {data.token}")
|
||||||
|
|
||||||
|
# Verify short token and extract user_id
|
||||||
|
user_id = verify_telegram_link_token(data.token)
|
||||||
|
logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}")
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired")
|
||||||
|
return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела")
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}")
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: User not found")
|
||||||
|
return TelegramLinkResponse(success=False, error="Пользователь не найден")
|
||||||
|
|
||||||
|
# Check if telegram_id already linked to another user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == data.telegram_id, User.id != user_id)
|
||||||
|
)
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
if existing_user:
|
||||||
|
logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}")
|
||||||
|
return TelegramLinkResponse(
|
||||||
|
success=False,
|
||||||
|
error="Этот Telegram аккаунт уже привязан к другому пользователю"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link account
|
||||||
|
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
|
||||||
|
user.telegram_id = data.telegram_id
|
||||||
|
user.telegram_username = data.telegram_username
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")
|
||||||
|
|
||||||
|
return TelegramLinkResponse(success=True, nickname=user.nickname)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
||||||
|
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
||||||
|
"""Get user by Telegram ID."""
|
||||||
|
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})")
|
||||||
|
return TelegramUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
nickname=user.nickname,
|
||||||
|
login=user.login,
|
||||||
|
avatar_url=user.avatar_url
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
||||||
|
async def unlink_telegram(telegram_id: int, db: DbSession):
|
||||||
|
"""Unlink Telegram account."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return TelegramLinkResponse(success=False, error="Аккаунт не найден")
|
||||||
|
|
||||||
|
user.telegram_id = None
|
||||||
|
user.telegram_username = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return TelegramLinkResponse(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
||||||
|
async def get_user_marathons(telegram_id: int, db: DbSession):
|
||||||
|
"""Get user's marathons by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get participations with marathons
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant, Marathon)
|
||||||
|
.join(Marathon, Participant.marathon_id == Marathon.id)
|
||||||
|
.where(Participant.user_id == user.id)
|
||||||
|
.order_by(Marathon.created_at.desc())
|
||||||
|
)
|
||||||
|
participations = result.all()
|
||||||
|
|
||||||
|
marathons = []
|
||||||
|
for participant, marathon in participations:
|
||||||
|
# Calculate position
|
||||||
|
position_result = await db.execute(
|
||||||
|
select(func.count(Participant.id) + 1)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon.id,
|
||||||
|
Participant.total_points > participant.total_points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position = position_result.scalar() or 1
|
||||||
|
|
||||||
|
marathons.append(TelegramMarathonResponse(
|
||||||
|
id=marathon.id,
|
||||||
|
title=marathon.title,
|
||||||
|
status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
|
||||||
|
total_points=participant.total_points,
|
||||||
|
position=position
|
||||||
|
))
|
||||||
|
|
||||||
|
return marathons
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
||||||
|
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
|
||||||
|
"""Get marathon details for user by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(Marathon.id == marathon_id)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not marathon:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.where(Participant.marathon_id == marathon_id, Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not participant:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate position
|
||||||
|
position_result = await db.execute(
|
||||||
|
select(func.count(Participant.id) + 1)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.total_points > participant.total_points
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position = position_result.scalar() or 1
|
||||||
|
|
||||||
|
# Get active events
|
||||||
|
result = await db.execute(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.marathon_id == marathon_id, Event.is_active == True)
|
||||||
|
)
|
||||||
|
active_events = result.scalars().all()
|
||||||
|
|
||||||
|
events_data = [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"type": e.type.value if hasattr(e.type, 'value') else e.type,
|
||||||
|
"start_time": e.start_time.isoformat() if e.start_time else None,
|
||||||
|
"end_time": e.end_time.isoformat() if e.end_time else None
|
||||||
|
}
|
||||||
|
for e in active_events
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get current assignment
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == "active"
|
||||||
|
)
|
||||||
|
.order_by(Assignment.started_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assignment_data = None
|
||||||
|
if assignment:
|
||||||
|
challenge = assignment.challenge
|
||||||
|
game = challenge.game if challenge else None
|
||||||
|
assignment_data = {
|
||||||
|
"id": assignment.id,
|
||||||
|
"status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status,
|
||||||
|
"challenge": {
|
||||||
|
"id": challenge.id if challenge else None,
|
||||||
|
"title": challenge.title if challenge else None,
|
||||||
|
"difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None),
|
||||||
|
"points": challenge.points if challenge else None,
|
||||||
|
"game": {
|
||||||
|
"id": game.id if game else None,
|
||||||
|
"title": game.title if game else None
|
||||||
|
}
|
||||||
|
} if challenge else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return TelegramMarathonDetails(
|
||||||
|
marathon={
|
||||||
|
"id": marathon.id,
|
||||||
|
"title": marathon.title,
|
||||||
|
"status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
|
||||||
|
"description": marathon.description
|
||||||
|
},
|
||||||
|
participant={
|
||||||
|
"total_points": participant.total_points,
|
||||||
|
"current_streak": participant.current_streak,
|
||||||
|
"drop_count": participant.drop_count
|
||||||
|
},
|
||||||
|
position=position,
|
||||||
|
active_events=events_data,
|
||||||
|
current_assignment=assignment_data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
||||||
|
async def get_user_stats(telegram_id: int, db: DbSession):
|
||||||
|
"""Get user's overall statistics by Telegram ID."""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get participations
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant, Marathon)
|
||||||
|
.join(Marathon, Participant.marathon_id == Marathon.id)
|
||||||
|
.where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
participations = result.all()
|
||||||
|
|
||||||
|
marathons_completed = 0
|
||||||
|
marathons_active = 0
|
||||||
|
total_points = 0
|
||||||
|
best_streak = 0
|
||||||
|
|
||||||
|
for participant, marathon in participations:
|
||||||
|
status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status
|
||||||
|
if status == "finished":
|
||||||
|
marathons_completed += 1
|
||||||
|
elif status == "active":
|
||||||
|
marathons_active += 1
|
||||||
|
|
||||||
|
total_points += participant.total_points
|
||||||
|
if participant.current_streak > best_streak:
|
||||||
|
best_streak = participant.current_streak
|
||||||
|
|
||||||
|
# Count completed assignments
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Assignment.id))
|
||||||
|
.join(Participant, Assignment.participant_id == Participant.id)
|
||||||
|
.where(Participant.user_id == user.id, Assignment.status == "completed")
|
||||||
|
)
|
||||||
|
challenges_completed = result.scalar() or 0
|
||||||
|
|
||||||
|
return TelegramStatsResponse(
|
||||||
|
marathons_completed=marathons_completed,
|
||||||
|
marathons_active=marathons_active,
|
||||||
|
challenges_completed=challenges_completed,
|
||||||
|
total_points=total_points,
|
||||||
|
best_streak=best_streak
|
||||||
|
)
|
||||||
@@ -106,3 +106,22 @@ async def link_telegram(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Telegram account linked successfully")
|
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")
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
TELEGRAM_BOT_TOKEN: str = ""
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
|
TELEGRAM_BOT_USERNAME: str = ""
|
||||||
|
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
UPLOAD_DIR: str = "uploads"
|
UPLOAD_DIR: str = "uploads"
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -35,3 +40,71 @@ def decode_access_token(token: str) -> dict | None:
|
|||||||
return payload
|
return payload
|
||||||
except jwt.JWTError:
|
except jwt.JWTError:
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class UserPublic(UserBase):
|
|||||||
login: str
|
login: str
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
role: str = "user"
|
role: str = "user"
|
||||||
|
telegram_id: int | None = None
|
||||||
|
telegram_username: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Dispute, DisputeStatus, DisputeVote,
|
Dispute, DisputeStatus, DisputeVote,
|
||||||
Assignment, AssignmentStatus, Participant,
|
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
|
||||||
)
|
)
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
|
||||||
class DisputeService:
|
class DisputeService:
|
||||||
@@ -58,8 +59,53 @@ class DisputeService:
|
|||||||
|
|
||||||
await db.commit()
|
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
|
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:
|
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||||
"""
|
"""
|
||||||
Handle the case when proof is determined to be invalid.
|
Handle the case when proof is determined to be invalid.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
|
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.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
|
||||||
class EventService:
|
class EventService:
|
||||||
@@ -89,6 +90,14 @@ class EventService:
|
|||||||
if created_by_id:
|
if created_by_id:
|
||||||
await db.refresh(event, ["created_by"])
|
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
|
return event
|
||||||
|
|
||||||
async def _assign_common_enemy_to_all(
|
async def _assign_common_enemy_to_all(
|
||||||
@@ -124,6 +133,9 @@ class EventService:
|
|||||||
result = await db.execute(select(Event).where(Event.id == event_id))
|
result = await db.execute(select(Event).where(Event.id == event_id))
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
if event:
|
if event:
|
||||||
|
event_type = event.type
|
||||||
|
marathon_id = event.marathon_id
|
||||||
|
|
||||||
event.is_active = False
|
event.is_active = False
|
||||||
if not event.end_time:
|
if not event.end_time:
|
||||||
event.end_time = datetime.utcnow()
|
event.end_time = datetime.utcnow()
|
||||||
@@ -145,6 +157,14 @@ class EventService:
|
|||||||
|
|
||||||
await db.commit()
|
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:
|
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
|
||||||
"""Consume jackpot event after one spin"""
|
"""Consume jackpot event after one spin"""
|
||||||
await self.end_event(db, event_id)
|
await self.end_event(db, event_id)
|
||||||
|
|||||||
212
backend/app/services/telegram_notifier.py
Normal file
212
backend/app/services/telegram_notifier.py
Normal 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
10
bot/Dockerfile
Normal 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
14
bot/config.py
Normal 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
1
bot/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot handlers
|
||||||
60
bot/handlers/link.py
Normal file
60
bot/handlers/link.py
Normal 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
211
bot/handlers/marathons.py
Normal 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
120
bot/handlers/start.py
Normal 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()
|
||||||
|
)
|
||||||
1
bot/keyboards/__init__.py
Normal file
1
bot/keyboards/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot keyboards
|
||||||
42
bot/keyboards/inline.py
Normal file
42
bot/keyboards/inline.py
Normal 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)
|
||||||
21
bot/keyboards/main_menu.py
Normal file
21
bot/keyboards/main_menu.py
Normal 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
65
bot/main.py
Normal 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())
|
||||||
1
bot/middlewares/__init__.py
Normal file
1
bot/middlewares/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot middlewares
|
||||||
28
bot/middlewares/logging.py
Normal file
28
bot/middlewares/logging.py
Normal 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
5
bot/requirements.txt
Normal 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
1
bot/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Bot services
|
||||||
123
bot/services/api_client.py
Normal file
123
bot/services/api_client.py
Normal 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()
|
||||||
@@ -27,6 +27,7 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
# S3 Storage
|
# S3 Storage
|
||||||
S3_ENABLED: ${S3_ENABLED:-false}
|
S3_ENABLED: ${S3_ENABLED:-false}
|
||||||
@@ -72,5 +73,17 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
22
frontend/src/api/telegram.ts
Normal file
22
frontend/src/api/telegram.ts
Normal 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')
|
||||||
|
},
|
||||||
|
}
|
||||||
186
frontend/src/components/TelegramLink.tsx
Normal file
186
frontend/src/components/TelegramLink.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
|
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
|
||||||
|
import { TelegramLink } from '@/components/TelegramLink'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, isAuthenticated, logout } = useAuthStore()
|
const { user, isAuthenticated, logout } = useAuthStore()
|
||||||
@@ -38,6 +39,8 @@ export function Layout() {
|
|||||||
<span>{user?.nickname}</span>
|
<span>{user?.nickname}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TelegramLink />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface AuthState {
|
|||||||
clearError: () => void
|
clearError: () => void
|
||||||
setPendingInviteCode: (code: string | null) => void
|
setPendingInviteCode: (code: string | null) => void
|
||||||
consumePendingInviteCode: () => string | null
|
consumePendingInviteCode: () => string | null
|
||||||
|
updateUser: (updates: Partial<User>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@@ -89,6 +90,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ pendingInviteCode: null })
|
set({ pendingInviteCode: null })
|
||||||
return code
|
return code
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateUser: (updates) => {
|
||||||
|
const currentUser = get().user
|
||||||
|
if (currentUser) {
|
||||||
|
set({ user: { ...currentUser, ...updates } })
|
||||||
|
}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface User {
|
|||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
telegram_id: number | null
|
||||||
|
telegram_username: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user