From e5e3a9e4b5666e4065cbae53b9dd2eaf5bc7b3cf Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:44:20 +0300 Subject: [PATCH] Refresh poll answers after recording selections --- app/handlers/admin/polls.py | 558 ++++++++++++++++++++++++++++++----- app/handlers/polls.py | 75 ++++- app/services/poll_service.py | 52 +++- 3 files changed, 589 insertions(+), 96 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..97352ec1 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,7 +3,8 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Dispatcher, F, types +from aiogram import Bot, Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,227 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + log_method = ( + logger.debug + if "there is no text in the message to edit" in error_text + else logger.warning + ) + log_method( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +async def _send_creation_message( + message: types.Message, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> types.Message: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message to delete not found" in error_text: + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning( + "Не удалось удалить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + message_id, + error, + ) + + sent_message = await message.bot.send_message( + chat_id=message.chat.id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + + return sent_message + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,13 +499,14 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) + await _safe_delete_message(callback.message) await state.update_data(questions=[]) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + form_text = _render_creation_progress(texts, await state.get_data(), "title") + await _send_creation_message( + callback.message, + state, + form_text, parse_mode="HTML", ) await callback.answer() @@ -297,31 +520,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) @admin_required @@ -333,36 +591,71 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - await message.answer( - texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_INVALID_HTML", + "❌ Ошибка в HTML: {error}", + ).format(error=error_message) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - ) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +681,49 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +733,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) @admin_required @@ -430,21 +754,51 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - await message.answer( - texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), + error_text = texts.t( + "ADMIN_POLLS_CREATION_NEEDS_QUESTION", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return title = data.get("title") @@ -452,6 +806,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +818,55 @@ async def process_poll_question( questions=questions, ) - await state.clear() - reward_text = _format_reward_text(poll, db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", - ).format( - title=poll.title, - count=len(poll.questions), - reward=reward_text, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await _send_creation_message( + message, + state, + result_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - lines = [line.strip() for line in message.text.splitlines() if line.strip()] + lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return question_text = lines[0] @@ -496,13 +876,26 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=question_text), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,12 +1062,15 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - texts = get_texts(db_user.language) + user_language = db_user.language + texts = get_texts(user_language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -689,7 +1085,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + reply_markup=_build_poll_details_keyboard(poll_id_value, user_language), parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/polls.py b/app/handlers/polls.py index 2572b38b..de51d892 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -3,6 +3,7 @@ import logging from datetime import datetime from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -41,6 +42,44 @@ async def _render_question_text( return "\n".join(lines) +async def _update_poll_message( + message: types.Message, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + try: + await message.edit_text( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + logger.debug( + "Опросное сообщение уже актуально, пропускаем обновление: %s", + error, + ) + return True + + logger.warning( + "Не удалось обновить сообщение опроса %s: %s", + message.message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.exception( + "Непредвиденная ошибка при обновлении сообщения опроса %s: %s", + message.message_id, + error, + ) + + return False + + def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: buttons: list[list[types.InlineKeyboardButton]] = [] for option in sorted(question.options, key=lambda o: o.order): @@ -98,11 +137,13 @@ async def handle_poll_start( db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, question), - parse_mode="HTML", - ) + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return await callback.answer() @@ -152,7 +193,18 @@ async def handle_poll_answer( option_id=option.id, ) - response = await get_poll_response_by_id(db, response.id) + try: + await db.refresh(response, attribute_names=["answers"]) + except Exception as error: # pragma: no cover - defensive cache busting + logger.debug( + "Не удалось обновить локальные ответы опроса %s: %s", + response.id, + error, + ) + response = await get_poll_response_by_id(db, response.id) + if not response: + await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True) + return index, next_question = await get_next_question(response) if next_question: @@ -163,11 +215,13 @@ async def handle_poll_answer( len(response.poll.questions), db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, next_question), - parse_mode="HTML", - ) + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return await callback.answer() return @@ -185,7 +239,12 @@ async def handle_poll_answer( ).format(amount=settings.format_price(reward_amount)) ) - await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") + if not await _update_poll_message( + callback.message, + "\n\n".join(thanks_lines), + ): + await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")) + return asyncio.create_task( _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) ) diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 5e8b6187..fc3962e8 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,8 +1,10 @@ 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 @@ -22,8 +24,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) +def _build_poll_invitation_text(poll: Poll, language: str) -> str: + texts = get_texts(language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -70,11 +72,28 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots = [ + SimpleNamespace( + id=user.id, + telegram_id=user.telegram_id, + language=user.language, + ) + for user in users + ] + + for index, user in enumerate(user_snapshots, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +103,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +111,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user.language) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -108,11 +127,30 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) + 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: + skipped += 1 + logger.info( + "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", + user.telegram_id, + poll_id, + error, + ) + else: # pragma: no cover - unexpected telegram error + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s пользователю %s: %s", + poll_id, + user.telegram_id, + error, + ) + await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, )