Add telegram bot

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,387 @@
import logging
from fastapi import APIRouter
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.core.config import settings
from app.core.security import create_telegram_link_token, verify_telegram_link_token
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/telegram", tags=["telegram"])
# Schemas
class TelegramLinkToken(BaseModel):
token: str
bot_url: str
class TelegramConfirmLink(BaseModel):
token: str
telegram_id: int
telegram_username: str | None = None
class TelegramLinkResponse(BaseModel):
success: bool
nickname: str | None = None
error: str | None = None
class TelegramUserResponse(BaseModel):
id: int
nickname: str
login: str
avatar_url: str | None = None
class Config:
from_attributes = True
class TelegramMarathonResponse(BaseModel):
id: int
title: str
status: str
total_points: int
position: int
class Config:
from_attributes = True
class TelegramMarathonDetails(BaseModel):
marathon: dict
participant: dict
position: int
active_events: list[dict]
current_assignment: dict | None
class TelegramStatsResponse(BaseModel):
marathons_completed: int
marathons_active: int
challenges_completed: int
total_points: int
best_streak: int
# Endpoints
@router.post("/generate-link-token", response_model=TelegramLinkToken)
async def generate_link_token(current_user: CurrentUser):
"""Generate a one-time token for Telegram account linking."""
logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})")
# Create a short token (≤64 chars) for Telegram deep link
token = create_telegram_link_token(
user_id=current_user.id,
expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES
)
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
bot_url = f"https://t.me/{bot_username}?start={token}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
return TelegramLinkToken(token=token, bot_url=bot_url)
@router.post("/confirm-link", response_model=TelegramLinkResponse)
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
"""Confirm Telegram account linking (called by bot)."""
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}")
logger.info(f"[TG_CONFIRM] token: {data.token}")
# Verify short token and extract user_id
user_id = verify_telegram_link_token(data.token)
logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}")
if user_id is None:
logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired")
return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела")
# Get user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}")
if not user:
logger.error(f"[TG_CONFIRM] FAILED: User not found")
return TelegramLinkResponse(success=False, error="Пользователь не найден")
# Check if telegram_id already linked to another user
result = await db.execute(
select(User).where(User.telegram_id == data.telegram_id, User.id != user_id)
)
existing_user = result.scalar_one_or_none()
if existing_user:
logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}")
return TelegramLinkResponse(
success=False,
error="Этот Telegram аккаунт уже привязан к другому пользователю"
)
# Link account
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
user.telegram_id = data.telegram_id
user.telegram_username = data.telegram_username
await db.commit()
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")
return TelegramLinkResponse(success=True, nickname=user.nickname)
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
"""Get user by Telegram ID."""
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}")
return None
logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})")
return TelegramUserResponse(
id=user.id,
nickname=user.nickname,
login=user.login,
avatar_url=user.avatar_url
)
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
async def unlink_telegram(telegram_id: int, db: DbSession):
"""Unlink Telegram account."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return TelegramLinkResponse(success=False, error="Аккаунт не найден")
user.telegram_id = None
user.telegram_username = None
await db.commit()
return TelegramLinkResponse(success=True)
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
async def get_user_marathons(telegram_id: int, db: DbSession):
"""Get user's marathons by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return []
# Get participations with marathons
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
.order_by(Marathon.created_at.desc())
)
participations = result.all()
marathons = []
for participant, marathon in participations:
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon.id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
marathons.append(TelegramMarathonResponse(
id=marathon.id,
title=marathon.title,
status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
total_points=participant.total_points,
position=position
))
return marathons
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
"""Get marathon details for user by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
return None
# Get participant
result = await db.execute(
select(Participant)
.where(Participant.marathon_id == marathon_id, Participant.user_id == user.id)
)
participant = result.scalar_one_or_none()
if not participant:
return None
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon_id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
# Get active events
result = await db.execute(
select(Event)
.where(Event.marathon_id == marathon_id, Event.is_active == True)
)
active_events = result.scalars().all()
events_data = [
{
"id": e.id,
"type": e.type.value if hasattr(e.type, 'value') else e.type,
"start_time": e.start_time.isoformat() if e.start_time else None,
"end_time": e.end_time.isoformat() if e.end_time else None
}
for e in active_events
]
# Get current assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == "active"
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
assignment = result.scalar_one_or_none()
assignment_data = None
if assignment:
challenge = assignment.challenge
game = challenge.game if challenge else None
assignment_data = {
"id": assignment.id,
"status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status,
"challenge": {
"id": challenge.id if challenge else None,
"title": challenge.title if challenge else None,
"difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None),
"points": challenge.points if challenge else None,
"game": {
"id": game.id if game else None,
"title": game.title if game else None
}
} if challenge else None
}
return TelegramMarathonDetails(
marathon={
"id": marathon.id,
"title": marathon.title,
"status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
"description": marathon.description
},
participant={
"total_points": participant.total_points,
"current_streak": participant.current_streak,
"drop_count": participant.drop_count
},
position=position,
active_events=events_data,
current_assignment=assignment_data
)
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
async def get_user_stats(telegram_id: int, db: DbSession):
"""Get user's overall statistics by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get participations
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
)
participations = result.all()
marathons_completed = 0
marathons_active = 0
total_points = 0
best_streak = 0
for participant, marathon in participations:
status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status
if status == "finished":
marathons_completed += 1
elif status == "active":
marathons_active += 1
total_points += participant.total_points
if participant.current_streak > best_streak:
best_streak = participant.current_streak
# Count completed assignments
result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Assignment.participant_id == Participant.id)
.where(Participant.user_id == user.id, Assignment.status == "completed")
)
challenges_completed = result.scalar() or 0
return TelegramStatsResponse(
marathons_completed=marathons_completed,
marathons_active=marathons_active,
challenges_completed=challenges_completed,
total_points=total_points,
best_streak=best_streak
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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