diff --git a/README.md b/README.md
index c9e3cc7..1715057 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
+[Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md).
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.
diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md
index 5750830..47d1330 100644
--- a/docs/ADMIN_GUIDE.md
+++ b/docs/ADMIN_GUIDE.md
@@ -14,6 +14,7 @@
## Работа с пользователями
1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
+ Карта вводится цифрами; допустимы и три, и четыре цифры, ведущие нули сохраняются. Если карта заранее привязана к этому же клиенту, он может завершить регистрацию с ней. При ошибке номера или телефона можно повторить ввод на текущем шаге.
2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие.
diff --git a/docs/TEXT_INPUT_AUDIT.md b/docs/TEXT_INPUT_AUDIT.md
new file mode 100644
index 0000000..bb8f796
--- /dev/null
+++ b/docs/TEXT_INPUT_AUDIT.md
@@ -0,0 +1,61 @@
+# Проверка текстового ввода
+
+Проверены зарегистрированные обработчики личных диалогов: регистрация, админка, касса, роли, каналы, эмодзи, общий и личный чат, команды и резервная обработка ввода. Демонстрационные `test_handlers.py` в рабочий диспетчер не подключены.
+
+## Клубная карта: три и четыре цифры
+
+Ограничения «не более трёх цифр» в текущем коде нет. В рабочей базе есть карты из четырёх цифр. Проверка полного диалога через диспетчер подтверждает приём `123`, `1234`, `0007` и сохранение ведущих нулей. Номер остаётся строкой; допустимы 1–50 цифр, как и до исправления.
+
+Исторический отказ именно на четырёх цифрах без текста ответа воспроизвести не удалось. Найдены и исправлены причины потери/остановки диалога:
+
+- Занятая карта сбрасывала регистрацию. Теперь остаётся шаг карты, можно исправить номер.
+- Карта, заранее записанная в профиле самого клиента, тоже считалась чужой занятой картой. Теперь клиент может завершить регистрацию со своей картой; карту другого пользователя присвоить нельзя.
+- Формат карты проверялся только после телефона. Теперь ошибка показывается сразу, на шаге карты.
+- Неправильный телефон приводил к потере введённых данных. Теперь сохраняются карта и никнейм.
+- Никнейм записывался второй транзакцией. Теперь никнейм, карта, телефон и регистрация сохраняются вместе.
+- Сбой ответа Telegram после успешного сохранения оставлял старое состояние. Завершённая регистрация теперь закрывает диалог до отправки подтверждения.
+- Если диалог уже истёк/отменён, обычное число могло поглощаться обработчиком счетов без ответа. Теперь бот предлагает `/register` или `/start`.
+
+## Остальные исправления
+
+| Область | Исправление |
+|---|---|
+| Команды во время ввода | Формы не сохраняют и не пересылают `/команды` как данные; известные команды доходят до своих обработчиков, неизвестные получают ответ. `/skip` оставлен только там, где он предусмотрен. |
+| Регистр команд | Зарегистрированные команды принимаются без учёта регистра. |
+| Текстовые формы | Добавлены проверки типа сообщения; неподходящее вложение не вызывает обращение к отсутствующему `message.text`. Состояние сохраняется, бот объясняет дальнейшее действие. |
+| Перехват текста | Слово «регистрация» не перезапускает активную форму. Быстрое удаление по ответу работает по точному триггеру и не перехватывает никнеймы или поля форм. |
+| Редактирование розыгрыша | Убран выдуманный callback и попытка редактировать сообщение человека. Розыгрыш без описания открывается корректно. Отмена и новое создание не сохраняют ID прежнего редактирования. |
+| Название, описание, призы | Проверяются пустые/слишком длинные значения, длина названия и каждого приза соответствует базе. Длинное подтверждение разбивается на сообщения, кнопки остаются на последнем. |
+| Массовые ID | Перенос строки равнозначен запятой. Некорректные ID не превращаются в молчаливую операцию с нулевым результатом. |
+| Массовые счета | Обрабатываются все строки, табуляция, пара «карта счёт», счёт и карта на отдельных строках, блоки Viposnova. Сохраняется принадлежность карты к счёту. |
+| Сохранение кассового диалога | Объект SQLAlchemy исключён из данных FSM: структура пригодна для JSON и Redis. При ошибочном вводе можно повторить попытку. |
+| Числовые ID | Границы чисел проверяются до SQL/Telegram; длинный номер карты в поиске не трактуется как выходящий за диапазон Telegram ID. |
+| Каналы и модерация | Проверяется ID; экранируются введённые значения в HTML. Ответ `/ban`/`/unban` на копию сообщения определяется по исходному автору в текущем диалоге. |
+| Чат | Команды не уходят получателю как текст. Выход словами «start»/«старт» использует действующий обработчик главного меню. |
+
+## Проверки
+
+`tests/test_text_inputs.py` проверяет полный цикл регистрации, дубликаты карт, конкурентную регистрацию двух клиентов, ошибку отправки подтверждения, ведущие нули, команды и неверный тип сообщения в текстовых формах, отмену/редактирование розыгрыша, длинные подтверждения, многострочный ввод счетов и ID, JSON-сериализацию состояния и команды модерации.
+
+Проверки выполняются на изолированных SQLite/PostgreSQL с подставным Telegram API. Рабочие карты, телефоны и роли пользователей для тестирования не изменяются. Отдельно проверяются CI, успешный деплой и здоровье работающего бота.
+
+## Handler inventory
+
+| Module | Text-capable handlers |
+|---|---|
+| [main.py](../main.py) | `cancel_dialog`, `cmd_start`, `btn_lotteries`, `btn_chat`, `btn_registration`, `cmd_register`, `text_registration`, `btn_my_code`, `btn_my_accounts`, `btn_help`, `btn_admin`, `btn_exit_chat`, `btn_main_menu`, `cmd_admin` |
+| [src/handlers/account_handlers.py](../src/handlers/account_handlers.py) | `detect_account_input` |
+| [src/handlers/admin_account_handlers.py](../src/handlers/admin_account_handlers.py) | `cancel_command`, `add_account_command`, `process_accounts_data`, `remove_account_command`, `verify_winner_command`, `winner_status_command`, `user_info_command` |
+| [src/handlers/admin_chat_handlers.py](../src/handlers/admin_chat_handlers.py) | `cmd_chat_mode`, `cmd_set_forward`, `cmd_global_ban`, `cmd_ban`, `cmd_unban`, `cmd_banlist`, `cmd_delete_message`, `cmd_chat_stats` |
+| [src/handlers/admin_emoji_handlers.py](../src/handlers/admin_emoji_handlers.py) | `add_emoji_start`, `receive_emoji`, `receive_emoji_description`, `list_my_emojis`, `list_all_emojis`, `delete_emoji_start` |
+| [src/handlers/admin_panel.py](../src/handlers/admin_panel.py) | `process_lottery_title`, `process_lottery_description`, `process_lottery_prizes`, `process_add_participant`, `process_remove_participant`, `process_search_participants`, `process_bulk_add_participant`, `process_bulk_remove_participant`, `process_add_to_lottery`, `process_remove_from_lottery`, `process_bulk_add_accounts`, `process_bulk_remove_accounts`, `process_winner_place`, `process_winner_user`, `admin_broadcast_send`, `admin_broadcast_add_channel_id`, `admin_broadcast_add_channel_description`, `admin_users_search_process` |
+| [src/handlers/cashier_handlers.py](../src/handlers/cashier_handlers.py) | `cashier_menu`, `add_accounts` |
+| [src/handlers/chat_handlers.py](../src/handlers/chat_handlers.py) | `enter_chat_command`, `exit_chat_command`, `check_exit_keywords` |
+| [src/handlers/help_handlers.py](../src/handlers/help_handlers.py) | `help_command` |
+| [src/handlers/input_fallback.py](../src/handlers/input_fallback.py) | `unknown_command`, `unmatched_input` |
+| [src/handlers/message_management.py](../src/handlers/message_management.py) | `delete_replied_message`, `quick_delete_replied_message` |
+| [src/handlers/operator_help.py](../src/handlers/operator_help.py) | `own_id`, `help_command` |
+| [src/handlers/p2p_chat.py](../src/handlers/p2p_chat.py) | `show_chat_menu`, `handle_p2p_message` |
+| [src/handlers/redraw_handlers.py](../src/handlers/redraw_handlers.py) | `check_unclaimed_winners`, `redraw_lottery` |
+| [src/handlers/registration_handlers.py](../src/handlers/registration_handlers.py) | `process_nickname`, `process_club_card`, `process_phone`, `show_verification_code`, `show_user_accounts` |
+| [src/handlers/staff_handlers.py](../src/handlers/staff_handlers.py) | `staff_menu`, `staff_command`, `staff_target`, `confirmation_pending`, `staff_list_command` |
diff --git a/main.py b/main.py
index 65b237f..a7fe900 100644
--- a/main.py
+++ b/main.py
@@ -8,7 +8,7 @@ from contextlib import asynccontextmanager
from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message, CallbackQuery
-from aiogram.filters import Command
+from aiogram.filters import Command, StateFilter
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
from aiogram.client.session.aiohttp import AiohttpSession
@@ -164,7 +164,7 @@ async def cmd_register(message: Message, state: FSMContext):
await btn_registration(message, state)
-@router.message(F.text.lower().in_(["регистрация", "регистр", "register"]))
+@router.message(StateFilter(None), F.text.lower().in_(["регистрация", "регистр", "register"]))
async def text_registration(message: Message, state: FSMContext):
"""Обработчик текста для регистрации"""
await btn_registration(message, state)
@@ -192,9 +192,9 @@ async def btn_help(message: Message):
@router.message(F.text == "⚙️ Админ панель")
-async def btn_admin(message: Message):
+async def btn_admin(message: Message, state: FSMContext):
"""Обработчик кнопки 'Админ панель'"""
- await cmd_admin(message)
+ await cmd_admin(message, state)
@router.message(F.text == "🚪 Выйти из чата")
@@ -211,7 +211,7 @@ async def btn_main_menu(message: Message, state: FSMContext):
@router.message(CaseInsensitiveCommand("admin"))
-async def cmd_admin(message: Message):
+async def cmd_admin(message: Message, state: FSMContext):
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
from src.core.config import ADMIN_IDS
from src.core.database import async_session_maker
@@ -238,6 +238,7 @@ async def cmd_admin(message: Message):
return
# Отправляем сообщение с кнопкой админ панели
+ await state.clear()
from src.components.ui import KeyboardBuilderImpl
kb = KeyboardBuilderImpl()
keyboard = kb.get_admin_keyboard()
@@ -329,6 +330,8 @@ def configure_dispatcher():
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
+ from src.handlers.input_fallback import input_fallback_router
+ dp.include_router(input_fallback_router)
diff --git a/src/core/registration_services.py b/src/core/registration_services.py
index c7b2ba4..3353296 100644
--- a/src/core/registration_services.py
+++ b/src/core/registration_services.py
@@ -6,6 +6,7 @@ from typing import Optional, List
from datetime import datetime, timezone, timedelta
from sqlalchemy.exc import IntegrityError
from src.utils.account_utils import format_account_number
+from src.utils.input_validation import club_card, phone_number
import secrets
@@ -17,16 +18,17 @@ class RegistrationService:
session: AsyncSession,
telegram_id: int,
club_card_number: str,
- phone: Optional[str] = None
+ phone: Optional[str] = None,
+ nickname: Optional[str] = None,
) -> User:
"""Зарегистрировать нового пользователя с клубной картой"""
- if not club_card_number or not club_card_number.isascii() or not club_card_number.isdigit() or len(club_card_number) > 50:
- raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр")
- if phone and len(phone) > 20:
- raise ValueError("Телефон должен быть не длиннее 20 символов")
+ club_card_number = club_card(club_card_number)
+ phone = phone_number(phone)
+ if nickname is not None and not 2 <= len(nickname) <= 20:
+ raise ValueError("Никнейм должен содержать от 2 до 20 символов")
# Проверяем, не занята ли клубная карта
existing = await session.execute(
- select(User).where(User.club_card_number == club_card_number)
+ select(User).where(User.club_card_number == club_card_number, User.telegram_id != telegram_id)
)
if existing.scalar_one_or_none():
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
@@ -39,10 +41,14 @@ class RegistrationService:
if not user:
raise ValueError("Пользователь не найден")
+ if user.is_registered:
+ raise ValueError("Вы уже зарегистрированы. Для изменения карты обратитесь к администратору.")
# Обновляем данные пользователя
user.club_card_number = club_card_number
user.phone = phone
+ if nickname is not None:
+ user.nickname = nickname
user.is_registered = True
user.generate_verification_code()
@@ -51,8 +57,6 @@ class RegistrationService:
except IntegrityError:
await session.rollback()
raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None
- await session.refresh(user)
-
return user
@staticmethod
diff --git a/src/core/services.py b/src/core/services.py
index ee44324..3ab2d95 100644
--- a/src/core/services.py
+++ b/src/core/services.py
@@ -31,6 +31,8 @@ class UserService:
@staticmethod
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
"""Получить пользователя по Telegram ID"""
+ if not isinstance(telegram_id, int) or not 0 < telegram_id < 2**63:
+ return None
result = await session.execute(
select(User).where(User.telegram_id == telegram_id)
)
@@ -157,10 +159,11 @@ class LotteryService:
async def create_lottery(session: AsyncSession, title: str, description: str,
prizes: List[str], creator_id: int) -> Lottery:
"""Создать новый розыгрыш"""
+ from src.utils.input_validation import lottery_field
lottery = Lottery(
- title=title,
- description=description,
- prizes=prizes,
+ title=lottery_field("title", title),
+ description=lottery_field("description", description),
+ prizes=lottery_field("prizes", prizes),
creator_id=creator_id
)
session.add(lottery)
@@ -171,6 +174,8 @@ class LotteryService:
@staticmethod
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
"""Получить розыгрыш по ID"""
+ if not isinstance(lottery_id, int) or not 0 < lottery_id < 2**31:
+ return None
result = await session.execute(
select(Lottery)
.options(selectinload(Lottery.participations).selectinload(Participation.user))
@@ -203,6 +208,9 @@ class LotteryService:
if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
return False
try:
+ from src.utils.input_validation import lottery_field
+ updates = {key: lottery_field(key, value) if key in {"title", "description", "prizes"} else value
+ for key, value in updates.items()}
result = await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
diff --git a/src/core/user_management.py b/src/core/user_management.py
index f56264c..054e401 100644
--- a/src/core/user_management.py
+++ b/src/core/user_management.py
@@ -68,7 +68,7 @@ class UserManagementService:
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
# Если запрос - число, ищем по telegram_id
- if query.isdigit():
+ if query.isascii() and query.isdigit() and len(query) <= 19 and 0 < int(query) < 2**63:
search_conditions.append(User.telegram_id == int(query))
conditions.append(or_(*search_conditions))
diff --git a/src/filters/dialog_input.py b/src/filters/dialog_input.py
new file mode 100644
index 0000000..2d73ce1
--- /dev/null
+++ b/src/filters/dialog_input.py
@@ -0,0 +1,16 @@
+"""Keep commands out of ordinary form input and messages being relayed."""
+from aiogram.filters import Filter
+from aiogram.types import Message
+
+
+class NotCommand(Filter):
+ def __init__(self, *allowed):
+ self.allowed = {name.lower() for name in allowed}
+
+ async def __call__(self, message: Message):
+ text = (message.text or "").lstrip()
+ if not text.startswith("/"):
+ return True
+ command = text.split()[0].split("@", 1)[0][1:].lower()
+ # /skip is an intentional value in the channel description dialog.
+ return command in self.allowed and len(text.split()) == 1
diff --git a/src/handlers/account_handlers.py b/src/handlers/account_handlers.py
index 3765142..9eee78c 100644
--- a/src/handlers/account_handlers.py
+++ b/src/handlers/account_handlers.py
@@ -43,13 +43,15 @@ async def detect_account_input(message: Message, state: FSMContext):
Извлекает номер клубной карты и определяет владельца
"""
if not await is_admin(message.from_user.id):
- return
+ from aiogram.dispatcher.event.bases import SkipHandler
+ raise SkipHandler()
# Парсим счета из сообщения
accounts = parse_accounts_from_message(message.text)
if not accounts:
- return # Счета не обнаружены, пропускаем
+ from aiogram.dispatcher.event.bases import SkipHandler
+ raise SkipHandler()
# Извлекаем номера клубных карт и определяем владельцев
from ..core.services import UserService
diff --git a/src/handlers/admin_account_handlers.py b/src/handlers/admin_account_handlers.py
index 3f235b0..0757098 100644
--- a/src/handlers/admin_account_handlers.py
+++ b/src/handlers/admin_account_handlers.py
@@ -1,8 +1,12 @@
"""Админские обработчики для управления счетами и верификации"""
+from src.utils.account_input import parse_account_records
+from src.utils.text_output import answer_plain_pages
+from src.utils.input_validation import numeric_id
+from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
-from aiogram.filters import Command
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, and_
@@ -121,7 +125,7 @@ async def process_single_account(message: Message, club_card: str, account_numbe
await state.clear()
-@router.message(AddAccountStates.waiting_for_data, F.text)
+@router.message(AddAccountStates.waiting_for_data, F.text, NotCommand())
async def process_accounts_data(message: Message, state: FSMContext):
"""Обработка данных счетов (один или несколько)"""
if message.text.strip().lower() == '/cancel':
@@ -140,7 +144,6 @@ async def process_accounts_data(message: Message, state: FSMContext):
f"Вы отправили: {len(lines)} строк\n\n"
f"Разделите данные на несколько частей."
)
- await state.clear()
return
# Отправляем начальное уведомление
@@ -150,46 +153,17 @@ async def process_accounts_data(message: Message, state: FSMContext):
)
accounts_data = []
- errors = []
+ entries, errors = parse_account_records(message.text)
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
# Универсальный парсер: поддержка однострочного и многострочного формата
- i = 0
- while i < len(lines):
- line = lines[i].strip()
-
- # Пропускаем пустые строки и строки с названиями/датами
- if not line or any(x in line.lower() for x in ['viposnova', '0.00', ':']):
- i += 1
+ for entry in entries:
+ parts = entry.split()
+ if len(parts) != 2:
+ errors.append("Для счёта не указана клубная карта: " + entry)
continue
-
- # Проверяем, есть ли в строке пробел (однострочный формат: "карта счет")
- if ' ' in line:
- # Однострочный формат: разделяем по первому пробелу
- parts = line.split(maxsplit=1)
- if len(parts) == 2:
- club_card, account_number = parts
- else:
- errors.append(f"Строка {i+1}: неверный формат")
- i += 1
- continue
- else:
- # Многострочный формат: текущая строка - счет, следующая - карта
- account_number = line
- i += 1
- if i >= len(lines):
- errors.append(f"Строка {i}: отсутствует номер карты после счета {account_number}")
- break
-
- club_card = lines[i].strip()
- # Пропускаем, если следующая строка содержит мусор
- if not club_card or any(x in club_card.lower() for x in ['viposnova', '0.00', ':']):
- errors.append(f"Строка {i}: некорректный номер карты после счета {account_number}")
- i += 1
- continue
-
- # Создаем счет
+ club_card, account_number = parts
try:
async with async_session_maker() as session:
account = await AccountService.create_account(
@@ -223,7 +197,6 @@ async def process_accounts_data(message: Message, state: FSMContext):
except Exception as e:
errors.append(f"Счет {account_number}: {public_error(e)}")
- i += 1
# Удаляем progress сообщение
try:
@@ -317,12 +290,11 @@ async def process_accounts_data(message: Message, state: FSMContext):
text += f"\n... и еще {len(errors) - 5} ошибок\n"
if not accounts_data:
- await message.answer(text)
- await state.clear()
+ await answer_plain_pages(message, text)
return
# Сохраняем данные и предлагаем добавить в розыгрыш
- await state.update_data(accounts=accounts_data)
+ await state.update_data(accounts=[{key: value for key, value in account.items() if key != "owner"} for account in accounts_data])
await show_lottery_selection(message, text, state)
@@ -332,7 +304,7 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon
lotteries = await LotteryService.get_active_lotteries(session)
if not lotteries:
- await message.answer(
+ await answer_plain_pages(message,
prev_text + "ℹ️ Нет активных розыгрышей для добавления счетов"
)
await state.clear()
@@ -353,7 +325,7 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
])
- await message.answer(
+ await answer_plain_pages(message,
prev_text + "➕ **Добавить счета в розыгрыш?**\n\n"
"Выберите розыгрыш из списка:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -503,7 +475,7 @@ async def verify_winner_command(message: Message):
verification_code = parts[1].upper()
try:
- lottery_id = int(parts[2])
+ lottery_id = numeric_id(parts[2], maximum=2**31 - 1)
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
@@ -588,7 +560,7 @@ async def winner_status_command(message: Message):
return
try:
- lottery_id = int(parts[1])
+ lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
diff --git a/src/handlers/admin_chat_handlers.py b/src/handlers/admin_chat_handlers.py
index 708d50e..a6b637c 100644
--- a/src/handlers/admin_chat_handlers.py
+++ b/src/handlers/admin_chat_handlers.py
@@ -1,8 +1,10 @@
"""Админские обработчики для управления чатом"""
+from html import escape
+from src.utils.input_validation import numeric_id
from src.utils.errors import public_error
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
-from aiogram.filters import Command
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from sqlalchemy.ext.asyncio import AsyncSession
from src.filters.case_insensitive import CaseInsensitiveCommand
@@ -20,6 +22,23 @@ from src.core.permissions import admin_only
router = Router(name='admin_chat_router')
+async def reply_target(message: Message):
+ """Resolve the human author of a bot copy only within the operator's dialog."""
+ replied = message.reply_to_message
+ if replied.from_user and not replied.from_user.is_bot:
+ return replied.from_user.id
+ from sqlalchemy import select
+ from src.core.models import User, P2PMessage
+ async with async_session_maker() as session:
+ original = await ChatMessageService.get_message_by_telegram_id(
+ session, replied.message_id, chat_id=message.chat.id)
+ if original:
+ return await session.scalar(select(User.telegram_id).where(User.id == original.user_id))
+ operator_id = select(User.id).where(User.telegram_id == message.from_user.id).scalar_subquery()
+ return await session.scalar(select(User.telegram_id).join(P2PMessage, P2PMessage.sender_id == User.id).where(
+ P2PMessage.recipient_id == operator_id, P2PMessage.recipient_message_id == replied.message_id))
+
+
def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура выбора режима чата"""
return InlineKeyboardMarkup(inline_keyboard=[
@@ -89,7 +108,11 @@ async def cmd_set_forward(message: Message):
)
return
- chat_id = args[1].strip()
+ try:
+ chat_id = str(numeric_id(args[1], signed=True))
+ except ValueError as error:
+ await message.answer(str(error))
+ return
async with async_session_maker() as session:
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
@@ -135,7 +158,10 @@ async def cmd_ban(message: Message):
# Проверяем является ли это ответом на сообщение
if message.reply_to_message:
- target_user_id = message.reply_to_message.from_user.id
+ target_user_id = await reply_target(message)
+ if target_user_id is None:
+ await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
+ return
reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
else:
args = message.text.split(maxsplit=2)
@@ -150,7 +176,7 @@ async def cmd_ban(message: Message):
return
try:
- target_user_id = int(args[1])
+ target_user_id = numeric_id(args[1])
reason = args[2] if len(args) > 2 else None
except ValueError:
await message.answer("❌ Неверный ID пользователя")
@@ -182,11 +208,11 @@ async def cmd_ban(message: Message):
reason=reason
)
- reason_text = f"\n📝 Причина: {reason}" if reason else ""
+ reason_text = f"\n📝 Причина: {escape(reason[:500])}" if reason else ""
await message.answer(
f"🚫 Пользователь забанен\n\n"
- f"👤 Пользователь: {user.name or 'Неизвестен'}\n"
+ f"👤 Пользователь: {escape(user.nickname or user.first_name or 'Неизвестен')}\n"
f"🆔 ID: {target_user_id}"
f"{reason_text}",
parse_mode="HTML"
@@ -200,7 +226,10 @@ async def cmd_unban(message: Message):
# Проверяем является ли это ответом на сообщение
if message.reply_to_message:
- target_user_id = message.reply_to_message.from_user.id
+ target_user_id = await reply_target(message)
+ if target_user_id is None:
+ await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
+ return
else:
args = message.text.split()
if len(args) < 2:
@@ -214,7 +243,7 @@ async def cmd_unban(message: Message):
return
try:
- target_user_id = int(args[1])
+ target_user_id = numeric_id(args[1])
except ValueError:
await message.answer("❌ Неверный ID пользователя")
return
@@ -252,11 +281,11 @@ async def cmd_banlist(message: Message):
user = ban.user
admin = ban.admin
- text += f"👤 {user.name or 'Неизвестен'} ({ban.telegram_id})\n"
- text += f"🔨 Забанил: {admin.name if admin else 'Неизвестен'}\n"
+ text += f"👤 {escape(user.nickname or user.first_name or 'Неизвестен')} ({ban.telegram_id})\n"
+ text += f"🔨 Забанил: {escape((admin.nickname or admin.first_name or 'Неизвестен') if admin else 'Неизвестен')}\n"
if ban.reason:
- text += f"📝 Причина: {ban.reason}\n"
+ text += f"📝 Причина: {escape(ban.reason[:500])}\n"
text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
text += "\n"
diff --git a/src/handlers/admin_emoji_handlers.py b/src/handlers/admin_emoji_handlers.py
index 0cfb68a..1664fee 100644
--- a/src/handlers/admin_emoji_handlers.py
+++ b/src/handlers/admin_emoji_handlers.py
@@ -2,12 +2,14 @@
Хендлеры для управления кастомными эмодзи админом
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
"""
+from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error
import logging
from html import escape
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
-from aiogram.filters import Command, StateFilter
+from aiogram.filters import StateFilter
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
@@ -42,7 +44,7 @@ async def add_emoji_start(message: Message, state: FSMContext):
await state.set_state(EmojiStates.waiting_for_emoji)
-@router.message(EmojiStates.waiting_for_emoji, F.text)
+@router.message(EmojiStates.waiting_for_emoji, F.text, NotCommand())
async def receive_emoji(message: Message, state: FSMContext):
"""Получить эмодзи от админа и сохранить его emoji_id"""
entities = [entity for entity in message.entities or () if entity.type == "custom_emoji"]
@@ -66,7 +68,7 @@ async def receive_emoji(message: Message, state: FSMContext):
await state.set_state(EmojiStates.waiting_for_description)
-@router.message(EmojiStates.waiting_for_description, F.text)
+@router.message(EmojiStates.waiting_for_description, F.text, NotCommand())
async def receive_emoji_description(message: Message, state: FSMContext):
"""Получить описание эмодзи и сохранить в БД"""
if not message.text:
diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py
index eb11cb9..f5f30ae 100644
--- a/src/handlers/admin_panel.py
+++ b/src/handlers/admin_panel.py
@@ -1,6 +1,10 @@
"""
Расширенная админ-панель для управления розыгрышами
"""
+from src.utils.input_validation import lottery_field, numeric_id
+from src.utils.text_output import answer_plain_pages
+from html import escape
+from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error
from src.core.access import is_admin
from src.core.access import is_admin as check_admin_access
@@ -180,11 +184,12 @@ def get_winner_management_keyboard() -> InlineKeyboardMarkup:
@admin_router.callback_query(F.data == "admin_panel")
-async def show_admin_panel(callback: CallbackQuery):
+async def show_admin_panel(callback: CallbackQuery, state: FSMContext):
"""Показать админ-панель"""
if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
+ await state.clear()
async with async_session_maker() as session:
# Быстрая статистика
@@ -215,11 +220,12 @@ async def show_admin_panel(callback: CallbackQuery):
# ======================
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
-async def show_lottery_management(callback: CallbackQuery):
+async def show_lottery_management(callback: CallbackQuery, state: FSMContext):
"""Управление розыгрышами"""
if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
+ await state.clear()
text = "🎲 Управление розыгрышами\n\n"
text += "Здесь вы можете создавать, редактировать и управлять розыгрышами.\n\n"
@@ -243,6 +249,7 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
logging.info(f"✅ Админ {callback.from_user.id} начинает создание розыгрыша")
+ await state.clear()
text = "📝 Создание нового розыгрыша\n\n"
text += "Шаг 1 из 4\n\n"
text += "Введите название розыгрыша:"
@@ -261,13 +268,19 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
-@admin_router.message(StateFilter(AdminStates.lottery_title))
+@admin_router.message(StateFilter(AdminStates.lottery_title), F.text, NotCommand())
async def process_lottery_title(message: Message, state: FSMContext):
"""Обработка названия розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
+ try:
+ value = lottery_field("title", message.text)
+ except ValueError as error:
+ await message.answer(str(error))
+ return
+
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
@@ -277,51 +290,49 @@ async def process_lottery_title(message: Message, state: FSMContext):
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
- title=message.text
+ title=value
)
if success:
- await message.answer(f"✅ Название изменено на: {message.text}")
+ await message.answer(f"✅ Название изменено на: {value}")
await state.clear()
# Возвращаемся к выбору полей
- from aiogram.types import CallbackQuery
- fake_callback = CallbackQuery(
- id="fake",
- from_user=message.from_user,
- chat_instance="fake",
- data=f"admin_edit_lottery_select_{edit_lottery_id}",
- message=message
- )
- await choose_edit_field(fake_callback, state)
+ await show_edit_fields(message, edit_lottery_id)
else:
await message.answer("❌ Ошибка при изменении названия")
return
# Если это создание нового розыгрыша
- await state.update_data(title=message.text)
+ await state.update_data(title=value)
text = f"📝 Создание нового розыгрыша\n\n"
text += f"Шаг 2 из 4\n\n"
- text += f"✅ Название: {message.text}\n\n"
+ text += f"✅ Название: {value}\n\n"
text += f"Введите описание розыгрыша (или '-' для пропуска):"
- await message.answer(text)
+ await answer_plain_pages(message, text)
await state.set_state(AdminStates.lottery_description)
-@admin_router.message(StateFilter(AdminStates.lottery_description))
+@admin_router.message(StateFilter(AdminStates.lottery_description), F.text, NotCommand())
async def process_lottery_description(message: Message, state: FSMContext):
"""Обработка описания розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
+ try:
+ value = lottery_field("description", message.text)
+ except ValueError as error:
+ await message.answer(str(error))
+ return
+
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
- description = None if message.text == "-" else message.text
+ description = value
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
@@ -333,21 +344,13 @@ async def process_lottery_description(message: Message, state: FSMContext):
await message.answer(f"✅ Описание изменено")
await state.clear()
# Возвращаемся к выбору полей
- from aiogram.types import CallbackQuery
- fake_callback = CallbackQuery(
- id="fake",
- from_user=message.from_user,
- chat_instance="fake",
- data=f"admin_edit_lottery_select_{edit_lottery_id}",
- message=message
- )
- await choose_edit_field(fake_callback, state)
+ await show_edit_fields(message, edit_lottery_id)
else:
await message.answer("❌ Ошибка при изменении описания")
return
# Если это создание нового розыгрыша
- description = None if message.text == "-" else message.text
+ description = value
await state.update_data(description=description)
data = await state.get_data()
@@ -363,21 +366,27 @@ async def process_lottery_description(message: Message, state: FSMContext):
text += f"🥉 AirPods Pro\n"
text += f"🏆 10,000 рублей"
- await message.answer(text)
+ await answer_plain_pages(message, text)
await state.set_state(AdminStates.lottery_prizes)
-@admin_router.message(StateFilter(AdminStates.lottery_prizes))
+@admin_router.message(StateFilter(AdminStates.lottery_prizes), F.text, NotCommand())
async def process_lottery_prizes(message: Message, state: FSMContext):
"""Обработка призов розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
+ try:
+ value = lottery_field("prizes", message.text)
+ except ValueError as error:
+ await message.answer(str(error))
+ return
+
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
- prizes = [prize.strip() for prize in message.text.split('\n') if prize.strip()]
+ prizes = value
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
@@ -392,15 +401,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
await message.answer(f"✅ Призы изменены")
await state.clear()
# Возвращаемся к выбору полей
- from aiogram.types import CallbackQuery
- fake_callback = CallbackQuery(
- id="fake",
- from_user=message.from_user,
- chat_instance="fake",
- data=f"admin_edit_lottery_select_{edit_lottery_id}",
- message=message
- )
- await choose_edit_field(fake_callback, state)
+ await show_edit_fields(message, edit_lottery_id)
else:
await message.answer("❌ Ошибка при изменении призов")
return
@@ -420,7 +421,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
text += f"\n✅ Подтвердите создание розыгрыша:"
- await message.answer(
+ await answer_plain_pages(message,
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Создать", callback_data="confirm_create_lottery")],
@@ -765,7 +766,7 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.add_participant_user)
-@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text)
+@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text, NotCommand())
async def process_add_participant(message: Message, state: FSMContext):
"""Обработка добавления участника"""
if not await check_admin_access(message.from_user.id):
@@ -886,7 +887,7 @@ async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMC
)
-@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text)
+@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text, NotCommand())
async def process_remove_participant(message: Message, state: FSMContext):
"""Обработка удаления участника"""
if not await check_admin_access(message.from_user.id):
@@ -1161,7 +1162,7 @@ async def start_search_participants(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.participant_search)
-@admin_router.message(StateFilter(AdminStates.participant_search), F.text)
+@admin_router.message(StateFilter(AdminStates.participant_search), F.text, NotCommand())
async def process_search_participants(message: Message, state: FSMContext):
"""Обработка поиска участников"""
if not await check_admin_access(message.from_user.id):
@@ -1169,6 +1170,9 @@ async def process_search_participants(message: Message, state: FSMContext):
return
search_term = message.text.strip()
+ if not 1 <= len(search_term) <= 100:
+ await message.answer("Поисковый запрос должен содержать от 1 до 100 символов. /cancel — отмена.")
+ return
async with async_session_maker() as session:
users = await UserService.search_users(session, search_term)
@@ -1279,7 +1283,7 @@ async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.add_participant_bulk)
-@admin_router.message(StateFilter(AdminStates.add_participant_bulk))
+@admin_router.message(StateFilter(AdminStates.add_participant_bulk), F.text, NotCommand())
async def process_bulk_add_participant(message: Message, state: FSMContext):
"""Обработка массового добавления участников"""
if not await check_admin_access(message.from_user.id):
@@ -1290,7 +1294,10 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
lottery_id = data['bulk_add_lottery_id']
# Парсим входные данные
- user_inputs = [x.strip() for x in message.text.split(',') if x.strip()]
+ user_inputs = [x.strip() for x in message.text.replace('\n', ',').split(',') if x.strip()]
+ if not user_inputs:
+ await message.answer('Введите хотя бы один Telegram ID или @username, либо /cancel.')
+ return
telegram_ids = []
async with async_session_maker() as session:
@@ -1302,11 +1309,14 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
if user:
telegram_ids.append(user.telegram_id)
elif user_input.isdigit():
- telegram_ids.append(int(user_input))
+ telegram_ids.append(numeric_id(user_input))
except:
continue
# Массовое добавление
+ if not telegram_ids or len(telegram_ids) != len(user_inputs):
+ await message.answer("Часть ID или username неверна либо не найдена. Исправьте список и повторите ввод. Изменения не внесены.")
+ return
results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id)
@@ -1410,7 +1420,7 @@ async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.remove_participant_bulk)
-@admin_router.message(StateFilter(AdminStates.remove_participant_bulk))
+@admin_router.message(StateFilter(AdminStates.remove_participant_bulk), F.text, NotCommand())
async def process_bulk_remove_participant(message: Message, state: FSMContext):
"""Обработка массового удаления участников"""
if not await check_admin_access(message.from_user.id):
@@ -1421,7 +1431,10 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
lottery_id = data['bulk_remove_lottery_id']
# Парсим входные данные
- user_inputs = [x.strip() for x in message.text.split(',') if x.strip()]
+ user_inputs = [x.strip() for x in message.text.replace('\n', ',').split(',') if x.strip()]
+ if not user_inputs:
+ await message.answer('Введите хотя бы один Telegram ID или @username, либо /cancel.')
+ return
telegram_ids = []
async with async_session_maker() as session:
@@ -1433,11 +1446,14 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
if user:
telegram_ids.append(user.telegram_id)
elif user_input.isdigit():
- telegram_ids.append(int(user_input))
+ telegram_ids.append(numeric_id(user_input))
except:
continue
# Массовое удаление
+ if not telegram_ids or len(telegram_ids) != len(user_inputs):
+ await message.answer("Часть ID или username неверна либо не найдена. Исправьте список и повторите ввод. Изменения не внесены.")
+ return
results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id)
@@ -1501,7 +1517,7 @@ async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext)
await state.set_state(AdminStates.add_to_lottery_user)
-@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text)
+@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text, NotCommand())
async def process_add_to_lottery(message: Message, state: FSMContext):
"""Обработка добавления участника в конкретный розыгрыш"""
if not await is_admin(message.from_user.id):
@@ -1637,7 +1653,7 @@ async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMCon
await state.set_state(AdminStates.remove_from_lottery_user)
-@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text)
+@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text, NotCommand())
async def process_remove_from_lottery(message: Message, state: FSMContext):
"""Обработка удаления участника из конкретного розыгрыша"""
if not await is_admin(message.from_user.id):
@@ -2002,7 +2018,7 @@ async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.add_participant_bulk_accounts)
-@admin_router.message(StateFilter(AdminStates.add_participant_bulk_accounts))
+@admin_router.message(StateFilter(AdminStates.add_participant_bulk_accounts), F.text, NotCommand())
async def process_bulk_add_accounts(message: Message, state: FSMContext):
"""Обработка массового добавления участников по номерам счетов"""
if not await check_admin_access(message.from_user.id):
@@ -2015,6 +2031,9 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext):
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text)
+ if not account_inputs:
+ await message.answer("Счета не распознаны. Используйте формат 11-22-33-44-55-66-77 или /cancel.")
+ return
async with async_session_maker() as session:
# Массовое добавление по номерам счетов
@@ -2127,7 +2146,7 @@ async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext
await state.set_state(AdminStates.remove_participant_bulk_accounts)
-@admin_router.message(StateFilter(AdminStates.remove_participant_bulk_accounts))
+@admin_router.message(StateFilter(AdminStates.remove_participant_bulk_accounts), F.text, NotCommand())
async def process_bulk_remove_accounts(message: Message, state: FSMContext):
"""Обработка массового удаления участников по номерам счетов"""
if not await check_admin_access(message.from_user.id):
@@ -2140,6 +2159,9 @@ async def process_bulk_remove_accounts(message: Message, state: FSMContext):
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text)
+ if not account_inputs:
+ await message.answer("Счета не распознаны. Используйте формат 11-22-33-44-55-66-77 или /cancel.")
+ return
async with async_session_maker() as session:
# Массовое удаление по номерам счетов
@@ -2408,13 +2430,23 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(edit_lottery_id=lottery_id)
+ await state.set_state(None)
+ await callback.answer()
+ await show_edit_fields(callback.message, lottery_id, edit=True)
+
+
+async def show_edit_fields(message: Message, lottery_id: int, edit=False):
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
+ if lottery is None:
+ await message.answer("Розыгрыш не найден. Откройте /admin.")
+ return
+
text = f"📝 Редактирование: {lottery.title}\n\n"
text += "Выберите, что хотите изменить:\n\n"
text += f"📝 Название: {lottery.title}\n"
- text += f"📄 Описание: {lottery.description[:50]}{'...' if len(lottery.description) > 50 else ''}\n"
+ text += f"📄 Описание: {(lottery.description or "")[:50]}{'...' if len(lottery.description or "") > 50 else ''}\n"
text += f"🎁 Призы: {len(getattr(lottery, 'prizes', []))} шт.\n"
text += f"🎭 Отображение: {getattr(lottery, 'winner_display_type', 'username')}\n"
text += f"🟢 Активен: {'Да' if getattr(lottery, 'is_active', True) else 'Нет'}"
@@ -2431,7 +2463,7 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_edit_lottery")]
]
- await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
+ await (message.edit_text if edit else message.answer)(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_toggle_active_"))
@@ -2776,7 +2808,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.set_winner_place)
-@admin_router.message(StateFilter(AdminStates.set_winner_place))
+@admin_router.message(StateFilter(AdminStates.set_winner_place), F.text, NotCommand())
async def process_winner_place(message: Message, state: FSMContext):
"""Обработка места победителя"""
if not await check_admin_access(message.from_user.id):
@@ -2784,7 +2816,7 @@ async def process_winner_place(message: Message, state: FSMContext):
return
try:
- place = int(message.text)
+ place = numeric_id(message.text, maximum=2**31 - 1)
if place < 1:
raise ValueError
except ValueError:
@@ -2798,6 +2830,9 @@ async def process_winner_place(message: Message, state: FSMContext):
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
+ if not lottery or place > len(lottery.prizes or []):
+ await message.answer("Номер места должен соответствовать одному из призов розыгрыша.")
+ return
if lottery.manual_winners and str(place) in lottery.manual_winners:
existing_id = lottery.manual_winners[str(place)]
existing_user = await UserService.get_user_by_telegram_id(session, existing_id)
@@ -2824,7 +2859,7 @@ async def process_winner_place(message: Message, state: FSMContext):
await state.set_state(AdminStates.set_winner_user)
-@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text)
+@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text, NotCommand())
async def process_winner_user(message: Message, state: FSMContext):
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
if not await check_admin_access(message.from_user.id):
@@ -4397,7 +4432,7 @@ async def admin_import_users_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.import_users_json)
-@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
+@admin_router.message(StateFilter(AdminStates.import_users_json), F.document, NotCommand())
async def admin_import_users_process(message: Message, state: FSMContext):
if not await check_admin_access(message.from_user.id):
return
@@ -4644,7 +4679,7 @@ BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть со
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
-@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker)
+@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker, NotCommand())
async def admin_broadcast_send(message: Message, state: FSMContext):
"""Обработка и отправка рассылки"""
if not await check_admin_access(message.from_user.id):
@@ -4876,14 +4911,14 @@ async def admin_broadcast_add_channel_start(callback: CallbackQuery, state: FSMC
await state.set_state(AdminStates.broadcast_add_channel_id)
-@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_id), F.text)
+@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_id), F.text, NotCommand())
async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
"""Обработка ID канала"""
if not await check_admin_access(message.from_user.id):
return
try:
- chat_id = int(message.text.strip())
+ chat_id = numeric_id(message.text, signed=True)
except ValueError:
await message.answer(
"❌ Неверный формат ID. Отправьте число, например: -1001234567890"
@@ -4903,7 +4938,6 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
await message.answer(
"❌ Неверный тип чата. Поддерживаются только каналы и группы."
)
- await state.clear()
return
# Сохраняем данные
@@ -4917,7 +4951,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
# Запрашиваем описание
text = (
f"✅ Канал найден!\n\n"
- f"📱 Название: {chat.title}\n"
+ f"📱 Название: {escape(chat.title or "")}\n"
f"🆔 ID: {chat_id}\n"
f"📝 Тип: {'Канал' if chat_type == 'channel' else 'Группа'}\n"
)
@@ -4939,10 +4973,9 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
f"Детали: {public_error(e)}",
parse_mode="HTML"
)
- await state.clear()
-@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_title), F.text)
+@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_title), F.text, NotCommand("skip"))
async def admin_broadcast_add_channel_description(message: Message, state: FSMContext):
"""Обработка описания канала"""
if not await check_admin_access(message.from_user.id):
@@ -4950,7 +4983,7 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
data = await state.get_data()
- description = None if message.text.strip() == '/skip' else message.text.strip()
+ description = None if message.text.strip().split('@', 1)[0].lower() == '/skip' else message.text.strip()
# Сохраняем в БД
async with async_session_maker() as session:
@@ -4977,10 +5010,11 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
existing.description = description
existing.chat_type = data['chat_type']
await session.commit()
+ await state.clear()
await message.answer(
"✅ Канал обновлен!\n\n"
- f"📱 {data['title']}",
+ f"📱 {escape(data['title'])}",
parse_mode="HTML"
)
else:
@@ -4995,10 +5029,11 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
)
session.add(channel)
await session.commit()
+ await state.clear()
await message.answer(
"✅ Канал добавлен!\n\n"
- f"📱 {data['title']}\n"
+ f"📱 {escape(data['title'])}\n"
f"Теперь вы можете использовать его для рассылок.",
parse_mode="HTML"
)
@@ -5209,13 +5244,16 @@ async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.user_management_search)
-@admin_router.message(AdminStates.user_management_search, F.text)
+@admin_router.message(AdminStates.user_management_search, F.text, NotCommand())
async def admin_users_search_process(message: Message, state: FSMContext):
"""Обработка поискового запроса"""
if not await check_admin_access(message.from_user.id):
return
query = message.text.strip()
+ if not 1 <= len(query) <= 100:
+ await message.answer("Поисковый запрос должен содержать от 1 до 100 символов. /cancel — отмена.")
+ return
if query == "/cancel":
await message.answer("❌ Поиск отменен")
@@ -5233,12 +5271,12 @@ async def admin_users_search_process(message: Message, state: FSMContext):
)
if not users:
- text = f"❌ По запросу «{query}» ничего не найдено"
+ text = f"❌ По запросу «{escape(query)}» ничего не найдено"
buttons = [
[InlineKeyboardButton(text="◀️ В управление пользователями", callback_data="admin_users")]
]
else:
- text = f"🔍 Результаты поиска: «{query}»\n"
+ text = f"🔍 Результаты поиска: «{escape(query)}»\n"
text += f"Найдено: {total} пользователей\n\n"
buttons = []
@@ -5262,7 +5300,7 @@ async def admin_users_search_process(message: Message, state: FSMContext):
if total > 15:
nav_buttons.append(InlineKeyboardButton(
text="➡️ Далее",
- callback_data=f"admin_users_search_page:{query}:2"
+ callback_data=f"admin_users_search_page:{escape(query)}:2"
))
buttons.append(nav_buttons)
diff --git a/src/handlers/cashier_handlers.py b/src/handlers/cashier_handlers.py
index 9a7b32b..68d6015 100644
--- a/src/handlers/cashier_handlers.py
+++ b/src/handlers/cashier_handlers.py
@@ -1,6 +1,7 @@
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
+from src.filters.dialog_input import NotCommand
from aiogram import F, Router
-from aiogram.filters import Command
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
@@ -10,7 +11,7 @@ from src.core.database import async_session_maker
from src.core.services import LotteryService
from src.handlers.account_services import AccountParticipationService
from src.middlewares.access import AccessMiddleware
-from src.utils.account_utils import parse_accounts_from_message
+from src.utils.account_input import parse_account_records
cashier_router = Router(name="cashier")
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
@@ -58,9 +59,12 @@ async def choose_draw(callback: CallbackQuery, state: FSMContext):
await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.")
-@cashier_router.message(CashierStates.accounts, F.text)
+@cashier_router.message(CashierStates.accounts, F.text, NotCommand())
async def add_accounts(message: Message, state: FSMContext):
- accounts = parse_accounts_from_message(message.text)
+ accounts, errors = parse_account_records(message.text)
+ if errors:
+ await message.answer("Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
+ return
if not accounts or len(accounts) > 1000:
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
return
diff --git a/src/handlers/chat_handlers.py b/src/handlers/chat_handlers.py
index 8f7cdae..552e832 100644
--- a/src/handlers/chat_handlers.py
+++ b/src/handlers/chat_handlers.py
@@ -1,4 +1,5 @@
"""Обработчики пользовательских сообщений в чате"""
+from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error
from src.core.access import is_admin
from src.utils.delivery import background_delivery
@@ -90,7 +91,7 @@ async def enter_chat(message: Message, state: FSMContext):
)
-@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat))
+@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat), NotCommand())
async def exit_chat_command(message: Message, state: FSMContext):
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
await exit_chat(message, state)
@@ -140,7 +141,7 @@ async def exit_chat(message: Message, state: FSMContext, actor=None):
)
-@router.message(StateFilter(ChatStates.in_chat), F.text)
+@router.message(StateFilter(ChatStates.in_chat), F.text, NotCommand())
async def check_exit_keywords(message: Message, state: FSMContext):
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
import logging
@@ -156,15 +157,8 @@ async def check_exit_keywords(message: Message, state: FSMContext):
# Выходим из чата и показываем главное меню
await state.clear()
- from src.components.ui import UserUI
- keyboard = UserUI.get_main_menu_keyboard(message.from_user.id)
-
- await message.answer(
- "🏠 Главное меню\n\n"
- "Вы вышли из режима чата.",
- reply_markup=keyboard,
- parse_mode="HTML"
- )
+ from main import cmd_start
+ await cmd_start(message, state)
return # Не обрабатываем дальше
else:
# Для /exit просто выходим
@@ -544,7 +538,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
-@router.message(F.photo, StateFilter(ChatStates.in_chat))
+@router.message(F.photo, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_photo_message(message: Message, state: FSMContext):
"""Обработчик фото"""
# Защита от дубликатов
@@ -614,7 +608,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
await message.answer("✅ Фото переслано в канал")
-@router.message(F.video, StateFilter(ChatStates.in_chat))
+@router.message(F.video, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_video_message(message: Message, state: FSMContext):
"""Обработчик видео"""
# Защита от дубликатов
@@ -681,7 +675,7 @@ async def handle_video_message(message: Message, state: FSMContext):
await message.answer("✅ Видео переслано в канал")
-@router.message(F.document, StateFilter(ChatStates.in_chat))
+@router.message(F.document, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_document_message(message: Message, state: FSMContext):
"""Обработчик документов"""
# Защита от дубликатов
@@ -748,7 +742,7 @@ async def handle_document_message(message: Message, state: FSMContext):
await message.answer("✅ Документ переслан в канал")
-@router.message(F.animation, StateFilter(ChatStates.in_chat))
+@router.message(F.animation, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_animation_message(message: Message, state: FSMContext):
"""Обработчик GIF анимаций"""
# Защита от дубликатов
@@ -815,7 +809,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
await message.answer("✅ Анимация переслана в канал")
-@router.message(F.sticker, StateFilter(ChatStates.in_chat))
+@router.message(F.sticker, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_sticker_message(message: Message, state: FSMContext):
"""Обработчик стикеров"""
# Защита от дубликатов
diff --git a/src/handlers/help_handlers.py b/src/handlers/help_handlers.py
index 44fc1aa..29bc199 100644
--- a/src/handlers/help_handlers.py
+++ b/src/handlers/help_handlers.py
@@ -2,7 +2,7 @@
from src.core.access import is_admin
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
-from aiogram.filters import Command
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from src.core.config import ADMIN_IDS
from src.filters.case_insensitive import CaseInsensitiveCommand
diff --git a/src/handlers/input_fallback.py b/src/handlers/input_fallback.py
new file mode 100644
index 0000000..e86e0cb
--- /dev/null
+++ b/src/handlers/input_fallback.py
@@ -0,0 +1,19 @@
+"""Explain unmatched input instead of silently dropping it or broadcasting it."""
+from aiogram import F, Router
+from aiogram.fsm.context import FSMContext
+from aiogram.types import Message
+
+input_fallback_router = Router(name="input_fallback")
+
+
+@input_fallback_router.message(F.text.lstrip().startswith("/"))
+async def unknown_command(message: Message):
+ await message.answer("Команда не распознана или недоступна на этом шаге. /help — справка, /cancel — отменить текущий ввод.")
+
+
+@input_fallback_router.message()
+async def unmatched_input(message: Message, state: FSMContext):
+ if await state.get_state():
+ await message.answer("Этот ввод не подходит для текущего шага. Отправьте данные в формате из подсказки или используйте предложенные кнопки. /cancel — отмена.")
+ else:
+ await message.answer("Сейчас бот не ожидает эти данные. /start — главное меню, /register — регистрация. Для переписки сначала откройте чат.")
diff --git a/src/handlers/message_management.py b/src/handlers/message_management.py
index aae31a4..80afee3 100644
--- a/src/handlers/message_management.py
+++ b/src/handlers/message_management.py
@@ -6,7 +6,8 @@ from src.core.access import is_admin
import logging
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery
-from aiogram.filters import Command
+from aiogram.filters import StateFilter
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from src.filters.case_insensitive import CaseInsensitiveCommand
from ..core.config import ADMIN_IDS
@@ -69,17 +70,17 @@ async def delete_message_callback(callback: CallbackQuery):
# Функция-фильтр для проверки триггерных слов
-def is_delete_trigger(message: Message) -> bool:
+async def is_delete_trigger(message: Message) -> bool:
"""Проверяет, является ли сообщение триггером для удаления"""
if not message.text:
return False
text_lower = message.text.lower().strip()
triggers = ["удалить", "delete", "del", "🗑️", "🗑", "❌"]
- return any(trigger in text_lower for trigger in triggers)
+ return text_lower in triggers and await is_admin(message.from_user.id)
-@message_admin_router.message(F.reply_to_message, is_delete_trigger)
+@message_admin_router.message(F.reply_to_message, is_delete_trigger, StateFilter(None, "ChatStates:in_chat", "P2PChatStates:chatting"))
async def quick_delete_replied_message(message: Message):
"""
Быстрое удаление сообщения по reply с триггерными словами или emoji
diff --git a/src/handlers/operator_help.py b/src/handlers/operator_help.py
index 7bfb122..8f0a4c2 100644
--- a/src/handlers/operator_help.py
+++ b/src/handlers/operator_help.py
@@ -2,7 +2,7 @@
from pathlib import Path
from aiogram import F, Router
-from aiogram.filters import Command
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.types import CallbackQuery, FSInputFile, Message
from src.core.access import get_role
@@ -34,7 +34,7 @@ async def send_guide(event, kind):
@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
async def help_command(message: Message):
- kind = message.text.split()[0].split("@")[0].removeprefix("/").removesuffix("_help")
+ kind = message.text.split()[0].split("@")[0].lower().removeprefix("/").removesuffix("_help")
await send_guide(message, kind)
diff --git a/src/handlers/p2p_chat.py b/src/handlers/p2p_chat.py
index c977da5..6a45cf2 100644
--- a/src/handlers/p2p_chat.py
+++ b/src/handlers/p2p_chat.py
@@ -1,10 +1,12 @@
"""Обработчики P2P чата между пользователями"""
+from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error
from src.utils.telegram_messages import copy_preserving_entities
from html import escape
from src.core.access import is_admin
from aiogram import Router, F
-from aiogram.filters import Command, StateFilter
+from aiogram.filters import StateFilter
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from src.filters.case_insensitive import CaseInsensitiveCommand
@@ -356,7 +358,7 @@ async def back_to_menu(callback: CallbackQuery, state: FSMContext):
# Обработчик сообщений в состоянии chatting
-@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
+@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document, NotCommand())
async def handle_p2p_message(message: Message, state: FSMContext):
"""Обработка P2P сообщения от пользователя"""
import logging
diff --git a/src/handlers/redraw_handlers.py b/src/handlers/redraw_handlers.py
index cabb45b..8cb4275 100644
--- a/src/handlers/redraw_handlers.py
+++ b/src/handlers/redraw_handlers.py
@@ -1,8 +1,9 @@
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
+from src.utils.input_validation import numeric_id
from src.utils.errors import public_error
from aiogram import Router, F
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
-from aiogram.filters import Command
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from sqlalchemy import select, and_
from datetime import datetime, timezone, timedelta
import random
@@ -36,7 +37,7 @@ async def check_unclaimed_winners(message: Message):
return
try:
- lottery_id = int(parts[1])
+ lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
@@ -132,7 +133,11 @@ async def redraw_lottery(message: Message):
if len(parts) != 2 or not parts[1].isdigit():
await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
return
- lottery_id = int(parts[1])
+ try:
+ lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
+ except ValueError as error:
+ await message.answer(str(error))
+ return
async with async_session_maker() as session:
winners = await redraw_unclaimed(session, lottery_id)
if winners:
diff --git a/src/handlers/registration_handlers.py b/src/handlers/registration_handlers.py
index e8f92fb..23e666c 100644
--- a/src/handlers/registration_handlers.py
+++ b/src/handlers/registration_handlers.py
@@ -1,8 +1,11 @@
"""Обработчики для регистрации пользователей"""
+from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error
+from src.utils.input_validation import club_card, phone_number
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
-from aiogram.filters import Command, StateFilter
+from aiogram.filters import StateFilter
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -92,7 +95,7 @@ async def start_registration(callback: CallbackQuery, state: FSMContext):
-@router.message(StateFilter(RegistrationStates.waiting_for_nickname), F.text)
+@router.message(StateFilter(RegistrationStates.waiting_for_nickname), F.text, NotCommand())
async def process_nickname(message: Message, state: FSMContext):
"""Обработка никнейма"""
nickname = message.text.strip()
@@ -110,62 +113,59 @@ async def process_nickname(message: Message, state: FSMContext):
# Сохраняем никнейм
await state.update_data(nickname=nickname)
+ await state.set_state(RegistrationStates.waiting_for_club_card)
await message.answer(
f"✅ Отлично! Ваш никнейм: {nickname}\n\n"
"Шаг 2 из 3: Клубная карта\n\n"
"📝 Введите номер вашей клубной карты:"
)
- await state.set_state(RegistrationStates.waiting_for_club_card)
-@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text)
+@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text, NotCommand())
async def process_club_card(message: Message, state: FSMContext):
"""Обработка номера клубной карты"""
- club_card_number = message.text.strip()
+ try:
+ club_card_number = club_card(message.text)
+ except ValueError as error:
+ await message.answer(f"❌ {error}\nПопробуйте ещё раз.")
+ return
# Проверяем, не занята ли карта
async with async_session_maker() as session:
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
- if existing_user:
+ if existing_user and existing_user.telegram_id != message.from_user.id:
await message.answer(
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
"Если это ваша карта, обратитесь к администратору."
)
- await state.clear()
return
await state.update_data(club_card_number=club_card_number)
+ await state.set_state(RegistrationStates.waiting_for_phone)
await message.answer(
"Шаг 3 из 3: Телефон\n\n"
"📱 Введите ваш номер телефона\n"
"(или отправьте '-' чтобы пропустить):"
)
- await state.set_state(RegistrationStates.waiting_for_phone)
-@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text)
+@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text, NotCommand())
async def process_phone(message: Message, state: FSMContext):
"""Обработка номера телефона"""
- phone_input = message.text.strip()
-
- # Проверяем, не отправил ли пользователь просто "-"
- if phone_input == "-":
- phone = None
- else:
- # Валидируем телефон: не должно быть пустых или некорректных значений
- if not phone_input:
- await message.answer(
- "❌ Неверный номер телефона.\n\n"
- "Пожалуйста, введите корректный номер или отправьте '-' чтобы пропустить."
- )
- return
- phone = phone_input
+ try:
+ phone = phone_number(message.text)
+ except ValueError as error:
+ await message.answer(f"❌ {error}")
+ return
data = await state.get_data()
- club_card_number = data['club_card_number']
+ club_card_number = data.get('club_card_number')
nickname = data.get('nickname')
+ if not club_card_number or not nickname:
+ await begin_registration(message, state)
+ return
try:
async with async_session_maker() as session:
@@ -173,16 +173,22 @@ async def process_phone(message: Message, state: FSMContext):
session,
telegram_id=message.from_user.id,
club_card_number=club_card_number,
- phone=phone
+ phone=phone,
+ nickname=nickname,
)
-
- # Обновляем никнейм пользователя
- if nickname:
- user.nickname = nickname
- await session.commit()
- await session.refresh(user)
-
- text = (
+ except ValueError as error:
+ # The card may have been claimed after step 2, while this user entered a phone.
+ await state.set_state(RegistrationStates.waiting_for_club_card)
+ await message.answer(f"❌ {error}\nВведите номер клубной карты ещё раз или /cancel.")
+ return
+ except Exception as error:
+ logger.exception("Registration persistence failed")
+ await message.answer(f"❌ {public_error(error)}\nПовторите ввод телефона или /cancel.")
+ return
+
+ # Registration is committed even if Telegram cannot deliver the acknowledgement.
+ await state.clear()
+ text = (
"✅ Регистрация завершена!\n\n"
f"🎭 Никнейм: {user.nickname}\n"
f"🎫 Клубная карта: {user.club_card_number}\n"
@@ -191,15 +197,7 @@ async def process_phone(message: Message, state: FSMContext):
"Теперь вы можете участвовать в розыгрышах!"
)
- await message.answer(text, parse_mode="HTML")
- await state.clear()
-
- except ValueError as e:
- await message.answer(f"❌ Ошибка регистрации: {str(e)}")
- await state.clear()
- except Exception as e:
- await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
- await state.clear()
+ await message.answer(text, parse_mode="HTML")
@router.message(Command("my_code"))
diff --git a/src/handlers/staff_handlers.py b/src/handlers/staff_handlers.py
index 347ccd6..12f99c0 100644
--- a/src/handlers/staff_handlers.py
+++ b/src/handlers/staff_handlers.py
@@ -1,9 +1,10 @@
"""One staff-management dialog, restricted at every step to system admins."""
+from src.filters.dialog_input import NotCommand
from html import escape
import secrets
from aiogram import BaseMiddleware, F, Router
-from aiogram.filters import Command
+from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
@@ -124,7 +125,7 @@ async def staff_command(message: Message, state: FSMContext):
"user" if removal else role, role if removal else None)
-@staff_router.message(StaffStates.target, F.text)
+@staff_router.message(StaffStates.target, F.text, NotCommand())
async def staff_target(message: Message, state: FSMContext):
try:
target_id = parse_target(message.text.strip())
@@ -154,7 +155,7 @@ async def confirm_role(callback: CallbackQuery, state: FSMContext):
"Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
-@staff_router.message(StaffStates.confirm)
+@staff_router.message(StaffStates.confirm, NotCommand())
async def confirmation_pending(message: Message):
await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")
diff --git a/src/utils/account_input.py b/src/utils/account_input.py
new file mode 100644
index 0000000..1d4b2e8
--- /dev/null
+++ b/src/utils/account_input.py
@@ -0,0 +1,54 @@
+"""Parse each account record independently, preserving card/account associations."""
+import re
+
+ACCOUNT = re.compile(r"(? str:
def parse_accounts_from_message(text: str) -> List[str]:
- """
- Извлекает все валидные номера счетов из текста сообщения.
- Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
- или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
-
- Также обрабатывает многострочный текст из кабинета:
- Запись начинается со слова "Viposnova" и содержит несколько строк до следующего "Viposnova":
- "Viposnova 16-11-2025 22:19:36
- 17-24-66-42-38-31-53
- 0.00 2918"
-
- Args:
- text: Текст сообщения
-
- Returns:
- List[str]: Список найденных строк (может включать номер карты и счета через пробел)
- """
- if not text:
- return []
-
- accounts = []
-
- # Группируем строки по записям (от "Viposnova" до следующего "Viposnova")
- lines = text.strip().split('\n')
- current_record = []
- records = []
-
- for line in lines:
- stripped = line.strip()
- # Если строка начинается с Viposnova и у нас уже есть текущая запись - сохраняем её
- if stripped.startswith('Viposnova') and current_record:
- records.append(' '.join(current_record))
- current_record = [stripped]
- else:
- current_record.append(stripped)
-
- # Добавляем последнюю запись
- if current_record:
- records.append(' '.join(current_record))
-
- # Обрабатываем каждую запись
- for record in records:
- parts = record.split()
-
- # Ищем счет в записи
- account_number = None
- account_idx = None
- for i, part in enumerate(parts):
- if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
- account_number = format_account_number(part)
- account_idx = i
- break
-
- if not account_number or account_number in accounts:
- continue
-
- # Ищем клубную карту (4-значное число после счета)
- card = None
- if account_idx is not None:
- for j in range(account_idx + 1, len(parts)):
- if re.match(r'^\d{4}$', parts[j]):
- card = parts[j]
- break
-
- # Добавляем результат
- if card:
- full_account = f"{card} {account_number}"
- if full_account not in accounts:
- accounts.append(full_account)
- else:
- accounts.append(account_number)
-
- # Если построчная обработка ничего не нашла, используем старый метод
- if not accounts:
- # Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
- pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
-
- # Находим все совпадения с картой и удаляем их из текста
- text_copy = text
- for match in re.finditer(pattern_with_card, text):
- card = match.group(1)
- account = match.group(2)
- formatted = format_account_number(account)
- if formatted:
- full_account = f"{card} {formatted}"
- if full_account not in accounts:
- accounts.append(full_account)
- # Удаляем это совпадение из копии текста, чтобы не найти повторно
- text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
-
- # Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
- pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
- matches_only = re.findall(pattern_only_account, text_copy)
-
- for match in matches_only:
- formatted = format_account_number(match)
- if formatted and formatted not in accounts:
- # Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
- is_duplicate = any(formatted in acc for acc in accounts)
- if not is_duplicate:
- accounts.append(formatted)
-
- return accounts
+ """Return all recognized records, preserving card numbers and leading zeroes."""
+ from .account_input import parse_account_records
+ return parse_account_records(text or "")[0]
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:
diff --git a/src/utils/input_validation.py b/src/utils/input_validation.py
new file mode 100644
index 0000000..278b2c9
--- /dev/null
+++ b/src/utils/input_validation.py
@@ -0,0 +1,47 @@
+"""Validation shared by dialog steps and database services."""
+import re
+
+
+def club_card(value):
+ value = value.strip() if isinstance(value, str) else ""
+ if not re.fullmatch(r"[0-9]{1,50}", value):
+ raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр без букв и разделителей.")
+ return value # Leading zeroes are part of the card number.
+
+
+def phone_number(value):
+ if value is None or value.strip() == "-":
+ return None
+ value = value.strip()
+ if not value or len(value) > 20 or not re.fullmatch(r"\+?[0-9() -]+", value) or not re.search(r"[0-9]", value):
+ raise ValueError("Введите телефон: цифры, при необходимости +, пробелы, скобки или дефисы; не более 20 символов. Для пропуска отправьте '-'.")
+ return value
+
+
+def numeric_id(value, *, maximum=2**63 - 1, signed=False):
+ value = str(value).strip()
+ pattern = r"-?[0-9]{1,19}" if signed else r"[0-9]{1,19}"
+ if not re.fullmatch(pattern, value) or not 0 < abs(int(value)) <= maximum:
+ raise ValueError("Некорректный ID: требуется число в допустимом диапазоне.")
+ return int(value)
+
+
+def lottery_field(field, value):
+ if field == "title":
+ if not isinstance(value, str) or not 1 <= len(value.strip()) <= 500:
+ raise ValueError("Название должно содержать от 1 до 500 символов.")
+ return value.strip()
+ if field == "description":
+ if value is None or value == "" or value.strip() == "-":
+ return None
+ if not value.strip() or len(value) > 4096:
+ raise ValueError("Введите описание до 4096 символов или '-' для пропуска.")
+ return value.strip()
+ if field == "prizes":
+ prizes = [line.strip() for line in value.splitlines() if line.strip()] if isinstance(value, str) else value
+ if not isinstance(prizes, list) or not 1 <= len(prizes) <= 100 or any(
+ not isinstance(prize, str) or not 1 <= len(prize.strip()) <= 500 for prize in prizes
+ ):
+ raise ValueError("Введите от 1 до 100 призов, каждый с новой строки и не длиннее 500 символов.")
+ return [prize.strip() for prize in prizes]
+ raise ValueError("Неизвестное поле розыгрыша.")
diff --git a/src/utils/text_output.py b/src/utils/text_output.py
new file mode 100644
index 0000000..cb013b5
--- /dev/null
+++ b/src/utils/text_output.py
@@ -0,0 +1,12 @@
+"""Long plain-text form previews keep their action buttons on the last page."""
+
+
+async def answer_plain_pages(message, text, **kwargs):
+ # A Python character occupies at most two Telegram UTF-16 units.
+ # Plain text needs neither entity splitting nor HTML tag repair.
+ while len(text) > 2000:
+ end = text.rfind("\n", 0, 2001)
+ end = end + 1 if end > 0 else 2000
+ await message.answer(text[:end], parse_mode=None)
+ text = text[end:]
+ return await message.answer(text, parse_mode=None, **kwargs)
diff --git a/tests/test_text_inputs.py b/tests/test_text_inputs.py
new file mode 100644
index 0000000..079bbfa
--- /dev/null
+++ b/tests/test_text_inputs.py
@@ -0,0 +1,280 @@
+import asyncio
+import json
+from datetime import datetime, timezone
+
+from aiogram import Bot
+from aiogram.exceptions import TelegramNetworkError
+from aiogram.types import Chat, Message, PhotoSize, Update, User as TelegramUser
+import pytest
+from sqlalchemy import select
+
+from src.core.database import async_session_maker
+from src.core.models import Account, Lottery, User
+from src.core.services import LotteryService, UserService
+from src.handlers.registration_handlers import RegistrationStates
+from src.utils.account_input import parse_account_records
+from src.utils.telegram_messages import utf16_length
+from test_dispatcher import TelegramStub, dispatch, event
+
+
+def context(actor):
+ import main
+ return main.dp.fsm.get_context(bot=Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS"), chat_id=actor, user_id=actor)
+
+
+async def start_registration(actor=950):
+ await dispatch(event(actor, text="/register"))
+ await dispatch(event(actor, text="Tester"))
+ return context(actor)
+
+
+@pytest.mark.parametrize("card", ["123", "1234", "0007", " 0123 ", "1" * 50])
+async def test_card_length_and_leading_zeroes_survive_complete_registration(card):
+ state = await start_registration()
+ await dispatch(event(950, text=card))
+ assert await state.get_state() == RegistrationStates.waiting_for_phone.state
+ await dispatch(event(950, text="-"))
+ assert await state.get_state() is None
+ async with async_session_maker() as session:
+ user = await UserService.get_user_by_telegram_id(session, 950)
+ assert user.is_registered and user.club_card_number == card.strip()
+ assert user.nickname == "Tester" and user.verification_code
+
+
+async def test_duplicate_and_invalid_cards_can_be_corrected_without_restarting():
+ async with async_session_maker() as session:
+ user = await UserService.get_or_create_user(session, 951)
+ user.club_card_number = "1234"
+ await session.commit()
+ state = await start_registration()
+ for bad in ["1234", "12x4", "1" * 51, " ", "1234"]:
+ calls = await dispatch(event(950, text=bad))
+ assert any(getattr(call, "text", None) for call in calls)
+ assert await state.get_state() == RegistrationStates.waiting_for_club_card.state
+ assert (await state.get_data())["nickname"] == "Tester"
+ await dispatch(event(950, text="0008"))
+ assert await state.get_state() == RegistrationStates.waiting_for_phone.state
+
+
+async def test_client_can_finish_registration_with_their_own_preassigned_four_digit_card():
+ async with async_session_maker() as session:
+ user = await UserService.get_or_create_user(session, 950)
+ user.club_card_number = "1234"
+ user.is_registered = False
+ await session.commit()
+ await start_registration()
+ await dispatch(event(950, text="1234"))
+ assert await context(950).get_state() == RegistrationStates.waiting_for_phone.state
+ await dispatch(event(950, text="-"))
+ async with async_session_maker() as session:
+ user = await UserService.get_user_by_telegram_id(session, 950)
+ assert user.club_card_number == "1234" and user.is_registered
+
+
+async def test_invalid_phone_keeps_card_and_nickname_until_corrected():
+ state = await start_registration()
+ await dispatch(event(950, text="1234"))
+ for bad in [" ", "1" * 21, ""]:
+ await dispatch(event(950, text=bad))
+ assert await state.get_state() == RegistrationStates.waiting_for_phone.state
+ assert (await state.get_data())["club_card_number"] == "1234"
+ await dispatch(event(950, text="+82 10-1234-5678"))
+ async with async_session_maker() as session:
+ user = await UserService.get_user_by_telegram_id(session, 950)
+ assert user.is_registered and user.phone == "+82 10-1234-5678"
+
+
+async def test_failed_success_reply_does_not_repeat_registration_or_lose_saved_nickname():
+ import main
+ state = await start_registration()
+ await dispatch(event(950, text="1234"))
+
+ class FailingReply(TelegramStub):
+ async def make_request(self, bot, method, timeout=None):
+ if (getattr(method, "text", "") or "").startswith("✅ Регистрация завершена"):
+ raise TelegramNetworkError(method=method, message="synthetic timeout")
+ return await super().make_request(bot, method, timeout)
+
+ bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=FailingReply())
+ await main.dp.feed_update(bot, event(950, text="-"))
+ assert await state.get_state() is None
+ async with async_session_maker() as session:
+ user = await UserService.get_user_by_telegram_id(session, 950)
+ assert user.is_registered and user.nickname == "Tester"
+
+
+async def test_registration_keyword_and_delete_reply_do_not_steal_form_input():
+ state = await start_registration()
+ await state.set_state(RegistrationStates.waiting_for_nickname)
+ await dispatch(event(950, text="регистрация"))
+ assert await state.get_state() == RegistrationStates.waiting_for_club_card.state
+ await state.set_state(RegistrationStates.waiting_for_nickname)
+ update = event(950, text="Adele")
+ update = update.model_copy(update={"message": update.message.model_copy(update={"reply_to_message": event(950).message})})
+ await dispatch(update)
+ assert await state.get_state() == RegistrationStates.waiting_for_club_card.state
+ assert (await state.get_data())["nickname"] == "Adele"
+
+
+async def test_all_text_forms_route_commands_and_reject_non_text_without_losing_data(caplog):
+ from src.handlers.admin_panel import AdminStates
+ from src.handlers.admin_emoji_handlers import EmojiStates
+ from src.handlers.admin_account_handlers import AddAccountStates
+ from src.handlers.cashier_handlers import CashierStates
+ from src.handlers.staff_handlers import StaffStates
+ forms = [*RegistrationStates.__all_states__, *EmojiStates.__all_states__, StaffStates.target,
+ StaffStates.confirm, AddAccountStates.waiting_for_data, CashierStates.accounts]
+ fields = ["lottery_title", "lottery_description", "lottery_prizes", "add_participant_user",
+ "remove_participant_user", "add_participant_bulk", "remove_participant_bulk",
+ "add_participant_bulk_accounts", "remove_participant_bulk_accounts", "add_to_lottery_user",
+ "remove_from_lottery_user", "set_winner_place", "set_winner_user", "participant_search",
+ "broadcast_add_channel_id", "broadcast_add_channel_title", "user_management_search"]
+ forms += [getattr(AdminStates, name) for name in fields]
+ await dispatch(event(900001, text="/cancel"))
+ state = context(900001)
+ for form in forms:
+ await state.set_state(form)
+ await state.set_data({"sentinel": "keep me"})
+ for command in ("/MY_ACCOUNTS", "/unknown_test_command"):
+ calls = await dispatch(event(900001, text=command))
+ assert any(getattr(call, "text", None) for call in calls), (form, command)
+ assert await state.get_state() == form.state, (form, command)
+ assert await state.get_data() == {"sentinel": "keep me"}, (form, command)
+ photo = event(900001, text="placeholder")
+ photo = photo.model_copy(update={"message": photo.message.model_copy(update={"text": None, "photo": [PhotoSize(
+ file_id="test", file_unique_id="test", width=1, height=1)]})})
+ calls = await dispatch(photo)
+ assert any(getattr(call, "text", None) for call in calls), form
+ assert await state.get_state() == form.state, form
+ await state.clear()
+ assert not any(record.levelname in {"ERROR", "CRITICAL"} for record in caplog.records)
+
+
+async def test_idle_card_input_gets_recovery_hint_instead_of_silence():
+ await dispatch(event(950, text="/cancel"))
+ calls = await dispatch(event(950, text="1234"))
+ assert any("не ожидает" in (getattr(call, "text", "") or "") for call in calls)
+
+
+async def create_draw():
+ async with async_session_maker() as session:
+ admin = await UserService.get_or_create_user(session, 900001)
+ return await LotteryService.create_lottery(session, "Original", None, ["Prize"], admin.id)
+
+
+async def test_edit_without_description_and_cancel_then_create_keep_draws_separate(caplog):
+ from src.handlers.admin_panel import AdminStates
+ lottery = await create_draw()
+ await dispatch(event(900001, callback_data=f"admin_edit_field_{lottery.id}_title"))
+ calls = await dispatch(event(900001, text="Edited"))
+ assert not any(call.__api_method__ == "answerCallbackQuery" for call in calls)
+ assert not any(record.levelname == "ERROR" for record in caplog.records)
+ await dispatch(event(900001, callback_data=f"admin_edit_field_{lottery.id}_title"))
+ await dispatch(event(900001, callback_data="admin_lotteries"))
+ assert await context(900001).get_state() is None
+ await dispatch(event(900001, callback_data="admin_create_lottery"))
+ await dispatch(event(900001, text="регистрация"))
+ assert await context(900001).get_state() == AdminStates.lottery_description.state
+ async with async_session_maker() as session:
+ assert (await session.get(Lottery, lottery.id)).title == "Edited"
+ assert "edit_lottery_id" not in await context(900001).get_data()
+
+
+async def test_lottery_text_validation_and_long_preview_keep_confirmation_usable():
+ from src.handlers.admin_panel import AdminStates
+ await dispatch(event(900001, callback_data="admin_create_lottery"))
+ for invalid in (" ", "x" * 501):
+ await dispatch(event(900001, text=invalid))
+ assert await context(900001).get_state() == AdminStates.lottery_title.state
+ await dispatch(event(900001, text="Title"))
+ await dispatch(event(900001, text="d" * 4000))
+ await dispatch(event(900001, text=" "))
+ assert await context(900001).get_state() == AdminStates.lottery_prizes.state
+ calls = await dispatch(event(900001, text="\n".join(["🎁" * 250] * 4)))
+ assert all(utf16_length(call.text) <= 4096 for call in calls if getattr(call, "text", None))
+ assert await context(900001).get_state() == AdminStates.lottery_confirm.state
+ assert any(getattr(call, "reply_markup", None) for call in calls)
+
+
+def test_account_parser_preserves_every_row_and_three_or_four_digit_cards():
+ entries, errors = parse_account_records("123 11-22-33-44-55-66-77\n0007\t88-99-00-11-22-33-44")
+ assert entries == ["123 11-22-33-44-55-66-77", "0007 88-99-00-11-22-33-44"] and not errors
+ assert parse_account_records("11-22-33-44-55-66-77\n88-99-00-11-22-33-44")[0] == [
+ "11-22-33-44-55-66-77", "88-99-00-11-22-33-44"]
+ assert parse_account_records("11-22-33-44-55-66-77\n0007")[0] == ["0007 11-22-33-44-55-66-77"]
+ text = "Viposnova 16-11-2025 22:19:36\n11-22-33-44-55-66-77\n0.00 123\nViposnova\n88-99-00-11-22-33-44\n0.00 0007"
+ assert parse_account_records(text)[0] == entries
+ assert parse_account_records("1234")[0] == []
+ assert parse_account_records("11-22-33-44-55-66-77-88")[0] == []
+
+
+async def test_bulk_account_form_accepts_tabs_and_stores_only_json_in_fsm():
+ await create_draw()
+ async with async_session_maker() as session:
+ for actor, card in [(950, "123"), (951, "0007")]:
+ user = await UserService.get_or_create_user(session, actor)
+ user.club_card_number = card
+ await session.commit()
+ await dispatch(event(900002, text="/ADD_ACCOUNT"))
+ await dispatch(event(900002, text="123\t11-22-33-44-55-66-77\n0007\t88-99-00-11-22-33-44"))
+ data = await context(900002).get_data()
+ assert len(data["accounts"]) == 2
+ json.dumps(data) # RedisStorage serializes the same structure.
+ async with async_session_maker() as session:
+ assert len((await session.scalars(select(Account))).all()) == 2
+
+
+async def test_two_clients_cannot_claim_the_same_card_and_loser_can_retry():
+ await start_registration(950)
+ await start_registration(951)
+ await dispatch(event(950, text="1234"))
+ await dispatch(event(951, text="1234"))
+ await asyncio.gather(dispatch(event(950, text="-")), dispatch(event(951, text="-")))
+ async with async_session_maker() as session:
+ registered = (await session.scalars(select(User).where(User.is_registered.is_(True)))).all()
+ assert len(registered) == 1
+ loser = 951 if registered[0].telegram_id == 950 else 950
+ assert await context(loser).get_state() == RegistrationStates.waiting_for_club_card.state
+ assert (await context(loser).get_data())["nickname"] == "Tester"
+ await dispatch(event(loser, text="0007"))
+ await dispatch(event(loser, text="-"))
+ assert await context(loser).get_state() is None
+
+
+async def test_multiline_participant_ids_and_invalid_numbers_have_explicit_results(caplog):
+ from src.handlers.admin_panel import AdminStates
+ from src.core.models import Participation
+ lottery = await create_draw()
+ async with async_session_maker() as session:
+ await UserService.get_or_create_user(session, 950)
+ await UserService.get_or_create_user(session, 951)
+ await dispatch(event(900001, text="/cancel"))
+ state = context(900001)
+ await state.set_state(AdminStates.add_participant_bulk)
+ await state.set_data({"bulk_add_lottery_id": lottery.id})
+ await dispatch(event(900001, text="9" * 80))
+ assert await state.get_state() == AdminStates.add_participant_bulk.state
+ await dispatch(event(900001, text="950\n951"))
+ async with async_session_maker() as session:
+ assert len((await session.scalars(select(Participation))).all()) == 2
+ for command in ("/redraw", "/check_unclaimed", "/winner_status", "/ban", "/unban", "/set_forward"):
+ calls = await dispatch(event(900001, text=command + " " + "9" * 80))
+ assert any(getattr(call, "text", None) for call in calls)
+ assert not any(record.levelname == "ERROR" for record in caplog.records)
+
+
+async def test_ban_reply_resolves_copied_message_and_escapes_reason():
+ from src.core.models import ChatMessage, BannedUser
+ async with async_session_maker() as session:
+ target = await UserService.get_or_create_user(session, 950, first_name="")
+ await UserService.get_or_create_user(session, 900001)
+ session.add(ChatMessage(user_id=target.id, telegram_message_id=9, message_type="text",
+ forwarded_message_ids={"900001": 44}))
+ await session.commit()
+ update = event(900001, text="/ban ")
+ reply = event(900001).message.model_copy(update={"message_id": 44})
+ update = update.model_copy(update={"message": update.message.model_copy(update={"reply_to_message": reply})})
+ calls = await dispatch(update)
+ assert any("<reason>" in (getattr(call, "text", "") or "") for call in calls)
+ async with async_session_maker() as session:
+ assert await session.scalar(select(BannedUser.telegram_id).where(BannedUser.is_active.is_(True))) == 950