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),
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
[Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md).
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.

View File

@@ -14,6 +14,7 @@
## Работа с пользователями
1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
Карта вводится цифрами; допустимы и три, и четыре цифры, ведущие нули сохраняются. Если карта заранее привязана к этому же клиенту, он может завершить регистрацию с ней. При ошибке номера или телефона можно повторить ввод на текущем шаге.
2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
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.types import Message, CallbackQuery
from aiogram.filters import Command
from aiogram.filters import Command, StateFilter
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
from aiogram.client.session.aiohttp import AiohttpSession
@@ -164,7 +164,7 @@ async def cmd_register(message: Message, state: FSMContext):
await btn_registration(message, state)
@router.message(F.text.lower().in_(["регистрация", "регистр", "register"]))
@router.message(StateFilter(None), F.text.lower().in_(["регистрация", "регистр", "register"]))
async def text_registration(message: Message, state: FSMContext):
"""Обработчик текста для регистрации"""
await btn_registration(message, state)
@@ -192,9 +192,9 @@ async def btn_help(message: Message):
@router.message(F.text == "⚙️ Админ панель")
async def btn_admin(message: Message):
async def btn_admin(message: Message, state: FSMContext):
"""Обработчик кнопки 'Админ панель'"""
await cmd_admin(message)
await cmd_admin(message, state)
@router.message(F.text == "🚪 Выйти из чата")
@@ -211,7 +211,7 @@ async def btn_main_menu(message: Message, state: FSMContext):
@router.message(CaseInsensitiveCommand("admin"))
async def cmd_admin(message: Message):
async def cmd_admin(message: Message, state: FSMContext):
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
from src.core.config import ADMIN_IDS
from src.core.database import async_session_maker
@@ -238,6 +238,7 @@ async def cmd_admin(message: Message):
return
# Отправляем сообщение с кнопкой админ панели
await state.clear()
from src.components.ui import KeyboardBuilderImpl
kb = KeyboardBuilderImpl()
keyboard = kb.get_admin_keyboard()
@@ -329,6 +330,8 @@ def configure_dispatcher():
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
from src.handlers.input_fallback import input_fallback_router
dp.include_router(input_fallback_router)

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ class UserManagementService:
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
# Если запрос - число, ищем по telegram_id
if query.isdigit():
if query.isascii() and query.isdigit() and len(query) <= 19 and 0 < int(query) < 2**63:
search_conditions.append(User.telegram_id == int(query))
conditions.append(or_(*search_conditions))

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):
return
from aiogram.dispatcher.event.bases import SkipHandler
raise SkipHandler()
# Парсим счета из сообщения
accounts = parse_accounts_from_message(message.text)
if not accounts:
return # Счета не обнаружены, пропускаем
from aiogram.dispatcher.event.bases import SkipHandler
raise SkipHandler()
# Извлекаем номера клубных карт и определяем владельцев
from ..core.services import UserService

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

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 aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from sqlalchemy.ext.asyncio import AsyncSession
from src.filters.case_insensitive import CaseInsensitiveCommand
@@ -20,6 +22,23 @@ from src.core.permissions import admin_only
router = Router(name='admin_chat_router')
async def reply_target(message: Message):
"""Resolve the human author of a bot copy only within the operator's dialog."""
replied = message.reply_to_message
if replied.from_user and not replied.from_user.is_bot:
return replied.from_user.id
from sqlalchemy import select
from src.core.models import User, P2PMessage
async with async_session_maker() as session:
original = await ChatMessageService.get_message_by_telegram_id(
session, replied.message_id, chat_id=message.chat.id)
if original:
return await session.scalar(select(User.telegram_id).where(User.id == original.user_id))
operator_id = select(User.id).where(User.telegram_id == message.from_user.id).scalar_subquery()
return await session.scalar(select(User.telegram_id).join(P2PMessage, P2PMessage.sender_id == User.id).where(
P2PMessage.recipient_id == operator_id, P2PMessage.recipient_message_id == replied.message_id))
def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура выбора режима чата"""
return InlineKeyboardMarkup(inline_keyboard=[
@@ -89,7 +108,11 @@ async def cmd_set_forward(message: Message):
)
return
chat_id = args[1].strip()
try:
chat_id = str(numeric_id(args[1], signed=True))
except ValueError as error:
await message.answer(str(error))
return
async with async_session_maker() as session:
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
@@ -135,7 +158,10 @@ async def cmd_ban(message: Message):
# Проверяем является ли это ответом на сообщение
if message.reply_to_message:
target_user_id = message.reply_to_message.from_user.id
target_user_id = await reply_target(message)
if target_user_id is None:
await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
return
reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
else:
args = message.text.split(maxsplit=2)
@@ -150,7 +176,7 @@ async def cmd_ban(message: Message):
return
try:
target_user_id = int(args[1])
target_user_id = numeric_id(args[1])
reason = args[2] if len(args) > 2 else None
except ValueError:
await message.answer("❌ Неверный ID пользователя")
@@ -182,11 +208,11 @@ async def cmd_ban(message: Message):
reason=reason
)
reason_text = f"\n📝 Причина: {reason}" if reason else ""
reason_text = f"\n📝 Причина: {escape(reason[:500])}" if reason else ""
await message.answer(
f"🚫 <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"{reason_text}",
parse_mode="HTML"
@@ -200,7 +226,10 @@ async def cmd_unban(message: Message):
# Проверяем является ли это ответом на сообщение
if message.reply_to_message:
target_user_id = message.reply_to_message.from_user.id
target_user_id = await reply_target(message)
if target_user_id is None:
await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
return
else:
args = message.text.split()
if len(args) < 2:
@@ -214,7 +243,7 @@ async def cmd_unban(message: Message):
return
try:
target_user_id = int(args[1])
target_user_id = numeric_id(args[1])
except ValueError:
await message.answer("❌ Неверный ID пользователя")
return
@@ -252,11 +281,11 @@ async def cmd_banlist(message: Message):
user = ban.user
admin = ban.admin
text += f"👤 {user.name or 'Неизвестен'} (<code>{ban.telegram_id}</code>)\n"
text += f"🔨 Забанил: {admin.name if admin else 'Неизвестен'}\n"
text += f"👤 {escape(user.nickname or user.first_name or 'Неизвестен')} (<code>{ban.telegram_id}</code>)\n"
text += f"🔨 Забанил: {escape((admin.nickname or admin.first_name or 'Неизвестен') if admin else 'Неизвестен')}\n"
if ban.reason:
text += f"📝 Причина: {ban.reason}\n"
text += f"📝 Причина: {escape(ban.reason[:500])}\n"
text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
text += "\n"

View File

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

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.core.access import is_admin
from src.core.access import is_admin as check_admin_access
@@ -180,11 +184,12 @@ def get_winner_management_keyboard() -> InlineKeyboardMarkup:
@admin_router.callback_query(F.data == "admin_panel")
async def show_admin_panel(callback: CallbackQuery):
async def show_admin_panel(callback: CallbackQuery, state: FSMContext):
"""Показать админ-панель"""
if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
await state.clear()
async with async_session_maker() as session:
# Быстрая статистика
@@ -215,11 +220,12 @@ async def show_admin_panel(callback: CallbackQuery):
# ======================
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
async def show_lottery_management(callback: CallbackQuery):
async def show_lottery_management(callback: CallbackQuery, state: FSMContext):
"""Управление розыгрышами"""
if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
await state.clear()
text = "🎲 Управление розыгрышами\n\n"
text += "Здесь вы можете создавать, редактировать и управлять розыгрышами.\n\n"
@@ -243,6 +249,7 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
logging.info(f"✅ Админ {callback.from_user.id} начинает создание розыгрыша")
await state.clear()
text = "📝 Создание нового розыгрыша\n\n"
text += "Шаг 1 из 4\n\n"
text += "Введите название розыгрыша:"
@@ -261,13 +268,19 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
@admin_router.message(StateFilter(AdminStates.lottery_title))
@admin_router.message(StateFilter(AdminStates.lottery_title), F.text, NotCommand())
async def process_lottery_title(message: Message, state: FSMContext):
"""Обработка названия розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
try:
value = lottery_field("title", message.text)
except ValueError as error:
await message.answer(str(error))
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
@@ -277,51 +290,49 @@ async def process_lottery_title(message: Message, state: FSMContext):
success = await LotteryService.update_lottery(
session,
edit_lottery_id,
title=message.text
title=value
)
if success:
await message.answer(f"✅ Название изменено на: {message.text}")
await message.answer(f"✅ Название изменено на: {value}")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
await show_edit_fields(message, edit_lottery_id)
else:
await message.answer("❌ Ошибка при изменении названия")
return
# Если это создание нового розыгрыша
await state.update_data(title=message.text)
await state.update_data(title=value)
text = f"📝 Создание нового розыгрыша\n\n"
text += f"Шаг 2 из 4\n\n"
text += f"✅ Название: {message.text}\n\n"
text += f"✅ Название: {value}\n\n"
text += f"Введите описание розыгрыша (или '-' для пропуска):"
await message.answer(text)
await answer_plain_pages(message, text)
await state.set_state(AdminStates.lottery_description)
@admin_router.message(StateFilter(AdminStates.lottery_description))
@admin_router.message(StateFilter(AdminStates.lottery_description), F.text, NotCommand())
async def process_lottery_description(message: Message, state: FSMContext):
"""Обработка описания розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
try:
value = lottery_field("description", message.text)
except ValueError as error:
await message.answer(str(error))
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
description = None if message.text == "-" else message.text
description = value
async with async_session_maker() as session:
success = await LotteryService.update_lottery(
session,
@@ -333,21 +344,13 @@ async def process_lottery_description(message: Message, state: FSMContext):
await message.answer(f"✅ Описание изменено")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
await show_edit_fields(message, edit_lottery_id)
else:
await message.answer("❌ Ошибка при изменении описания")
return
# Если это создание нового розыгрыша
description = None if message.text == "-" else message.text
description = value
await state.update_data(description=description)
data = await state.get_data()
@@ -363,21 +366,27 @@ async def process_lottery_description(message: Message, state: FSMContext):
text += f"🥉 AirPods Pro\n"
text += f"🏆 10,000 рублей"
await message.answer(text)
await answer_plain_pages(message, text)
await state.set_state(AdminStates.lottery_prizes)
@admin_router.message(StateFilter(AdminStates.lottery_prizes))
@admin_router.message(StateFilter(AdminStates.lottery_prizes), F.text, NotCommand())
async def process_lottery_prizes(message: Message, state: FSMContext):
"""Обработка призов розыгрыша (создание или редактирование)"""
if not await check_admin_access(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
try:
value = lottery_field("prizes", message.text)
except ValueError as error:
await message.answer(str(error))
return
data = await state.get_data()
edit_lottery_id = data.get('edit_lottery_id')
prizes = [prize.strip() for prize in message.text.split('\n') if prize.strip()]
prizes = value
# Если это редактирование существующего розыгрыша
if edit_lottery_id:
@@ -392,15 +401,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
await message.answer(f"✅ Призы изменены")
await state.clear()
# Возвращаемся к выбору полей
from aiogram.types import CallbackQuery
fake_callback = CallbackQuery(
id="fake",
from_user=message.from_user,
chat_instance="fake",
data=f"admin_edit_lottery_select_{edit_lottery_id}",
message=message
)
await choose_edit_field(fake_callback, state)
await show_edit_fields(message, edit_lottery_id)
else:
await message.answer("❌ Ошибка при изменении призов")
return
@@ -420,7 +421,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
text += f"\n✅ Подтвердите создание розыгрыша:"
await message.answer(
await answer_plain_pages(message,
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Создать", callback_data="confirm_create_lottery")],
@@ -765,7 +766,7 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.add_participant_user)
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text)
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text, NotCommand())
async def process_add_participant(message: Message, state: FSMContext):
"""Обработка добавления участника"""
if not await check_admin_access(message.from_user.id):
@@ -886,7 +887,7 @@ async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMC
)
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text)
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text, NotCommand())
async def process_remove_participant(message: Message, state: FSMContext):
"""Обработка удаления участника"""
if not await check_admin_access(message.from_user.id):
@@ -1161,7 +1162,7 @@ async def start_search_participants(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.participant_search)
@admin_router.message(StateFilter(AdminStates.participant_search), F.text)
@admin_router.message(StateFilter(AdminStates.participant_search), F.text, NotCommand())
async def process_search_participants(message: Message, state: FSMContext):
"""Обработка поиска участников"""
if not await check_admin_access(message.from_user.id):
@@ -1169,6 +1170,9 @@ async def process_search_participants(message: Message, state: FSMContext):
return
search_term = message.text.strip()
if not 1 <= len(search_term) <= 100:
await message.answer("Поисковый запрос должен содержать от 1 до 100 символов. /cancel — отмена.")
return
async with async_session_maker() as session:
users = await UserService.search_users(session, search_term)
@@ -1279,7 +1283,7 @@ async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.add_participant_bulk)
@admin_router.message(StateFilter(AdminStates.add_participant_bulk))
@admin_router.message(StateFilter(AdminStates.add_participant_bulk), F.text, NotCommand())
async def process_bulk_add_participant(message: Message, state: FSMContext):
"""Обработка массового добавления участников"""
if not await check_admin_access(message.from_user.id):
@@ -1290,7 +1294,10 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
lottery_id = data['bulk_add_lottery_id']
# Парсим входные данные
user_inputs = [x.strip() for x in message.text.split(',') if x.strip()]
user_inputs = [x.strip() for x in message.text.replace('\n', ',').split(',') if x.strip()]
if not user_inputs:
await message.answer('Введите хотя бы один Telegram ID или @username, либо /cancel.')
return
telegram_ids = []
async with async_session_maker() as session:
@@ -1302,11 +1309,14 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
if user:
telegram_ids.append(user.telegram_id)
elif user_input.isdigit():
telegram_ids.append(int(user_input))
telegram_ids.append(numeric_id(user_input))
except:
continue
# Массовое добавление
if not telegram_ids or len(telegram_ids) != len(user_inputs):
await message.answer("Часть ID или username неверна либо не найдена. Исправьте список и повторите ввод. Изменения не внесены.")
return
results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id)
@@ -1410,7 +1420,7 @@ async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.remove_participant_bulk)
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk))
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk), F.text, NotCommand())
async def process_bulk_remove_participant(message: Message, state: FSMContext):
"""Обработка массового удаления участников"""
if not await check_admin_access(message.from_user.id):
@@ -1421,7 +1431,10 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
lottery_id = data['bulk_remove_lottery_id']
# Парсим входные данные
user_inputs = [x.strip() for x in message.text.split(',') if x.strip()]
user_inputs = [x.strip() for x in message.text.replace('\n', ',').split(',') if x.strip()]
if not user_inputs:
await message.answer('Введите хотя бы один Telegram ID или @username, либо /cancel.')
return
telegram_ids = []
async with async_session_maker() as session:
@@ -1433,11 +1446,14 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
if user:
telegram_ids.append(user.telegram_id)
elif user_input.isdigit():
telegram_ids.append(int(user_input))
telegram_ids.append(numeric_id(user_input))
except:
continue
# Массовое удаление
if not telegram_ids or len(telegram_ids) != len(user_inputs):
await message.answer("Часть ID или username неверна либо не найдена. Исправьте список и повторите ввод. Изменения не внесены.")
return
results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id)
@@ -1501,7 +1517,7 @@ async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext)
await state.set_state(AdminStates.add_to_lottery_user)
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text)
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text, NotCommand())
async def process_add_to_lottery(message: Message, state: FSMContext):
"""Обработка добавления участника в конкретный розыгрыш"""
if not await is_admin(message.from_user.id):
@@ -1637,7 +1653,7 @@ async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMCon
await state.set_state(AdminStates.remove_from_lottery_user)
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text)
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text, NotCommand())
async def process_remove_from_lottery(message: Message, state: FSMContext):
"""Обработка удаления участника из конкретного розыгрыша"""
if not await is_admin(message.from_user.id):
@@ -2002,7 +2018,7 @@ async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.add_participant_bulk_accounts)
@admin_router.message(StateFilter(AdminStates.add_participant_bulk_accounts))
@admin_router.message(StateFilter(AdminStates.add_participant_bulk_accounts), F.text, NotCommand())
async def process_bulk_add_accounts(message: Message, state: FSMContext):
"""Обработка массового добавления участников по номерам счетов"""
if not await check_admin_access(message.from_user.id):
@@ -2015,6 +2031,9 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext):
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text)
if not account_inputs:
await message.answer("Счета не распознаны. Используйте формат 11-22-33-44-55-66-77 или /cancel.")
return
async with async_session_maker() as session:
# Массовое добавление по номерам счетов
@@ -2127,7 +2146,7 @@ async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext
await state.set_state(AdminStates.remove_participant_bulk_accounts)
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk_accounts))
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk_accounts), F.text, NotCommand())
async def process_bulk_remove_accounts(message: Message, state: FSMContext):
"""Обработка массового удаления участников по номерам счетов"""
if not await check_admin_access(message.from_user.id):
@@ -2140,6 +2159,9 @@ async def process_bulk_remove_accounts(message: Message, state: FSMContext):
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
from ..utils.account_utils import parse_accounts_from_message
account_inputs = parse_accounts_from_message(message.text)
if not account_inputs:
await message.answer("Счета не распознаны. Используйте формат 11-22-33-44-55-66-77 или /cancel.")
return
async with async_session_maker() as session:
# Массовое удаление по номерам счетов
@@ -2408,13 +2430,23 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(edit_lottery_id=lottery_id)
await state.set_state(None)
await callback.answer()
await show_edit_fields(callback.message, lottery_id, edit=True)
async def show_edit_fields(message: Message, lottery_id: int, edit=False):
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if lottery is None:
await message.answer("Розыгрыш не найден. Откройте /admin.")
return
text = f"📝 Редактирование: {lottery.title}\n\n"
text += "Выберите, что хотите изменить:\n\n"
text += f"📝 Название: {lottery.title}\n"
text += f"📄 Описание: {lottery.description[:50]}{'...' if len(lottery.description) > 50 else ''}\n"
text += f"📄 Описание: {(lottery.description or "")[:50]}{'...' if len(lottery.description or "") > 50 else ''}\n"
text += f"🎁 Призы: {len(getattr(lottery, 'prizes', []))} шт.\n"
text += f"🎭 Отображение: {getattr(lottery, 'winner_display_type', 'username')}\n"
text += f"🟢 Активен: {'Да' if getattr(lottery, 'is_active', True) else 'Нет'}"
@@ -2431,7 +2463,7 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_edit_lottery")]
]
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
await (message.edit_text if edit else message.answer)(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_toggle_active_"))
@@ -2776,7 +2808,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.set_winner_place)
@admin_router.message(StateFilter(AdminStates.set_winner_place))
@admin_router.message(StateFilter(AdminStates.set_winner_place), F.text, NotCommand())
async def process_winner_place(message: Message, state: FSMContext):
"""Обработка места победителя"""
if not await check_admin_access(message.from_user.id):
@@ -2784,7 +2816,7 @@ async def process_winner_place(message: Message, state: FSMContext):
return
try:
place = int(message.text)
place = numeric_id(message.text, maximum=2**31 - 1)
if place < 1:
raise ValueError
except ValueError:
@@ -2798,6 +2830,9 @@ async def process_winner_place(message: Message, state: FSMContext):
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery or place > len(lottery.prizes or []):
await message.answer("Номер места должен соответствовать одному из призов розыгрыша.")
return
if lottery.manual_winners and str(place) in lottery.manual_winners:
existing_id = lottery.manual_winners[str(place)]
existing_user = await UserService.get_user_by_telegram_id(session, existing_id)
@@ -2824,7 +2859,7 @@ async def process_winner_place(message: Message, state: FSMContext):
await state.set_state(AdminStates.set_winner_user)
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text)
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text, NotCommand())
async def process_winner_user(message: Message, state: FSMContext):
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
if not await check_admin_access(message.from_user.id):
@@ -4397,7 +4432,7 @@ async def admin_import_users_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.import_users_json)
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document, NotCommand())
async def admin_import_users_process(message: Message, state: FSMContext):
if not await check_admin_access(message.from_user.id):
return
@@ -4644,7 +4679,7 @@ BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть со
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker)
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker, NotCommand())
async def admin_broadcast_send(message: Message, state: FSMContext):
"""Обработка и отправка рассылки"""
if not await check_admin_access(message.from_user.id):
@@ -4876,14 +4911,14 @@ async def admin_broadcast_add_channel_start(callback: CallbackQuery, state: FSMC
await state.set_state(AdminStates.broadcast_add_channel_id)
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_id), F.text)
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_id), F.text, NotCommand())
async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
"""Обработка ID канала"""
if not await check_admin_access(message.from_user.id):
return
try:
chat_id = int(message.text.strip())
chat_id = numeric_id(message.text, signed=True)
except ValueError:
await message.answer(
"❌ Неверный формат ID. Отправьте число, например: -1001234567890"
@@ -4903,7 +4938,6 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
await message.answer(
"❌ Неверный тип чата. Поддерживаются только каналы и группы."
)
await state.clear()
return
# Сохраняем данные
@@ -4917,7 +4951,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
# Запрашиваем описание
text = (
f"✅ <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>Тип:</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)}",
parse_mode="HTML"
)
await state.clear()
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_title), F.text)
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_title), F.text, NotCommand("skip"))
async def admin_broadcast_add_channel_description(message: Message, state: FSMContext):
"""Обработка описания канала"""
if not await check_admin_access(message.from_user.id):
@@ -4950,7 +4983,7 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
data = await state.get_data()
description = None if message.text.strip() == '/skip' else message.text.strip()
description = None if message.text.strip().split('@', 1)[0].lower() == '/skip' else message.text.strip()
# Сохраняем в БД
async with async_session_maker() as session:
@@ -4977,10 +5010,11 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
existing.description = description
existing.chat_type = data['chat_type']
await session.commit()
await state.clear()
await message.answer(
"✅ <b>Канал обновлен!</b>\n\n"
f"📱 {data['title']}",
f"📱 {escape(data['title'])}",
parse_mode="HTML"
)
else:
@@ -4995,10 +5029,11 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
)
session.add(channel)
await session.commit()
await state.clear()
await message.answer(
"✅ <b>Канал добавлен!</b>\n\n"
f"📱 {data['title']}\n"
f"📱 {escape(data['title'])}\n"
f"Теперь вы можете использовать его для рассылок.",
parse_mode="HTML"
)
@@ -5209,13 +5244,16 @@ async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.user_management_search)
@admin_router.message(AdminStates.user_management_search, F.text)
@admin_router.message(AdminStates.user_management_search, F.text, NotCommand())
async def admin_users_search_process(message: Message, state: FSMContext):
"""Обработка поискового запроса"""
if not await check_admin_access(message.from_user.id):
return
query = message.text.strip()
if not 1 <= len(query) <= 100:
await message.answer("Поисковый запрос должен содержать от 1 до 100 символов. /cancel — отмена.")
return
if query == "/cancel":
await message.answer("❌ Поиск отменен")
@@ -5233,12 +5271,12 @@ async def admin_users_search_process(message: Message, state: FSMContext):
)
if not users:
text = f"❌ По запросу «{query}» ничего не найдено"
text = f"❌ По запросу «{escape(query)}» ничего не найдено"
buttons = [
[InlineKeyboardButton(text="◀️ В управление пользователями", callback_data="admin_users")]
]
else:
text = f"🔍 <b>Результаты поиска:</b> «{query}»\n"
text = f"🔍 <b>Результаты поиска:</b> «{escape(query)}»\n"
text += f"Найдено: {total} пользователей\n\n"
buttons = []
@@ -5262,7 +5300,7 @@ async def admin_users_search_process(message: Message, state: FSMContext):
if total > 15:
nav_buttons.append(InlineKeyboardButton(
text="➡️ Далее",
callback_data=f"admin_users_search_page:{query}:2"
callback_data=f"admin_users_search_page:{escape(query)}:2"
))
buttons.append(nav_buttons)

View File

@@ -1,6 +1,7 @@
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
from src.filters.dialog_input import NotCommand
from aiogram import F, Router
from aiogram.filters import Command
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
@@ -10,7 +11,7 @@ from src.core.database import async_session_maker
from src.core.services import LotteryService
from src.handlers.account_services import AccountParticipationService
from src.middlewares.access import AccessMiddleware
from src.utils.account_utils import parse_accounts_from_message
from src.utils.account_input import parse_account_records
cashier_router = Router(name="cashier")
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
@@ -58,9 +59,12 @@ async def choose_draw(callback: CallbackQuery, state: FSMContext):
await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.")
@cashier_router.message(CashierStates.accounts, F.text)
@cashier_router.message(CashierStates.accounts, F.text, NotCommand())
async def add_accounts(message: Message, state: FSMContext):
accounts = parse_accounts_from_message(message.text)
accounts, errors = parse_account_records(message.text)
if errors:
await message.answer("Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
return
if not accounts or len(accounts) > 1000:
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
return

View File

@@ -1,4 +1,5 @@
"""Обработчики пользовательских сообщений в чате"""
from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error
from src.core.access import is_admin
from src.utils.delivery import background_delivery
@@ -90,7 +91,7 @@ async def enter_chat(message: Message, state: FSMContext):
)
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat))
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat), NotCommand())
async def exit_chat_command(message: Message, state: FSMContext):
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
await exit_chat(message, state)
@@ -140,7 +141,7 @@ async def exit_chat(message: Message, state: FSMContext, actor=None):
)
@router.message(StateFilter(ChatStates.in_chat), F.text)
@router.message(StateFilter(ChatStates.in_chat), F.text, NotCommand())
async def check_exit_keywords(message: Message, state: FSMContext):
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
import logging
@@ -156,15 +157,8 @@ async def check_exit_keywords(message: Message, state: FSMContext):
# Выходим из чата и показываем главное меню
await state.clear()
from src.components.ui import UserUI
keyboard = UserUI.get_main_menu_keyboard(message.from_user.id)
await message.answer(
"🏠 <b>Главное меню</b>\n\n"
"Вы вышли из режима чата.",
reply_markup=keyboard,
parse_mode="HTML"
)
from main import cmd_start
await cmd_start(message, state)
return # Не обрабатываем дальше
else:
# Для /exit просто выходим
@@ -544,7 +538,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
@router.message(F.photo, StateFilter(ChatStates.in_chat))
@router.message(F.photo, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_photo_message(message: Message, state: FSMContext):
"""Обработчик фото"""
# Защита от дубликатов
@@ -614,7 +608,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
await message.answer("✅ Фото переслано в канал")
@router.message(F.video, StateFilter(ChatStates.in_chat))
@router.message(F.video, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_video_message(message: Message, state: FSMContext):
"""Обработчик видео"""
# Защита от дубликатов
@@ -681,7 +675,7 @@ async def handle_video_message(message: Message, state: FSMContext):
await message.answer("✅ Видео переслано в канал")
@router.message(F.document, StateFilter(ChatStates.in_chat))
@router.message(F.document, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_document_message(message: Message, state: FSMContext):
"""Обработчик документов"""
# Защита от дубликатов
@@ -748,7 +742,7 @@ async def handle_document_message(message: Message, state: FSMContext):
await message.answer("✅ Документ переслан в канал")
@router.message(F.animation, StateFilter(ChatStates.in_chat))
@router.message(F.animation, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_animation_message(message: Message, state: FSMContext):
"""Обработчик GIF анимаций"""
# Защита от дубликатов
@@ -815,7 +809,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
await message.answer("✅ Анимация переслана в канал")
@router.message(F.sticker, StateFilter(ChatStates.in_chat))
@router.message(F.sticker, StateFilter(ChatStates.in_chat), NotCommand())
async def handle_sticker_message(message: Message, state: FSMContext):
"""Обработчик стикеров"""
# Защита от дубликатов

View File

@@ -2,7 +2,7 @@
from src.core.access import is_admin
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from src.core.config import ADMIN_IDS
from src.filters.case_insensitive import CaseInsensitiveCommand

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
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from aiogram.filters import StateFilter
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from src.filters.case_insensitive import CaseInsensitiveCommand
from ..core.config import ADMIN_IDS
@@ -69,17 +70,17 @@ async def delete_message_callback(callback: CallbackQuery):
# Функция-фильтр для проверки триггерных слов
def is_delete_trigger(message: Message) -> bool:
async def is_delete_trigger(message: Message) -> bool:
"""Проверяет, является ли сообщение триггером для удаления"""
if not message.text:
return False
text_lower = message.text.lower().strip()
triggers = ["удалить", "delete", "del", "🗑️", "🗑", ""]
return any(trigger in text_lower for trigger in triggers)
return text_lower in triggers and await is_admin(message.from_user.id)
@message_admin_router.message(F.reply_to_message, is_delete_trigger)
@message_admin_router.message(F.reply_to_message, is_delete_trigger, StateFilter(None, "ChatStates:in_chat", "P2PChatStates:chatting"))
async def quick_delete_replied_message(message: Message):
"""
Быстрое удаление сообщения по reply с триггерными словами или emoji

View File

@@ -2,7 +2,7 @@
from pathlib import Path
from aiogram import F, Router
from aiogram.filters import Command
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
from aiogram.types import CallbackQuery, FSInputFile, Message
from src.core.access import get_role
@@ -34,7 +34,7 @@ async def send_guide(event, kind):
@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
async def help_command(message: Message):
kind = message.text.split()[0].split("@")[0].removeprefix("/").removesuffix("_help")
kind = message.text.split()[0].split("@")[0].lower().removeprefix("/").removesuffix("_help")
await send_guide(message, kind)

View File

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

View File

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

View File

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

View File

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

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]:
"""
Извлекает все валидные номера счетов из текста сообщения.
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
Также обрабатывает многострочный текст из кабинета:
Запись начинается со слова "Viposnova" и содержит несколько строк до следующего "Viposnova":
"Viposnova 16-11-2025 22:19:36
17-24-66-42-38-31-53
0.00 2918"
Args:
text: Текст сообщения
Returns:
List[str]: Список найденных строк (может включать номер карты и счета через пробел)
"""
if not text:
return []
accounts = []
# Группируем строки по записям (от "Viposnova" до следующего "Viposnova")
lines = text.strip().split('\n')
current_record = []
records = []
for line in lines:
stripped = line.strip()
# Если строка начинается с Viposnova и у нас уже есть текущая запись - сохраняем её
if stripped.startswith('Viposnova') and current_record:
records.append(' '.join(current_record))
current_record = [stripped]
else:
current_record.append(stripped)
# Добавляем последнюю запись
if current_record:
records.append(' '.join(current_record))
# Обрабатываем каждую запись
for record in records:
parts = record.split()
# Ищем счет в записи
account_number = None
account_idx = None
for i, part in enumerate(parts):
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
account_number = format_account_number(part)
account_idx = i
break
if not account_number or account_number in accounts:
continue
# Ищем клубную карту (4-значное число после счета)
card = None
if account_idx is not None:
for j in range(account_idx + 1, len(parts)):
if re.match(r'^\d{4}$', parts[j]):
card = parts[j]
break
# Добавляем результат
if card:
full_account = f"{card} {account_number}"
if full_account not in accounts:
accounts.append(full_account)
else:
accounts.append(account_number)
# Если построчная обработка ничего не нашла, используем старый метод
if not accounts:
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
# Находим все совпадения с картой и удаляем их из текста
text_copy = text
for match in re.finditer(pattern_with_card, text):
card = match.group(1)
account = match.group(2)
formatted = format_account_number(account)
if formatted:
full_account = f"{card} {formatted}"
if full_account not in accounts:
accounts.append(full_account)
# Удаляем это совпадение из копии текста, чтобы не найти повторно
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
matches_only = re.findall(pattern_only_account, text_copy)
for match in matches_only:
formatted = format_account_number(match)
if formatted and formatted not in accounts:
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
is_duplicate = any(formatted in acc for acc in accounts)
if not is_duplicate:
accounts.append(formatted)
return accounts
"""Return all recognized records, preserving card numbers and leading zeroes."""
from .account_input import parse_account_records
return parse_account_records(text or "")[0]
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:

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