import asyncio import html from collections.abc import Iterable from types import SimpleNamespace import structlog from aiogram import Bot from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.user import add_user_balance from app.database.models import ( Poll, PollOption, PollQuestion, PollResponse, TransactionType, User, ) from app.localization.texts import get_texts logger = structlog.get_logger(__name__) def _build_poll_invitation_text(poll: Poll, language: str) -> str: texts = get_texts(language) lines: list[str] = [f'🗳️ {html.escape(poll.title)}'] if poll.description: lines.append(html.escape(poll.description)) if poll.reward_enabled and poll.reward_amount_kopeks > 0: reward_line = texts.t( 'POLL_INVITATION_REWARD', '🎁 За участие вы получите {amount}.', ).format(amount=settings.format_price(poll.reward_amount_kopeks)) lines.append(reward_line) lines.append( texts.t( 'POLL_INVITATION_START', 'Нажмите кнопку ниже, чтобы пройти опрос.', ) ) return '\n\n'.join(lines) def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( text=texts.t('POLL_START_BUTTON', '📝 Пройти опрос'), callback_data=f'poll_start:{response_id}', ) ] ] ) async def send_poll_to_users( bot: Bot, db: AsyncSession, poll: Poll, users: Iterable[User], ) -> dict: from app.database.database import AsyncSessionLocal sent = 0 failed = 0 skipped = 0 poll_id = poll.id user_snapshots = [ SimpleNamespace( id=user.id, telegram_id=user.telegram_id, language=user.language, ) for user in users ] # Получаем список пользователей, которые уже прошли опрос, за один запрос user_ids = [user_snapshot.id for user_snapshot in user_snapshots] existing_responses_result = await db.execute( select(PollResponse.user_id).where(and_(PollResponse.poll_id == poll_id, PollResponse.user_id.in_(user_ids))) ) existing_user_ids = set(existing_responses_result.scalars().all()) # Используем умеренный семафор, чтобы не превышать лимиты подключений к БД semaphore = asyncio.Semaphore(30) # Баланс между производительностью и нагрузкой на БД # Создаем отдельную функцию для создания отдельной сессии для каждой отправки async def send_poll_invitation(user_snapshot): """Отправляет приглашение к опросу одному пользователю""" async with semaphore: # Skip email-only users (no telegram_id) if not user_snapshot.telegram_id: return 'skipped' # Пропускаем пользователей, которые уже прошли опрос if user_snapshot.id in existing_user_ids: return 'skipped' # Создаем новую сессию для изоляции транзакции async with AsyncSessionLocal() as new_db: try: # Проверяем еще раз в новой сессии на случай гонки existing_response = await new_db.execute( select(PollResponse.id).where( and_( PollResponse.poll_id == poll_id, PollResponse.user_id == user_snapshot.id, ) ) ) existing_id = existing_response.scalar_one_or_none() if existing_id: return 'skipped' response = PollResponse( poll_id=poll_id, user_id=user_snapshot.id, ) new_db.add(response) await new_db.flush() text = _build_poll_invitation_text(poll, user_snapshot.language) keyboard = build_start_keyboard(response.id, user_snapshot.language) await bot.send_message( chat_id=user_snapshot.telegram_id, text=text, reply_markup=keyboard, parse_mode='HTML', disable_web_page_preview=True, ) await new_db.commit() return 'sent' except TelegramBadRequest as error: error_text = str(error).lower() if 'chat not found' in error_text or 'bot was blocked by the user' in error_text: await new_db.rollback() return 'skipped' # pragma: no cover - unexpected telegram error await new_db.rollback() return 'failed' except Exception as error: # pragma: no cover - defensive logging await new_db.rollback() # Проверяем, является ли ошибка связанной с лимитом подключений if 'too many clients' in str(error).lower(): logger.warning( '⚠️ Ограничение на количество подключений к БД: пользователю', poll_id=poll_id, telegram_id=user_snapshot.telegram_id, ) # Уменьшаем вероятность переполнения, делая небольшую задержку await asyncio.sleep(0.1) else: logger.error( '❌ Ошибка отправки опроса пользователю', poll_id=poll_id, telegram_id=user_snapshot.telegram_id, error=error, ) return 'failed' # Отправляем все приглашения одновременно без задержек для максимальной скорости tasks = [send_poll_invitation(user_snapshot) for user_snapshot in user_snapshots] results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, str): # Успешно выполненная задача if result == 'sent': sent += 1 elif result == 'failed': failed += 1 elif result == 'skipped': skipped += 1 elif isinstance(result, Exception): # Ошибка выполнения задачи failed += 1 return { 'sent': sent, 'failed': failed, 'skipped': skipped, 'total': sent + failed + skipped, } async def reward_user_for_poll( db: AsyncSession, response: PollResponse, ) -> int: await db.refresh(response, with_for_update=True) poll = response.poll if not poll.reward_enabled or poll.reward_amount_kopeks <= 0: return 0 if response.reward_given: return response.reward_amount_kopeks user = response.user description = f'Награда за участие в опросе "{poll.title}"' response.reward_given = True response.reward_amount_kopeks = poll.reward_amount_kopeks success = await add_user_balance( db, user, poll.reward_amount_kopeks, description, transaction_type=TransactionType.POLL_REWARD, ) if not success: return 0 await db.refresh( response, attribute_names=['reward_given', 'reward_amount_kopeks'], ) return poll.reward_amount_kopeks async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]: if not response.poll or not response.poll.questions: return None, None answered_question_ids = {answer.question_id for answer in response.answers} ordered_questions = sorted(response.poll.questions, key=lambda q: q.order) for index, question in enumerate(ordered_questions, start=1): if question.id not in answered_question_ids: return index, question return None, None async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None: for option in question.options: if option.id == option_id: return option return None