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", reply_markup: dict | None = None ) -> 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: payload = { "chat_id": chat_id, "text": text, "parse_mode": parse_mode } if reply_markup: payload["reply_markup"] = reply_markup response = await client.post( f"{self.api_url}/sendMessage", json=payload, 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, reply_markup: dict | None = None ) -> 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: logger.warning(f"[Notify] User {user_id} not found") return False if not user.telegram_id: logger.warning(f"[Notify] User {user_id} ({user.nickname}) has no telegram_id") return False logger.info(f"[Notify] Sending to user {user.nickname} (telegram_id={user.telegram_id})") return await self.send_message(user.telegram_id, message, reply_markup=reply_markup) async def notify_marathon_participants( self, db: AsyncSession, marathon_id: int, message: str, exclude_user_id: int | None = None, check_setting: str | None = None ) -> int: """Send notification to all marathon participants with linked Telegram. Args: check_setting: If provided, only send to users with this setting enabled. Options: 'notify_events', 'notify_disputes', 'notify_moderation' """ 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 # Check notification setting if specified if check_setting and not getattr(user, check_setting, True): logger.info(f"[Notify] Skipping user {user.nickname} - {check_setting} is disabled") 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 (respects notify_events setting).""" event_messages = { "golden_hour": f"🌟 Начался Golden Hour в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!", "jackpot": f"🎰 JACKPOT в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!", "double_risk": f"⚡ Double Risk в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!", "common_enemy": f"👥 Common Enemy в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!", "swap": f"🔄 Swap в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!", "game_choice": f"🎲 Выбор игры в «{marathon_title}»!\n\nВыбери игру и один из 3 челленджей!" } message = event_messages.get( event_type, f"📌 Новое событие в «{marathon_title}»!" ) return await self.notify_marathon_participants( db, marathon_id, message, check_setting='notify_events' ) async def notify_event_end( self, db: AsyncSession, marathon_id: int, event_type: str, marathon_title: str ) -> int: """Notify participants about event end (respects notify_events setting).""" event_names = { "golden_hour": "Golden Hour", "jackpot": "Jackpot", "double_risk": "Double Risk", "common_enemy": "Common Enemy", "swap": "Swap", "game_choice": "Выбор игры" } event_name = event_names.get(event_type, "Событие") message = f"⏰ {event_name} в «{marathon_title}» завершён" return await self.notify_marathon_participants( db, marathon_id, message, check_setting='notify_events' ) async def notify_marathon_start( self, db: AsyncSession, marathon_id: int, marathon_title: str ) -> int: """Notify participants about marathon start.""" message = ( f"🚀 Марафон «{marathon_title}» начался!\n\n" f"Время крутить колесо и получить первое задание!" ) return await self.notify_marathon_participants(db, marathon_id, message) async def notify_marathon_finish( self, db: AsyncSession, marathon_id: int, marathon_title: str ) -> int: """Notify participants about marathon finish.""" message = ( f"🏆 Марафон «{marathon_title}» завершён!\n\n" f"Зайди на сайт, чтобы увидеть итоговую таблицу!" ) return await self.notify_marathon_participants(db, marathon_id, message) async def notify_dispute_raised( self, db: AsyncSession, user_id: int, marathon_title: str, challenge_title: str, assignment_id: int ) -> bool: """Notify user about dispute raised on their assignment (respects notify_disputes setting).""" # Check user's notification settings result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user and not user.notify_disputes: logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled") return False logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}") dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}" logger.info(f"[Dispute] URL: {dispute_url}") # Telegram requires HTTPS for inline keyboard URLs use_inline_button = dispute_url.startswith("https://") if use_inline_button: message = ( f"⚠️ На твоё задание подан спор\n\n" f"Марафон: {marathon_title}\n" f"Задание: {challenge_title}" ) reply_markup = { "inline_keyboard": [[ {"text": "Открыть спор", "url": dispute_url} ]] } else: message = ( f"⚠️ На твоё задание подан спор\n\n" f"Марафон: {marathon_title}\n" f"Задание: {challenge_title}\n\n" f"🔗 {dispute_url}" ) reply_markup = None result = await self.notify_user(db, user_id, message, reply_markup=reply_markup) logger.info(f"[Dispute] Notification result: {result}") return result 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 (respects notify_disputes setting).""" # Check user's notification settings result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user and not user.notify_disputes: logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled") return False if is_valid: message = ( f"❌ Спор признан обоснованным\n\n" f"Марафон: {marathon_title}\n" f"Задание: {challenge_title}\n\n" f"Задание возвращено. Выполни его заново." ) else: message = ( f"✅ Спор отклонён\n\n" f"Марафон: {marathon_title}\n" f"Задание: {challenge_title}\n\n" f"Твоё выполнение засчитано!" ) return await self.notify_user(db, user_id, message) async def notify_game_approved( self, db: AsyncSession, user_id: int, marathon_title: str, game_title: str ) -> bool: """Notify user that their proposed game was approved (respects notify_moderation setting).""" # Check user's notification settings result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user and not user.notify_moderation: logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled") return False message = ( f"✅ Твоя игра одобрена!\n\n" f"Марафон: {marathon_title}\n" f"Игра: {game_title}\n\n" f"Теперь она доступна для всех участников." ) return await self.notify_user(db, user_id, message) async def notify_game_rejected( self, db: AsyncSession, user_id: int, marathon_title: str, game_title: str ) -> bool: """Notify user that their proposed game was rejected (respects notify_moderation setting).""" # Check user's notification settings result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user and not user.notify_moderation: logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled") return False message = ( f"❌ Твоя игра отклонена\n\n" f"Марафон: {marathon_title}\n" f"Игра: {game_title}\n\n" f"Ты можешь предложить другую игру." ) return await self.notify_user(db, user_id, message) async def notify_challenge_approved( self, db: AsyncSession, user_id: int, marathon_title: str, game_title: str, challenge_title: str ) -> bool: """Notify user that their proposed challenge was approved (respects notify_moderation setting).""" # Check user's notification settings result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user and not user.notify_moderation: logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled") return False message = ( f"✅ Твой челлендж одобрен!\n\n" f"Марафон: {marathon_title}\n" f"Игра: {game_title}\n" f"Задание: {challenge_title}\n\n" f"Теперь оно доступно для всех участников." ) return await self.notify_user(db, user_id, message) async def notify_challenge_rejected( self, db: AsyncSession, user_id: int, marathon_title: str, game_title: str, challenge_title: str ) -> bool: """Notify user that their proposed challenge was rejected (respects notify_moderation setting).""" # Check user's notification settings result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user and not user.notify_moderation: logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled") return False message = ( f"❌ Твой челлендж отклонён\n\n" f"Марафон: {marathon_title}\n" f"Игра: {game_title}\n" f"Задание: {challenge_title}\n\n" f"Ты можешь предложить другой челлендж." ) return await self.notify_user(db, user_id, message) async def notify_admin_disputes_pending( self, db: AsyncSession, count: int ) -> bool: """Notify admin about disputes waiting for decision.""" if not settings.TELEGRAM_ADMIN_ID: logger.warning("[Notify] No TELEGRAM_ADMIN_ID configured") return False admin_url = f"{settings.FRONTEND_URL}/admin/disputes" use_inline_button = admin_url.startswith("https://") if use_inline_button: message = ( f"⚠️ {count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения\n\n" f"Голосование завершено, требуется ваше решение." ) reply_markup = { "inline_keyboard": [[ {"text": "Открыть оспаривания", "url": admin_url} ]] } else: message = ( f"⚠️ {count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения\n\n" f"Голосование завершено, требуется ваше решение.\n\n" f"🔗 {admin_url}" ) reply_markup = None return await self.send_message( int(settings.TELEGRAM_ADMIN_ID), message, reply_markup=reply_markup ) # Global instance telegram_notifier = TelegramNotifier()