9de34900a2
Bot uses default HTML parse mode — all messages are HTML-parsed by Telegram. Added html.escape() to all user-controlled and admin-controlled strings before interpolation into HTML messages to prevent injection and parse errors. 49 files, ~250+ injection points fixed: - user.full_name, first_name across all handlers and services - tariff.name/description in purchase flow, admin panel, auto-purchase service - campaign.name, start_parameter in admin and user-facing handlers - group.name, promo_group.name across promo management - contest.title, prize_text, leaderboard names (including public channels) - transaction.description (contains raw user.full_name from referral service) - restriction_reason across all balance and subscription handlers - ticket.title, message_text, poll.title, poll.description - welcome text template placeholders (first_name, username) - maintenance reason, admin_name, selected_prize.display_name New helpers in app/utils/formatting.py: - safe_html_name() for escaping display names - user_html_link() replacing 15+ duplicated inline link patterns
257 lines
9.2 KiB
Python
257 lines
9.2 KiB
Python
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'🗳️ <b>{html.escape(poll.title)}</b>']
|
|
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
|