diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py
index 1220cae4..efcc31eb 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,209 @@ 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: FSMContext,
+ text: str,
+ *,
+ reply_markup: types.InlineKeyboardMarkup | None = None,
+ parse_mode: str | None = "HTML",
+) -> bool:
+ 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 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
+
+ logger.warning(
+ "Не удалось обновить сообщение создания опроса %s: %s",
+ message_id,
+ error,
+ )
+
+ try:
+ await bot.delete_message(chat_id=chat_id, message_id=message_id)
+ except TelegramBadRequest:
+ # Сообщение уже удалено или нельзя удалить — продолжаем без ошибок
+ pass
+ except Exception as delete_error: # pragma: no cover - defensive logging
+ logger.debug(
+ "Не удалось удалить старое сообщение создания опроса %s: %s",
+ message_id,
+ delete_error,
+ )
+ except Exception as error: # pragma: no cover - defensive logging
+ logger.error(
+ "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s",
+ message_id,
+ error,
+ )
+
+ if not chat_id:
+ return False
+
+ try:
+ new_message = await bot.send_message(
+ chat_id=chat_id,
+ text=text,
+ reply_markup=reply_markup,
+ parse_mode=parse_mode,
+ )
+ await state.update_data(
+ form_chat_id=new_message.chat.id,
+ form_message_id=new_message.message_id,
+ )
+ return True
+ except Exception as error: # pragma: no cover - defensive logging
+ logger.error(
+ "Не удалось отправить сообщение создания опроса в чат %s: %s",
+ chat_id,
+ error,
+ )
+ return False
+
+
+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,15 +481,24 @@ async def start_poll_creation(
texts = get_texts(db_user.language)
await state.clear()
await state.set_state(PollCreationStates.waiting_for_title)
- await state.update_data(questions=[])
+ await state.update_data(
+ questions=[],
+ form_chat_id=callback.message.chat.id,
+ )
- await callback.message.edit_text(
- texts.t(
- "ADMIN_POLLS_CREATION_TITLE_PROMPT",
- "🗳️ Создание опроса\n\nВведите заголовок опроса:",
- ),
+ state_data = await state.get_data()
+ form_text = _render_creation_progress(texts, state_data, "title")
+
+ form_message = await callback.message.answer(
+ form_text,
parse_mode="HTML",
)
+ await state.update_data(
+ form_chat_id=form_message.chat.id,
+ form_message_id=form_message.message_id,
+ )
+
+ await _safe_delete_message(callback.message)
await callback.answer()
@@ -297,31 +510,50 @@ async def process_poll_title(
state: FSMContext,
db: AsyncSession,
):
+ texts = get_texts(db_user.language)
+
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,
+ cancel_text,
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+ if not updated:
+ await message.answer(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)
+
+ state_data = await state.get_data()
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, form_text)
+ if not updated:
+ await message.answer(error_text)
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, чтобы пропустить.",
- )
- + f"\n\n{get_html_help_text()}",
- parse_mode="HTML",
- )
+ new_data = await state.get_data()
+ form_text = _render_creation_progress(texts, new_data, "description")
+ updated = await _edit_creation_message(message.bot, state, form_text)
+ if not updated:
+ await message.answer(form_text, parse_mode="HTML")
@admin_required
@@ -333,36 +565,55 @@ 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,
+ cancel_text,
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+ if not updated:
+ await message.answer(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, form_text)
+ if not updated:
+ await message.answer(error_text)
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, state, form_text)
+ if not updated:
+ await message.answer(form_text, parse_mode="HTML")
def _parse_reward_amount(message_text: str) -> int | None:
@@ -388,18 +639,38 @@ 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,
+ cancel_text,
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+ if not updated:
+ await message.answer(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, form_text)
+ if not updated:
+ await message.answer(error_text)
return
reward_enabled = reward_kopeks > 0
@@ -409,16 +680,11 @@ 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, state, form_text)
+ if not updated:
+ await message.answer(form_text, parse_mode="HTML")
@admin_required
@@ -430,21 +696,40 @@ 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,
+ cancel_text,
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+ if not updated:
+ await message.answer(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, state, form_text)
+ if not updated:
+ await message.answer(error_text)
return
title = data.get("title")
@@ -462,31 +747,48 @@ 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,
+ state,
+ result_text,
+ reply_markup=keyboard,
parse_mode="HTML",
)
+ if not updated:
+ await message.answer(
+ 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, form_text)
+ if not updated:
+ await message.answer(error_text)
return
question_text = lines[0]
@@ -496,13 +798,21 @@ 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, state, form_text)
+ if not updated:
+ await message.answer(status_message, parse_mode="HTML")
async def _render_poll_details(poll: Poll, language: str) -> str:
@@ -669,6 +979,8 @@ 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:
@@ -689,7 +1001,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, db_user.language),
parse_mode="HTML",
)
await callback.answer()
diff --git a/app/services/poll_service.py b/app/services/poll_service.py
index 5e8b6187..fe640784 100644
--- a/app/services/poll_service.py
+++ b/app/services/poll_service.py
@@ -1,5 +1,6 @@
import asyncio
import logging
+from types import SimpleNamespace
from typing import Iterable
from aiogram import Bot
@@ -17,6 +18,7 @@ from app.database.models import (
TransactionType,
User,
)
+from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
@@ -70,11 +72,42 @@ 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: list[SimpleNamespace] = []
+ for raw_user in users:
+ try:
+ user_id = getattr(raw_user, "id")
+ telegram_id = getattr(raw_user, "telegram_id")
+ except AttributeError as error: # pragma: no cover - defensive logging
+ logger.warning("Пропускаем пользователя без обязательных полей: %s", error)
+ continue
+
+ language = getattr(raw_user, "language", None) or DEFAULT_LANGUAGE
+ user_snapshots.append(
+ SimpleNamespace(id=user_id, telegram_id=telegram_id, language=language)
+ )
+
+ for index, user in enumerate(user_snapshots, start=1):
+ if not user.telegram_id:
+ failed += 1
+ logger.error(
+ "❌ Ошибка отправки опроса %s: у пользователя %s отсутствует telegram_id",
+ poll_id,
+ user.id,
+ )
+ continue
+
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 +117,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 +125,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)
keyboard = build_start_keyboard(response.id, user.language)
await bot.send_message(
@@ -112,7 +145,7 @@ async def send_poll_to_users(
failed += 1
logger.error(
"❌ Ошибка отправки опроса %s пользователю %s: %s",
- poll.id,
+ poll_id,
user.telegram_id,
error,
)