From f3f8d0f737248c94b1378eb7709e0bdfcfae29de Mon Sep 17 00:00:00 2001 From: Trevor1985 Date: Sun, 13 Sep 2026 21:39:40 +0900 Subject: [PATCH] Fix text dialog routing and club card registration recovery --- README.md | 1 + docs/ADMIN_GUIDE.md | 1 + docs/TEXT_INPUT_AUDIT.md | 61 ++++++ main.py | 13 +- src/core/registration_services.py | 20 +- src/core/services.py | 14 +- src/core/user_management.py | 2 +- src/filters/dialog_input.py | 16 ++ src/handlers/account_handlers.py | 6 +- src/handlers/admin_account_handlers.py | 64 ++---- src/handlers/admin_chat_handlers.py | 51 ++++- src/handlers/admin_emoji_handlers.py | 8 +- src/handlers/admin_panel.py | 188 ++++++++++------- src/handlers/cashier_handlers.py | 12 +- src/handlers/chat_handlers.py | 26 +-- src/handlers/help_handlers.py | 2 +- src/handlers/input_fallback.py | 19 ++ src/handlers/message_management.py | 9 +- src/handlers/operator_help.py | 4 +- src/handlers/p2p_chat.py | 6 +- src/handlers/redraw_handlers.py | 11 +- src/handlers/registration_handlers.py | 82 ++++---- src/handlers/staff_handlers.py | 7 +- src/utils/account_input.py | 54 +++++ src/utils/account_utils.py | 106 +--------- src/utils/input_validation.py | 47 +++++ src/utils/text_output.py | 12 ++ tests/test_text_inputs.py | 280 +++++++++++++++++++++++++ 28 files changed, 788 insertions(+), 334 deletions(-) create mode 100644 docs/TEXT_INPUT_AUDIT.md create mode 100644 src/filters/dialog_input.py create mode 100644 src/handlers/input_fallback.py create mode 100644 src/utils/account_input.py create mode 100644 src/utils/input_validation.py create mode 100644 src/utils/text_output.py create mode 100644 tests/test_text_inputs.py 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