Fix text dialog routing and club card registration recovery
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-13 21:39:40 +09:00
parent 3c1a6a02a5
commit f3f8d0f737
28 changed files with 788 additions and 334 deletions

View File

@@ -4,6 +4,7 @@
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md), Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md). [настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
[Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md).
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md), Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md). [системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли. Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.

View File

@@ -14,6 +14,7 @@
## Работа с пользователями ## Работа с пользователями
1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот. 1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
Карта вводится цифрами; допустимы и три, и четыре цифры, ведущие нули сохраняются. Если карта заранее привязана к этому же клиенту, он может завершить регистрацию с ней. При ошибке номера или телефона можно повторить ввод на текущем шаге.
2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту. 2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре. 3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие. 4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие.

61
docs/TEXT_INPUT_AUDIT.md Normal file
View File

@@ -0,0 +1,61 @@
# Проверка текстового ввода
Проверены зарегистрированные обработчики личных диалогов: регистрация, админка, касса, роли, каналы, эмодзи, общий и личный чат, команды и резервная обработка ввода. Демонстрационные `test_handlers.py` в рабочий диспетчер не подключены.
## Клубная карта: три и четыре цифры
Ограничения «не более трёх цифр» в текущем коде нет. В рабочей базе есть карты из четырёх цифр. Проверка полного диалога через диспетчер подтверждает приём `123`, `1234`, `0007` и сохранение ведущих нулей. Номер остаётся строкой; допустимы 150 цифр, как и до исправления.
Исторический отказ именно на четырёх цифрах без текста ответа воспроизвести не удалось. Найдены и исправлены причины потери/остановки диалога:
- Занятая карта сбрасывала регистрацию. Теперь остаётся шаг карты, можно исправить номер.
- Карта, заранее записанная в профиле самого клиента, тоже считалась чужой занятой картой. Теперь клиент может завершить регистрацию со своей картой; карту другого пользователя присвоить нельзя.
- Формат карты проверялся только после телефона. Теперь ошибка показывается сразу, на шаге карты.
- Неправильный телефон приводил к потере введённых данных. Теперь сохраняются карта и никнейм.
- Никнейм записывался второй транзакцией. Теперь никнейм, карта, телефон и регистрация сохраняются вместе.
- Сбой ответа 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` |

13
main.py
View File

@@ -8,7 +8,7 @@ from contextlib import asynccontextmanager
from aiogram import Bot, Dispatcher, Router, F from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message, CallbackQuery 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.memory import MemoryStorage, SimpleEventIsolation
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
@@ -164,7 +164,7 @@ async def cmd_register(message: Message, state: FSMContext):
await btn_registration(message, state) 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): async def text_registration(message: Message, state: FSMContext):
"""Обработчик текста для регистрации""" """Обработчик текста для регистрации"""
await btn_registration(message, state) await btn_registration(message, state)
@@ -192,9 +192,9 @@ async def btn_help(message: Message):
@router.message(F.text == "⚙️ Админ панель") @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 == "🚪 Выйти из чата") @router.message(F.text == "🚪 Выйти из чата")
@@ -211,7 +211,7 @@ async def btn_main_menu(message: Message, state: FSMContext):
@router.message(CaseInsensitiveCommand("admin")) @router.message(CaseInsensitiveCommand("admin"))
async def cmd_admin(message: Message): async def cmd_admin(message: Message, state: FSMContext):
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel""" """Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
from src.core.config import ADMIN_IDS from src.core.config import ADMIN_IDS
from src.core.database import async_session_maker from src.core.database import async_session_maker
@@ -238,6 +238,7 @@ async def cmd_admin(message: Message):
return return
# Отправляем сообщение с кнопкой админ панели # Отправляем сообщение с кнопкой админ панели
await state.clear()
from src.components.ui import KeyboardBuilderImpl from src.components.ui import KeyboardBuilderImpl
kb = KeyboardBuilderImpl() kb = KeyboardBuilderImpl()
keyboard = kb.get_admin_keyboard() keyboard = kb.get_admin_keyboard()
@@ -329,6 +330,8 @@ def configure_dispatcher():
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов) # 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
from src.handlers.input_fallback import input_fallback_router
dp.include_router(input_fallback_router)

View File

@@ -6,6 +6,7 @@ from typing import Optional, List
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from src.utils.account_utils import format_account_number from src.utils.account_utils import format_account_number
from src.utils.input_validation import club_card, phone_number
import secrets import secrets
@@ -17,16 +18,17 @@ class RegistrationService:
session: AsyncSession, session: AsyncSession,
telegram_id: int, telegram_id: int,
club_card_number: str, club_card_number: str,
phone: Optional[str] = None phone: Optional[str] = None,
nickname: Optional[str] = None,
) -> User: ) -> User:
"""Зарегистрировать нового пользователя с клубной картой""" """Зарегистрировать нового пользователя с клубной картой"""
if not club_card_number or not club_card_number.isascii() or not club_card_number.isdigit() or len(club_card_number) > 50: club_card_number = club_card(club_card_number)
raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр") phone = phone_number(phone)
if phone and len(phone) > 20: if nickname is not None and not 2 <= len(nickname) <= 20:
raise ValueError("Телефон должен быть не длиннее 20 символов") raise ValueError("Никнейм должен содержать от 2 до 20 символов")
# Проверяем, не занята ли клубная карта # Проверяем, не занята ли клубная карта
existing = await session.execute( 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(): if existing.scalar_one_or_none():
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована") raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
@@ -39,10 +41,14 @@ class RegistrationService:
if not user: if not user:
raise ValueError("Пользователь не найден") raise ValueError("Пользователь не найден")
if user.is_registered:
raise ValueError("Вы уже зарегистрированы. Для изменения карты обратитесь к администратору.")
# Обновляем данные пользователя # Обновляем данные пользователя
user.club_card_number = club_card_number user.club_card_number = club_card_number
user.phone = phone user.phone = phone
if nickname is not None:
user.nickname = nickname
user.is_registered = True user.is_registered = True
user.generate_verification_code() user.generate_verification_code()
@@ -51,8 +57,6 @@ class RegistrationService:
except IntegrityError: except IntegrityError:
await session.rollback() await session.rollback()
raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None
await session.refresh(user)
return user return user
@staticmethod @staticmethod

View File

@@ -31,6 +31,8 @@ class UserService:
@staticmethod @staticmethod
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]: async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
"""Получить пользователя по Telegram ID""" """Получить пользователя по Telegram ID"""
if not isinstance(telegram_id, int) or not 0 < telegram_id < 2**63:
return None
result = await session.execute( result = await session.execute(
select(User).where(User.telegram_id == telegram_id) select(User).where(User.telegram_id == telegram_id)
) )
@@ -157,10 +159,11 @@ class LotteryService:
async def create_lottery(session: AsyncSession, title: str, description: str, async def create_lottery(session: AsyncSession, title: str, description: str,
prizes: List[str], creator_id: int) -> Lottery: prizes: List[str], creator_id: int) -> Lottery:
"""Создать новый розыгрыш""" """Создать новый розыгрыш"""
from src.utils.input_validation import lottery_field
lottery = Lottery( lottery = Lottery(
title=title, title=lottery_field("title", title),
description=description, description=lottery_field("description", description),
prizes=prizes, prizes=lottery_field("prizes", prizes),
creator_id=creator_id creator_id=creator_id
) )
session.add(lottery) session.add(lottery)
@@ -171,6 +174,8 @@ class LotteryService:
@staticmethod @staticmethod
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]: async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
"""Получить розыгрыш по ID""" """Получить розыгрыш по ID"""
if not isinstance(lottery_id, int) or not 0 < lottery_id < 2**31:
return None
result = await session.execute( result = await session.execute(
select(Lottery) select(Lottery)
.options(selectinload(Lottery.participations).selectinload(Participation.user)) .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"}: if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
return False return False
try: 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( result = await session.execute(
update(Lottery) update(Lottery)
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False)) .where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))

View File

@@ -68,7 +68,7 @@ class UserManagementService:
search_conditions.append(User.club_card_number.ilike(f'%{query}%')) search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
# Если запрос - число, ищем по telegram_id # Если запрос - число, ищем по 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)) search_conditions.append(User.telegram_id == int(query))
conditions.append(or_(*search_conditions)) conditions.append(or_(*search_conditions))

View File

@@ -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

View File

@@ -43,13 +43,15 @@ async def detect_account_input(message: Message, state: FSMContext):
Извлекает номер клубной карты и определяет владельца Извлекает номер клубной карты и определяет владельца
""" """
if not await is_admin(message.from_user.id): 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) accounts = parse_accounts_from_message(message.text)
if not accounts: if not accounts:
return # Счета не обнаружены, пропускаем from aiogram.dispatcher.event.bases import SkipHandler
raise SkipHandler()
# Извлекаем номера клубных карт и определяем владельцев # Извлекаем номера клубных карт и определяем владельцев
from ..core.services import UserService from ..core.services import UserService

View File

@@ -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 src.utils.errors import public_error
from aiogram import Router, F, Bot from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup 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.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, and_ from sqlalchemy import select, and_
@@ -121,7 +125,7 @@ async def process_single_account(message: Message, club_card: str, account_numbe
await state.clear() 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): async def process_accounts_data(message: Message, state: FSMContext):
"""Обработка данных счетов (один или несколько)""" """Обработка данных счетов (один или несколько)"""
if message.text.strip().lower() == '/cancel': 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"Вы отправили: {len(lines)} строк\n\n"
f"Разделите данные на несколько частей." f"Разделите данные на несколько частей."
) )
await state.clear()
return return
# Отправляем начальное уведомление # Отправляем начальное уведомление
@@ -150,46 +153,17 @@ async def process_accounts_data(message: Message, state: FSMContext):
) )
accounts_data = [] accounts_data = []
errors = [] entries, errors = parse_account_records(message.text)
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
# Универсальный парсер: поддержка однострочного и многострочного формата # Универсальный парсер: поддержка однострочного и многострочного формата
i = 0 for entry in entries:
while i < len(lines): parts = entry.split()
line = lines[i].strip() if len(parts) != 2:
errors.append("Для счёта не указана клубная карта: " + entry)
# Пропускаем пустые строки и строки с названиями/датами
if not line or any(x in line.lower() for x in ['viposnova', '0.00', ':']):
i += 1
continue continue
club_card, account_number = parts
# Проверяем, есть ли в строке пробел (однострочный формат: "карта счет")
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
# Создаем счет
try: try:
async with async_session_maker() as session: async with async_session_maker() as session:
account = await AccountService.create_account( account = await AccountService.create_account(
@@ -223,7 +197,6 @@ async def process_accounts_data(message: Message, state: FSMContext):
except Exception as e: except Exception as e:
errors.append(f"Счет {account_number}: {public_error(e)}") errors.append(f"Счет {account_number}: {public_error(e)}")
i += 1
# Удаляем progress сообщение # Удаляем progress сообщение
try: try:
@@ -317,12 +290,11 @@ async def process_accounts_data(message: Message, state: FSMContext):
text += f"\n... и еще {len(errors) - 5} ошибок\n" text += f"\n... и еще {len(errors) - 5} ошибок\n"
if not accounts_data: if not accounts_data:
await message.answer(text) await answer_plain_pages(message, text)
await state.clear()
return 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) 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) lotteries = await LotteryService.get_active_lotteries(session)
if not lotteries: if not lotteries:
await message.answer( await answer_plain_pages(message,
prev_text + " Нет активных розыгрышей для добавления счетов" prev_text + " Нет активных розыгрышей для добавления счетов"
) )
await state.clear() 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") InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
]) ])
await message.answer( await answer_plain_pages(message,
prev_text + " **Добавить счета в розыгрыш?**\n\n" prev_text + " **Добавить счета в розыгрыш?**\n\n"
"Выберите розыгрыш из списка:", "Выберите розыгрыш из списка:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -503,7 +475,7 @@ async def verify_winner_command(message: Message):
verification_code = parts[1].upper() verification_code = parts[1].upper()
try: try:
lottery_id = int(parts[2]) lottery_id = numeric_id(parts[2], maximum=2**31 - 1)
except ValueError: except ValueError:
await message.answer("❌ lottery_id должен быть числом") await message.answer("❌ lottery_id должен быть числом")
return return
@@ -588,7 +560,7 @@ async def winner_status_command(message: Message):
return return
try: try:
lottery_id = int(parts[1]) lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
except ValueError: except ValueError:
await message.answer("❌ lottery_id должен быть числом") await message.answer("❌ lottery_id должен быть числом")
return return

View File

@@ -1,8 +1,10 @@
"""Админские обработчики для управления чатом""" """Админские обработчики для управления чатом"""
from html import escape
from src.utils.input_validation import numeric_id
from src.utils.errors import public_error from src.utils.errors import public_error
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton 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 sqlalchemy.ext.asyncio import AsyncSession
from src.filters.case_insensitive import CaseInsensitiveCommand from src.filters.case_insensitive import CaseInsensitiveCommand
@@ -20,6 +22,23 @@ from src.core.permissions import admin_only
router = Router(name='admin_chat_router') 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: def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура выбора режима чата""" """Клавиатура выбора режима чата"""
return InlineKeyboardMarkup(inline_keyboard=[ return InlineKeyboardMarkup(inline_keyboard=[
@@ -89,7 +108,11 @@ async def cmd_set_forward(message: Message):
) )
return 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: async with async_session_maker() as session:
settings = await ChatSettingsService.set_forward_chat(session, chat_id) settings = await ChatSettingsService.set_forward_chat(session, chat_id)
@@ -135,7 +158,10 @@ async def cmd_ban(message: Message):
# Проверяем является ли это ответом на сообщение # Проверяем является ли это ответом на сообщение
if message.reply_to_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 reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
else: else:
args = message.text.split(maxsplit=2) args = message.text.split(maxsplit=2)
@@ -150,7 +176,7 @@ async def cmd_ban(message: Message):
return return
try: try:
target_user_id = int(args[1]) target_user_id = numeric_id(args[1])
reason = args[2] if len(args) > 2 else None reason = args[2] if len(args) > 2 else None
except ValueError: except ValueError:
await message.answer("❌ Неверный ID пользователя") await message.answer("❌ Неверный ID пользователя")
@@ -182,11 +208,11 @@ async def cmd_ban(message: Message):
reason=reason reason=reason
) )
reason_text = f"\n📝 Причина: {reason}" if reason else "" reason_text = f"\n📝 Причина: {escape(reason[:500])}" if reason else ""
await message.answer( await message.answer(
f"🚫 <b>Пользователь забанен</b>\n\n" f"🚫 <b>Пользователь забанен</b>\n\n"
f"👤 Пользователь: {user.name or 'Неизвестен'}\n" f"👤 Пользователь: {escape(user.nickname or user.first_name or 'Неизвестен')}\n"
f"🆔 ID: <code>{target_user_id}</code>" f"🆔 ID: <code>{target_user_id}</code>"
f"{reason_text}", f"{reason_text}",
parse_mode="HTML" parse_mode="HTML"
@@ -200,7 +226,10 @@ async def cmd_unban(message: Message):
# Проверяем является ли это ответом на сообщение # Проверяем является ли это ответом на сообщение
if message.reply_to_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: else:
args = message.text.split() args = message.text.split()
if len(args) < 2: if len(args) < 2:
@@ -214,7 +243,7 @@ async def cmd_unban(message: Message):
return return
try: try:
target_user_id = int(args[1]) target_user_id = numeric_id(args[1])
except ValueError: except ValueError:
await message.answer("❌ Неверный ID пользователя") await message.answer("❌ Неверный ID пользователя")
return return
@@ -252,11 +281,11 @@ async def cmd_banlist(message: Message):
user = ban.user user = ban.user
admin = ban.admin admin = ban.admin
text += f"👤 {user.name or 'Неизвестен'} (<code>{ban.telegram_id}</code>)\n" text += f"👤 {escape(user.nickname or user.first_name or 'Неизвестен')} (<code>{ban.telegram_id}</code>)\n"
text += f"🔨 Забанил: {admin.name if admin else 'Неизвестен'}\n" text += f"🔨 Забанил: {escape((admin.nickname or admin.first_name or 'Неизвестен') if admin else 'Неизвестен')}\n"
if ban.reason: 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 += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
text += "\n" text += "\n"

View File

@@ -2,12 +2,14 @@
Хендлеры для управления кастомными эмодзи админом Хендлеры для управления кастомными эмодзи админом
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
""" """
from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error from src.utils.errors import public_error
import logging import logging
from html import escape from html import escape
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton 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.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession 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) 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): async def receive_emoji(message: Message, state: FSMContext):
"""Получить эмодзи от админа и сохранить его emoji_id""" """Получить эмодзи от админа и сохранить его emoji_id"""
entities = [entity for entity in message.entities or () if entity.type == "custom_emoji"] 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) 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): async def receive_emoji_description(message: Message, state: FSMContext):
"""Получить описание эмодзи и сохранить в БД""" """Получить описание эмодзи и сохранить в БД"""
if not message.text: if not message.text:

View File

@@ -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.utils.errors import public_error
from src.core.access import is_admin from src.core.access import is_admin
from src.core.access import is_admin as check_admin_access 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") @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): if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True) await callback.answer("❌ Недостаточно прав", show_alert=True)
return return
await state.clear()
async with async_session_maker() as session: 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"})) @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): if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True) await callback.answer("❌ Недостаточно прав", show_alert=True)
return return
await state.clear()
text = "🎲 Управление розыгрышами\n\n" text = "🎲 Управление розыгрышами\n\n"
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} начинает создание розыгрыша") logging.info(f"✅ Админ {callback.from_user.id} начинает создание розыгрыша")
await state.clear()
text = "📝 Создание нового розыгрыша\n\n" text = "📝 Создание нового розыгрыша\n\n"
text += "Шаг 1 из 4\n\n" text += "Шаг 1 из 4\n\n"
text += "Введите название розыгрыша:" text += "Введите название розыгрыша:"
@@ -261,13 +268,19 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
await callback.message.answer(f"❌ Ошибка: {public_error(e)}") 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): async def process_lottery_title(message: Message, state: FSMContext):
"""Обработка названия розыгрыша (создание или редактирование)""" """Обработка названия розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав") await message.answer("❌ Недостаточно прав")
return return
try:
value = lottery_field("title", message.text)
except ValueError as error:
await message.answer(str(error))
return
data = await state.get_data() data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id') 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( success = await LotteryService.update_lottery(
session, session,
edit_lottery_id, edit_lottery_id,
title=message.text title=value
) )
if success: if success:
await message.answer(f"✅ Название изменено на: {message.text}") await message.answer(f"✅ Название изменено на: {value}")
await state.clear() await state.clear()
# Возвращаемся к выбору полей # Возвращаемся к выбору полей
from aiogram.types import CallbackQuery await show_edit_fields(message, edit_lottery_id)
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)
else: else:
await message.answer("❌ Ошибка при изменении названия") await message.answer("❌ Ошибка при изменении названия")
return return
# Если это создание нового розыгрыша # Если это создание нового розыгрыша
await state.update_data(title=message.text) await state.update_data(title=value)
text = f"📝 Создание нового розыгрыша\n\n" text = f"📝 Создание нового розыгрыша\n\n"
text += f"Шаг 2 из 4\n\n" text += f"Шаг 2 из 4\n\n"
text += f"✅ Название: {message.text}\n\n" text += f"✅ Название: {value}\n\n"
text += f"Введите описание розыгрыша (или '-' для пропуска):" text += f"Введите описание розыгрыша (или '-' для пропуска):"
await message.answer(text) await answer_plain_pages(message, text)
await state.set_state(AdminStates.lottery_description) 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): async def process_lottery_description(message: Message, state: FSMContext):
"""Обработка описания розыгрыша (создание или редактирование)""" """Обработка описания розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав") await message.answer("❌ Недостаточно прав")
return return
try:
value = lottery_field("description", message.text)
except ValueError as error:
await message.answer(str(error))
return
data = await state.get_data() data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id') edit_lottery_id = data.get('edit_lottery_id')
# Если это редактирование существующего розыгрыша # Если это редактирование существующего розыгрыша
if edit_lottery_id: if edit_lottery_id:
description = None if message.text == "-" else message.text description = value
async with async_session_maker() as session: async with async_session_maker() as session:
success = await LotteryService.update_lottery( success = await LotteryService.update_lottery(
session, session,
@@ -333,21 +344,13 @@ async def process_lottery_description(message: Message, state: FSMContext):
await message.answer(f"✅ Описание изменено") await message.answer(f"✅ Описание изменено")
await state.clear() await state.clear()
# Возвращаемся к выбору полей # Возвращаемся к выбору полей
from aiogram.types import CallbackQuery await show_edit_fields(message, edit_lottery_id)
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)
else: else:
await message.answer("❌ Ошибка при изменении описания") await message.answer("❌ Ошибка при изменении описания")
return return
# Если это создание нового розыгрыша # Если это создание нового розыгрыша
description = None if message.text == "-" else message.text description = value
await state.update_data(description=description) await state.update_data(description=description)
data = await state.get_data() 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"🥉 AirPods Pro\n"
text += f"🏆 10,000 рублей" text += f"🏆 10,000 рублей"
await message.answer(text) await answer_plain_pages(message, text)
await state.set_state(AdminStates.lottery_prizes) 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): async def process_lottery_prizes(message: Message, state: FSMContext):
"""Обработка призов розыгрыша (создание или редактирование)""" """Обработка призов розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав") await message.answer("❌ Недостаточно прав")
return return
try:
value = lottery_field("prizes", message.text)
except ValueError as error:
await message.answer(str(error))
return
data = await state.get_data() data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id') 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: if edit_lottery_id:
@@ -392,15 +401,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
await message.answer(f"✅ Призы изменены") await message.answer(f"✅ Призы изменены")
await state.clear() await state.clear()
# Возвращаемся к выбору полей # Возвращаемся к выбору полей
from aiogram.types import CallbackQuery await show_edit_fields(message, edit_lottery_id)
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)
else: else:
await message.answer("❌ Ошибка при изменении призов") await message.answer("❌ Ошибка при изменении призов")
return return
@@ -420,7 +421,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
text += f"\n✅ Подтвердите создание розыгрыша:" text += f"\n✅ Подтвердите создание розыгрыша:"
await message.answer( await answer_plain_pages(message,
text, text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Создать", callback_data="confirm_create_lottery")], [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) 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): async def process_add_participant(message: Message, state: FSMContext):
"""Обработка добавления участника""" """Обработка добавления участника"""
if not await check_admin_access(message.from_user.id): 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): async def process_remove_participant(message: Message, state: FSMContext):
"""Обработка удаления участника""" """Обработка удаления участника"""
if not await check_admin_access(message.from_user.id): 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) 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): async def process_search_participants(message: Message, state: FSMContext):
"""Обработка поиска участников""" """Обработка поиска участников"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
@@ -1169,6 +1170,9 @@ async def process_search_participants(message: Message, state: FSMContext):
return return
search_term = message.text.strip() 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: async with async_session_maker() as session:
users = await UserService.search_users(session, search_term) 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) 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): async def process_bulk_add_participant(message: Message, state: FSMContext):
"""Обработка массового добавления участников""" """Обработка массового добавления участников"""
if not await check_admin_access(message.from_user.id): 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'] 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 = [] telegram_ids = []
async with async_session_maker() as session: async with async_session_maker() as session:
@@ -1302,11 +1309,14 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
if user: if user:
telegram_ids.append(user.telegram_id) telegram_ids.append(user.telegram_id)
elif user_input.isdigit(): elif user_input.isdigit():
telegram_ids.append(int(user_input)) telegram_ids.append(numeric_id(user_input))
except: except:
continue 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) results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id) 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) 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): async def process_bulk_remove_participant(message: Message, state: FSMContext):
"""Обработка массового удаления участников""" """Обработка массового удаления участников"""
if not await check_admin_access(message.from_user.id): 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'] 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 = [] telegram_ids = []
async with async_session_maker() as session: async with async_session_maker() as session:
@@ -1433,11 +1446,14 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
if user: if user:
telegram_ids.append(user.telegram_id) telegram_ids.append(user.telegram_id)
elif user_input.isdigit(): elif user_input.isdigit():
telegram_ids.append(int(user_input)) telegram_ids.append(numeric_id(user_input))
except: except:
continue 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) results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id) 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) 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): async def process_add_to_lottery(message: Message, state: FSMContext):
"""Обработка добавления участника в конкретный розыгрыш""" """Обработка добавления участника в конкретный розыгрыш"""
if not await is_admin(message.from_user.id): 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) 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): async def process_remove_from_lottery(message: Message, state: FSMContext):
"""Обработка удаления участника из конкретного розыгрыша""" """Обработка удаления участника из конкретного розыгрыша"""
if not await is_admin(message.from_user.id): 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) 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): async def process_bulk_add_accounts(message: Message, state: FSMContext):
"""Обработка массового добавления участников по номерам счетов""" """Обработка массового добавления участников по номерам счетов"""
if not await check_admin_access(message.from_user.id): 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 для корректной обработки формата "КАРТА СЧЕТ" # Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text) 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: 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) 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): async def process_bulk_remove_accounts(message: Message, state: FSMContext):
"""Обработка массового удаления участников по номерам счетов""" """Обработка массового удаления участников по номерам счетов"""
if not await check_admin_access(message.from_user.id): 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 для корректной обработки формата "КАРТА СЧЕТ" # Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text) 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: 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]) lottery_id = int(callback.data.split("_")[-1])
await state.update_data(edit_lottery_id=lottery_id) 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) lottery = await LotteryService.get_lottery(session, lottery_id)
if lottery is None:
await message.answer("Розыгрыш не найден. Откройте /admin.")
return
text = f"📝 Редактирование: {lottery.title}\n\n" text = f"📝 Редактирование: {lottery.title}\n\n"
text += "Выберите, что хотите изменить:\n\n" text += "Выберите, что хотите изменить:\n\n"
text += f"📝 Название: {lottery.title}\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"🎁 Призы: {len(getattr(lottery, 'prizes', []))} шт.\n"
text += f"🎭 Отображение: {getattr(lottery, 'winner_display_type', 'username')}\n" text += f"🎭 Отображение: {getattr(lottery, 'winner_display_type', 'username')}\n"
text += f"🟢 Активен: {'Да' if getattr(lottery, 'is_active', True) else 'Нет'}" 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")] [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_")) @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) 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): async def process_winner_place(message: Message, state: FSMContext):
"""Обработка места победителя""" """Обработка места победителя"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
@@ -2784,7 +2816,7 @@ async def process_winner_place(message: Message, state: FSMContext):
return return
try: try:
place = int(message.text) place = numeric_id(message.text, maximum=2**31 - 1)
if place < 1: if place < 1:
raise ValueError raise ValueError
except ValueError: except ValueError:
@@ -2798,6 +2830,9 @@ async def process_winner_place(message: Message, state: FSMContext):
async with async_session_maker() as session: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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: if lottery.manual_winners and str(place) in lottery.manual_winners:
existing_id = lottery.manual_winners[str(place)] existing_id = lottery.manual_winners[str(place)]
existing_user = await UserService.get_user_by_telegram_id(session, existing_id) 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) 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): async def process_winner_user(message: Message, state: FSMContext):
"""Обработка пользователя-победителя (по ID, username или номеру счета)""" """Обработка пользователя-победителя (по ID, username или номеру счета)"""
if not await check_admin_access(message.from_user.id): 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) 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): async def admin_import_users_process(message: Message, state: FSMContext):
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
return return
@@ -4644,7 +4679,7 @@ BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть со
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки." 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): async def admin_broadcast_send(message: Message, state: FSMContext):
"""Обработка и отправка рассылки""" """Обработка и отправка рассылки"""
if not await check_admin_access(message.from_user.id): 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) 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): async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
"""Обработка ID канала""" """Обработка ID канала"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
return return
try: try:
chat_id = int(message.text.strip()) chat_id = numeric_id(message.text, signed=True)
except ValueError: except ValueError:
await message.answer( await message.answer(
"❌ Неверный формат ID. Отправьте число, например: -1001234567890" "❌ Неверный формат ID. Отправьте число, например: -1001234567890"
@@ -4903,7 +4938,6 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
await message.answer( await message.answer(
"❌ Неверный тип чата. Поддерживаются только каналы и группы." "❌ Неверный тип чата. Поддерживаются только каналы и группы."
) )
await state.clear()
return return
# Сохраняем данные # Сохраняем данные
@@ -4917,7 +4951,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
# Запрашиваем описание # Запрашиваем описание
text = ( text = (
f"✅ <b>Канал найден!</b>\n\n" f"✅ <b>Канал найден!</b>\n\n"
f"📱 <b>Название:</b> {chat.title}\n" f"📱 <b>Название:</b> {escape(chat.title or "")}\n"
f"🆔 <b>ID:</b> <code>{chat_id}</code>\n" f"🆔 <b>ID:</b> <code>{chat_id}</code>\n"
f"📝 <b>Тип:</b> {'Канал' if chat_type == 'channel' else 'Группа'}\n" f"📝 <b>Тип:</b> {'Канал' 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)}", f"Детали: {public_error(e)}",
parse_mode="HTML" 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): async def admin_broadcast_add_channel_description(message: Message, state: FSMContext):
"""Обработка описания канала""" """Обработка описания канала"""
if not await check_admin_access(message.from_user.id): 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() 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: 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.description = description
existing.chat_type = data['chat_type'] existing.chat_type = data['chat_type']
await session.commit() await session.commit()
await state.clear()
await message.answer( await message.answer(
"✅ <b>Канал обновлен!</b>\n\n" "✅ <b>Канал обновлен!</b>\n\n"
f"📱 {data['title']}", f"📱 {escape(data['title'])}",
parse_mode="HTML" parse_mode="HTML"
) )
else: else:
@@ -4995,10 +5029,11 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
) )
session.add(channel) session.add(channel)
await session.commit() await session.commit()
await state.clear()
await message.answer( await message.answer(
"✅ <b>Канал добавлен!</b>\n\n" "✅ <b>Канал добавлен!</b>\n\n"
f"📱 {data['title']}\n" f"📱 {escape(data['title'])}\n"
f"Теперь вы можете использовать его для рассылок.", f"Теперь вы можете использовать его для рассылок.",
parse_mode="HTML" 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) 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): async def admin_users_search_process(message: Message, state: FSMContext):
"""Обработка поискового запроса""" """Обработка поискового запроса"""
if not await check_admin_access(message.from_user.id): if not await check_admin_access(message.from_user.id):
return return
query = message.text.strip() query = message.text.strip()
if not 1 <= len(query) <= 100:
await message.answer("Поисковый запрос должен содержать от 1 до 100 символов. /cancel — отмена.")
return
if query == "/cancel": if query == "/cancel":
await message.answer("❌ Поиск отменен") await message.answer("❌ Поиск отменен")
@@ -5233,12 +5271,12 @@ async def admin_users_search_process(message: Message, state: FSMContext):
) )
if not users: if not users:
text = f"❌ По запросу «{query}» ничего не найдено" text = f"❌ По запросу «{escape(query)}» ничего не найдено"
buttons = [ buttons = [
[InlineKeyboardButton(text="◀️ В управление пользователями", callback_data="admin_users")] [InlineKeyboardButton(text="◀️ В управление пользователями", callback_data="admin_users")]
] ]
else: else:
text = f"🔍 <b>Результаты поиска:</b> «{query}»\n" text = f"🔍 <b>Результаты поиска:</b> «{escape(query)}»\n"
text += f"Найдено: {total} пользователей\n\n" text += f"Найдено: {total} пользователей\n\n"
buttons = [] buttons = []
@@ -5262,7 +5300,7 @@ async def admin_users_search_process(message: Message, state: FSMContext):
if total > 15: if total > 15:
nav_buttons.append(InlineKeyboardButton( nav_buttons.append(InlineKeyboardButton(
text="➡️ Далее", text="➡️ Далее",
callback_data=f"admin_users_search_page:{query}:2" callback_data=f"admin_users_search_page:{escape(query)}:2"
)) ))
buttons.append(nav_buttons) buttons.append(nav_buttons)

View File

@@ -1,6 +1,7 @@
"""Independent cashier dialogs; staff assignment is restricted to super admins.""" """Independent cashier dialogs; staff assignment is restricted to super admins."""
from src.filters.dialog_input import NotCommand
from aiogram import F, Router 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.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message 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.core.services import LotteryService
from src.handlers.account_services import AccountParticipationService from src.handlers.account_services import AccountParticipationService
from src.middlewares.access import AccessMiddleware 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 = Router(name="cashier")
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True)) 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.") 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): 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: if not accounts or len(accounts) > 1000:
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.") await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
return return

View File

@@ -1,4 +1,5 @@
"""Обработчики пользовательских сообщений в чате""" """Обработчики пользовательских сообщений в чате"""
from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error from src.utils.errors import public_error
from src.core.access import is_admin from src.core.access import is_admin
from src.utils.delivery import background_delivery 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): async def exit_chat_command(message: Message, state: FSMContext):
"""Выйти из режима чата через команду /exit (регистронезависимо)""" """Выйти из режима чата через команду /exit (регистронезависимо)"""
await exit_chat(message, state) 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): async def check_exit_keywords(message: Message, state: FSMContext):
"""Проверка на ключевые слова для выхода из чата + обработка сообщений""" """Проверка на ключевые слова для выхода из чата + обработка сообщений"""
import logging import logging
@@ -156,15 +157,8 @@ async def check_exit_keywords(message: Message, state: FSMContext):
# Выходим из чата и показываем главное меню # Выходим из чата и показываем главное меню
await state.clear() await state.clear()
from src.components.ui import UserUI from main import cmd_start
keyboard = UserUI.get_main_menu_keyboard(message.from_user.id) await cmd_start(message, state)
await message.answer(
"🏠 <b>Главное меню</b>\n\n"
"Вы вышли из режима чата.",
reply_markup=keyboard,
parse_mode="HTML"
)
return # Не обрабатываем дальше return # Не обрабатываем дальше
else: else:
# Для /exit просто выходим # Для /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): 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("✅ Фото переслано в канал") 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): 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("✅ Видео переслано в канал") 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): 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("✅ Документ переслан в канал") 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): async def handle_animation_message(message: Message, state: FSMContext):
"""Обработчик GIF анимаций""" """Обработчик GIF анимаций"""
# Защита от дубликатов # Защита от дубликатов
@@ -815,7 +809,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
await message.answer("✅ Анимация переслана в канал") 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): async def handle_sticker_message(message: Message, state: FSMContext):
"""Обработчик стикеров""" """Обработчик стикеров"""
# Защита от дубликатов # Защита от дубликатов

View File

@@ -2,7 +2,7 @@
from src.core.access import is_admin from src.core.access import is_admin
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton 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.core.config import ADMIN_IDS
from src.filters.case_insensitive import CaseInsensitiveCommand from src.filters.case_insensitive import CaseInsensitiveCommand

View File

@@ -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 — регистрация. Для переписки сначала откройте чат.")

View File

@@ -6,7 +6,8 @@ from src.core.access import is_admin
import logging import logging
from aiogram import Router, F, Bot from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery 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 src.filters.case_insensitive import CaseInsensitiveCommand
from ..core.config import ADMIN_IDS 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: if not message.text:
return False return False
text_lower = message.text.lower().strip() text_lower = message.text.lower().strip()
triggers = ["удалить", "delete", "del", "🗑️", "🗑", ""] 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): async def quick_delete_replied_message(message: Message):
""" """
Быстрое удаление сообщения по reply с триггерными словами или emoji Быстрое удаление сообщения по reply с триггерными словами или emoji

View File

@@ -2,7 +2,7 @@
from pathlib import Path from pathlib import Path
from aiogram import F, Router 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 aiogram.types import CallbackQuery, FSInputFile, Message
from src.core.access import get_role 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")) @operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
async def help_command(message: Message): 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) await send_guide(message, kind)

View File

@@ -1,10 +1,12 @@
"""Обработчики P2P чата между пользователями""" """Обработчики P2P чата между пользователями"""
from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error from src.utils.errors import public_error
from src.utils.telegram_messages import copy_preserving_entities from src.utils.telegram_messages import copy_preserving_entities
from html import escape from html import escape
from src.core.access import is_admin from src.core.access import is_admin
from aiogram import Router, F 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 aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from src.filters.case_insensitive import CaseInsensitiveCommand from src.filters.case_insensitive import CaseInsensitiveCommand
@@ -356,7 +358,7 @@ async def back_to_menu(callback: CallbackQuery, state: FSMContext):
# Обработчик сообщений в состоянии chatting # Обработчик сообщений в состоянии 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): async def handle_p2p_message(message: Message, state: FSMContext):
"""Обработка P2P сообщения от пользователя""" """Обработка P2P сообщения от пользователя"""
import logging import logging

View File

@@ -1,8 +1,9 @@
"""Команды для повторного розыгрыша неподтвержденных выигрышей""" """Команды для повторного розыгрыша неподтвержденных выигрышей"""
from src.utils.input_validation import numeric_id
from src.utils.errors import public_error from src.utils.errors import public_error
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup 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 sqlalchemy import select, and_
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import random import random
@@ -36,7 +37,7 @@ async def check_unclaimed_winners(message: Message):
return return
try: try:
lottery_id = int(parts[1]) lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
except ValueError: except ValueError:
await message.answer("❌ lottery_id должен быть числом") await message.answer("❌ lottery_id должен быть числом")
return return
@@ -132,7 +133,11 @@ async def redraw_lottery(message: Message):
if len(parts) != 2 or not parts[1].isdigit(): if len(parts) != 2 or not parts[1].isdigit():
await message.answer("Формат: /redraw ID_РОЗЫГРЫША") await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
return 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: async with async_session_maker() as session:
winners = await redraw_unclaimed(session, lottery_id) winners = await redraw_unclaimed(session, lottery_id)
if winners: if winners:

View File

@@ -1,8 +1,11 @@
"""Обработчики для регистрации пользователей""" """Обработчики для регистрации пользователей"""
from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error from src.utils.errors import public_error
from src.utils.input_validation import club_card, phone_number
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup 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 import select
from sqlalchemy.orm import selectinload 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): async def process_nickname(message: Message, state: FSMContext):
"""Обработка никнейма""" """Обработка никнейма"""
nickname = message.text.strip() nickname = message.text.strip()
@@ -110,62 +113,59 @@ async def process_nickname(message: Message, state: FSMContext):
# Сохраняем никнейм # Сохраняем никнейм
await state.update_data(nickname=nickname) await state.update_data(nickname=nickname)
await state.set_state(RegistrationStates.waiting_for_club_card)
await message.answer( await message.answer(
f"✅ Отлично! Ваш никнейм: {nickname}\n\n" f"✅ Отлично! Ваш никнейм: {nickname}\n\n"
"Шаг 2 из 3: Клубная карта\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): 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: async with async_session_maker() as session:
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number) 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( await message.answer(
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n" f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
"Если это ваша карта, обратитесь к администратору." "Если это ваша карта, обратитесь к администратору."
) )
await state.clear()
return return
await state.update_data(club_card_number=club_card_number) await state.update_data(club_card_number=club_card_number)
await state.set_state(RegistrationStates.waiting_for_phone)
await message.answer( await message.answer(
"Шаг 3 из 3: Телефон\n\n" "Шаг 3 из 3: Телефон\n\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): async def process_phone(message: Message, state: FSMContext):
"""Обработка номера телефона""" """Обработка номера телефона"""
phone_input = message.text.strip() try:
phone = phone_number(message.text)
# Проверяем, не отправил ли пользователь просто "-" except ValueError as error:
if phone_input == "-": await message.answer(f"{error}")
phone = None return
else:
# Валидируем телефон: не должно быть пустых или некорректных значений
if not phone_input:
await message.answer(
"❌ Неверный номер телефона.\n\n"
"Пожалуйста, введите корректный номер или отправьте '-' чтобы пропустить."
)
return
phone = phone_input
data = await state.get_data() data = await state.get_data()
club_card_number = data['club_card_number'] club_card_number = data.get('club_card_number')
nickname = data.get('nickname') nickname = data.get('nickname')
if not club_card_number or not nickname:
await begin_registration(message, state)
return
try: try:
async with async_session_maker() as session: async with async_session_maker() as session:
@@ -173,16 +173,22 @@ async def process_phone(message: Message, state: FSMContext):
session, session,
telegram_id=message.from_user.id, telegram_id=message.from_user.id,
club_card_number=club_card_number, club_card_number=club_card_number,
phone=phone phone=phone,
nickname=nickname,
) )
except ValueError as error:
# Обновляем никнейм пользователя # The card may have been claimed after step 2, while this user entered a phone.
if nickname: await state.set_state(RegistrationStates.waiting_for_club_card)
user.nickname = nickname await message.answer(f"{error}\nВведите номер клубной карты ещё раз или /cancel.")
await session.commit() return
await session.refresh(user) except Exception as error:
logger.exception("Registration persistence failed")
text = ( 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" "✅ Регистрация завершена!\n\n"
f"🎭 Никнейм: {user.nickname}\n" f"🎭 Никнейм: {user.nickname}\n"
f"🎫 Клубная карта: {user.club_card_number}\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 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()
@router.message(Command("my_code")) @router.message(Command("my_code"))

View File

@@ -1,9 +1,10 @@
"""One staff-management dialog, restricted at every step to system admins.""" """One staff-management dialog, restricted at every step to system admins."""
from src.filters.dialog_input import NotCommand
from html import escape from html import escape
import secrets import secrets
from aiogram import BaseMiddleware, F, Router 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.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message 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) "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): async def staff_target(message: Message, state: FSMContext):
try: try:
target_id = parse_target(message.text.strip()) target_id = parse_target(message.text.strip())
@@ -154,7 +155,7 @@ async def confirm_role(callback: CallbackQuery, state: FSMContext):
"Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard()) "Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
@staff_router.message(StaffStates.confirm) @staff_router.message(StaffStates.confirm, NotCommand())
async def confirmation_pending(message: Message): async def confirmation_pending(message: Message):
await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.") await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")

View File

@@ -0,0 +1,54 @@
"""Parse each account record independently, preserving card/account associations."""
import re
ACCOUNT = re.compile(r"(?<![\w-])(?:[0-9]{2}(?:[- ][0-9]{2}){6}|[0-9]{14})(?![\w-])")
CARD = re.compile(r"[0-9]{1,50}")
def parse_account_records(text):
from .account_utils import format_account_number
lines = [line.strip() for line in text.splitlines() if line.strip()]
records = []
if any(line.lower().startswith("viposnova") for line in lines):
current = []
for line in lines:
if line.lower().startswith("viposnova") and current:
records.append(" ".join(current))
current = []
current.append(line)
if current:
records.append(" ".join(current))
else:
i = 0
while i < len(lines):
record = lines[i]
# A bare account followed by a card on the next line is one record.
if ACCOUNT.fullmatch(record) and i + 1 < len(lines) and CARD.fullmatch(lines[i + 1]) and not ACCOUNT.fullmatch(lines[i + 1]):
record += " " + lines[i + 1]
i += 1
records.append(record)
i += 1
entries, errors = [], []
for index, record in enumerate(records, 1):
matches = list(ACCOUNT.finditer(record))
if not matches:
errors.append(f"Запись {index}: номер счёта не распознан; нужно 14 цифр.")
continue
for match in matches:
account = format_account_number(match.group())
card = None
if len(matches) == 1:
prefix = record[:match.start()].strip()
suffix = record[match.end():].strip()
if CARD.fullmatch(prefix):
card = prefix
elif CARD.fullmatch(suffix):
card = suffix
elif record.lower().startswith("viposnova"):
candidates = [part for part in suffix.split() if CARD.fullmatch(part)]
card = candidates[-1] if candidates else None
entry = f"{card} {account}" if card else account
if entry not in entries:
entries.append(entry)
return entries, errors

View File

@@ -98,109 +98,9 @@ def mask_account_number(account_number: str, show_last_digits: int = 4) -> str:
def parse_accounts_from_message(text: str) -> List[str]: def parse_accounts_from_message(text: str) -> List[str]:
""" """Return all recognized records, preserving card numbers and leading zeroes."""
Извлекает все валидные номера счетов из текста сообщения. from .account_input import parse_account_records
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77") return parse_account_records(text or "")[0]
или просто "СЧЕТ" (например "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
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]: def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:

View File

@@ -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("Неизвестное поле розыгрыша.")

12
src/utils/text_output.py Normal file
View File

@@ -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)

280
tests/test_text_inputs.py Normal file
View File

@@ -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, " ", ""]:
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, "<invalid>"]:
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="<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 <reason>")
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("&lt;reason&gt;" 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