Files
remnawave-bedolaga-telegram…/app/services/poll_service.py
2025-11-06 01:30:17 +03:00

256 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import logging
from types import SimpleNamespace
from typing import Iterable
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 = logging.getLogger(__name__)
def _build_poll_invitation_text(poll: Poll, language: str) -> str:
texts = get_texts(language)
lines: list[str] = [f"🗳️ <b>{poll.title}</b>"]
if poll.description:
lines.append(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:
# Пропускаем пользователей, которые уже прошли опрос
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"
else: # 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(
"⚠️ Ограничение на количество подключений к БД: %s пользователю %s",
poll_id,
user_snapshot.telegram_id,
)
# Уменьшаем вероятность переполнения, делая небольшую задержку
await asyncio.sleep(0.1)
else:
logger.error(
"❌ Ошибка отправки опроса %s пользователю %s: %s",
poll_id,
user_snapshot.telegram_id,
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