Add telegram bot
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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_BOT_TOKEN: str = ""
|
||||
TELEGRAM_BOT_USERNAME: str = ""
|
||||
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
||||
|
||||
# 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 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user