Fix text dialog routing and club card registration recovery
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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` системным администратором, с подтверждением роли.
|
||||
|
||||
@@ -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
61
docs/TEXT_INPUT_AUDIT.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Проверка текстового ввода
|
||||
|
||||
Проверены зарегистрированные обработчики личных диалогов: регистрация, админка, касса, роли, каналы, эмодзи, общий и личный чат, команды и резервная обработка ввода. Демонстрационные `test_handlers.py` в рабочий диспетчер не подключены.
|
||||
|
||||
## Клубная карта: три и четыре цифры
|
||||
|
||||
Ограничения «не более трёх цифр» в текущем коде нет. В рабочей базе есть карты из четырёх цифр. Проверка полного диалога через диспетчер подтверждает приём `123`, `1234`, `0007` и сохранение ведущих нулей. Номер остаётся строкой; допустимы 1–50 цифр, как и до исправления.
|
||||
|
||||
Исторический отказ именно на четырёх цифрах без текста ответа воспроизвести не удалось. Найдены и исправлены причины потери/остановки диалога:
|
||||
|
||||
- Занятая карта сбрасывала регистрацию. Теперь остаётся шаг карты, можно исправить номер.
|
||||
- Карта, заранее записанная в профиле самого клиента, тоже считалась чужой занятой картой. Теперь клиент может завершить регистрацию со своей картой; карту другого пользователя присвоить нельзя.
|
||||
- Формат карты проверялся только после телефона. Теперь ошибка показывается сразу, на шаге карты.
|
||||
- Неправильный телефон приводил к потере введённых данных. Теперь сохраняются карта и никнейм.
|
||||
- Никнейм записывался второй транзакцией. Теперь никнейм, карта, телефон и регистрация сохраняются вместе.
|
||||
- Сбой ответа Telegram после успешного сохранения оставлял старое состояние. Завершённая регистрация теперь закрывает диалог до отправки подтверждения.
|
||||
- Если диалог уже истёк/отменён, обычное число могло поглощаться обработчиком счетов без ответа. Теперь бот предлагает `/register` или `/start`.
|
||||
|
||||
## Остальные исправления
|
||||
|
||||
| Область | Исправление |
|
||||
|---|---|
|
||||
| Команды во время ввода | Формы не сохраняют и не пересылают `/команды` как данные; известные команды доходят до своих обработчиков, неизвестные получают ответ. `/skip` оставлен только там, где он предусмотрен. |
|
||||
| Регистр команд | Зарегистрированные команды принимаются без учёта регистра. |
|
||||
| Текстовые формы | Добавлены проверки типа сообщения; неподходящее вложение не вызывает обращение к отсутствующему `message.text`. Состояние сохраняется, бот объясняет дальнейшее действие. |
|
||||
| Перехват текста | Слово «регистрация» не перезапускает активную форму. Быстрое удаление по ответу работает по точному триггеру и не перехватывает никнеймы или поля форм. |
|
||||
| Редактирование розыгрыша | Убран выдуманный callback и попытка редактировать сообщение человека. Розыгрыш без описания открывается корректно. Отмена и новое создание не сохраняют ID прежнего редактирования. |
|
||||
| Название, описание, призы | Проверяются пустые/слишком длинные значения, длина названия и каждого приза соответствует базе. Длинное подтверждение разбивается на сообщения, кнопки остаются на последнем. |
|
||||
| Массовые ID | Перенос строки равнозначен запятой. Некорректные ID не превращаются в молчаливую операцию с нулевым результатом. |
|
||||
| Массовые счета | Обрабатываются все строки, табуляция, пара «карта счёт», счёт и карта на отдельных строках, блоки Viposnova. Сохраняется принадлежность карты к счёту. |
|
||||
| Сохранение кассового диалога | Объект SQLAlchemy исключён из данных FSM: структура пригодна для JSON и Redis. При ошибочном вводе можно повторить попытку. |
|
||||
| Числовые ID | Границы чисел проверяются до SQL/Telegram; длинный номер карты в поиске не трактуется как выходящий за диапазон Telegram ID. |
|
||||
| Каналы и модерация | Проверяется ID; экранируются введённые значения в HTML. Ответ `/ban`/`/unban` на копию сообщения определяется по исходному автору в текущем диалоге. |
|
||||
| Чат | Команды не уходят получателю как текст. Выход словами «start»/«старт» использует действующий обработчик главного меню. |
|
||||
|
||||
## Проверки
|
||||
|
||||
`tests/test_text_inputs.py` проверяет полный цикл регистрации, дубликаты карт, конкурентную регистрацию двух клиентов, ошибку отправки подтверждения, ведущие нули, команды и неверный тип сообщения в текстовых формах, отмену/редактирование розыгрыша, длинные подтверждения, многострочный ввод счетов и ID, JSON-сериализацию состояния и команды модерации.
|
||||
|
||||
Проверки выполняются на изолированных SQLite/PostgreSQL с подставным Telegram API. Рабочие карты, телефоны и роли пользователей для тестирования не изменяются. Отдельно проверяются CI, успешный деплой и здоровье работающего бота.
|
||||
|
||||
## Handler inventory
|
||||
|
||||
| Module | Text-capable handlers |
|
||||
|---|---|
|
||||
| [main.py](../main.py) | `cancel_dialog`, `cmd_start`, `btn_lotteries`, `btn_chat`, `btn_registration`, `cmd_register`, `text_registration`, `btn_my_code`, `btn_my_accounts`, `btn_help`, `btn_admin`, `btn_exit_chat`, `btn_main_menu`, `cmd_admin` |
|
||||
| [src/handlers/account_handlers.py](../src/handlers/account_handlers.py) | `detect_account_input` |
|
||||
| [src/handlers/admin_account_handlers.py](../src/handlers/admin_account_handlers.py) | `cancel_command`, `add_account_command`, `process_accounts_data`, `remove_account_command`, `verify_winner_command`, `winner_status_command`, `user_info_command` |
|
||||
| [src/handlers/admin_chat_handlers.py](../src/handlers/admin_chat_handlers.py) | `cmd_chat_mode`, `cmd_set_forward`, `cmd_global_ban`, `cmd_ban`, `cmd_unban`, `cmd_banlist`, `cmd_delete_message`, `cmd_chat_stats` |
|
||||
| [src/handlers/admin_emoji_handlers.py](../src/handlers/admin_emoji_handlers.py) | `add_emoji_start`, `receive_emoji`, `receive_emoji_description`, `list_my_emojis`, `list_all_emojis`, `delete_emoji_start` |
|
||||
| [src/handlers/admin_panel.py](../src/handlers/admin_panel.py) | `process_lottery_title`, `process_lottery_description`, `process_lottery_prizes`, `process_add_participant`, `process_remove_participant`, `process_search_participants`, `process_bulk_add_participant`, `process_bulk_remove_participant`, `process_add_to_lottery`, `process_remove_from_lottery`, `process_bulk_add_accounts`, `process_bulk_remove_accounts`, `process_winner_place`, `process_winner_user`, `admin_broadcast_send`, `admin_broadcast_add_channel_id`, `admin_broadcast_add_channel_description`, `admin_users_search_process` |
|
||||
| [src/handlers/cashier_handlers.py](../src/handlers/cashier_handlers.py) | `cashier_menu`, `add_accounts` |
|
||||
| [src/handlers/chat_handlers.py](../src/handlers/chat_handlers.py) | `enter_chat_command`, `exit_chat_command`, `check_exit_keywords` |
|
||||
| [src/handlers/help_handlers.py](../src/handlers/help_handlers.py) | `help_command` |
|
||||
| [src/handlers/input_fallback.py](../src/handlers/input_fallback.py) | `unknown_command`, `unmatched_input` |
|
||||
| [src/handlers/message_management.py](../src/handlers/message_management.py) | `delete_replied_message`, `quick_delete_replied_message` |
|
||||
| [src/handlers/operator_help.py](../src/handlers/operator_help.py) | `own_id`, `help_command` |
|
||||
| [src/handlers/p2p_chat.py](../src/handlers/p2p_chat.py) | `show_chat_menu`, `handle_p2p_message` |
|
||||
| [src/handlers/redraw_handlers.py](../src/handlers/redraw_handlers.py) | `check_unclaimed_winners`, `redraw_lottery` |
|
||||
| [src/handlers/registration_handlers.py](../src/handlers/registration_handlers.py) | `process_nickname`, `process_club_card`, `process_phone`, `show_verification_code`, `show_user_accounts` |
|
||||
| [src/handlers/staff_handlers.py](../src/handlers/staff_handlers.py) | `staff_menu`, `staff_command`, `staff_target`, `confirmation_pending`, `staff_list_command` |
|
||||
13
main.py
13
main.py
@@ -8,7 +8,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from aiogram import Bot, Dispatcher, Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
|
||||
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
@@ -164,7 +164,7 @@ async def cmd_register(message: Message, state: FSMContext):
|
||||
await btn_registration(message, state)
|
||||
|
||||
|
||||
@router.message(F.text.lower().in_(["регистрация", "регистр", "register"]))
|
||||
@router.message(StateFilter(None), F.text.lower().in_(["регистрация", "регистр", "register"]))
|
||||
async def text_registration(message: Message, state: FSMContext):
|
||||
"""Обработчик текста для регистрации"""
|
||||
await btn_registration(message, state)
|
||||
@@ -192,9 +192,9 @@ async def btn_help(message: Message):
|
||||
|
||||
|
||||
@router.message(F.text == "⚙️ Админ панель")
|
||||
async def btn_admin(message: Message):
|
||||
async def btn_admin(message: Message, state: FSMContext):
|
||||
"""Обработчик кнопки 'Админ панель'"""
|
||||
await cmd_admin(message)
|
||||
await cmd_admin(message, state)
|
||||
|
||||
|
||||
@router.message(F.text == "🚪 Выйти из чата")
|
||||
@@ -211,7 +211,7 @@ async def btn_main_menu(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("admin"))
|
||||
async def cmd_admin(message: Message):
|
||||
async def cmd_admin(message: Message, state: FSMContext):
|
||||
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
|
||||
from src.core.config import ADMIN_IDS
|
||||
from src.core.database import async_session_maker
|
||||
@@ -238,6 +238,7 @@ async def cmd_admin(message: Message):
|
||||
return
|
||||
|
||||
# Отправляем сообщение с кнопкой админ панели
|
||||
await state.clear()
|
||||
from src.components.ui import KeyboardBuilderImpl
|
||||
kb = KeyboardBuilderImpl()
|
||||
keyboard = kb.get_admin_keyboard()
|
||||
@@ -329,6 +330,8 @@ def configure_dispatcher():
|
||||
|
||||
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
||||
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
||||
from src.handlers.input_fallback import input_fallback_router
|
||||
dp.include_router(input_fallback_router)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
16
src/filters/dialog_input.py
Normal file
16
src/filters/dialog_input.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""Обработчик стикеров"""
|
||||
# Защита от дубликатов
|
||||
|
||||
@@ -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
|
||||
|
||||
19
src/handlers/input_fallback.py
Normal file
19
src/handlers/input_fallback.py
Normal 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 — регистрация. Для переписки сначала откройте чат.")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
54
src/utils/account_input.py
Normal file
54
src/utils/account_input.py
Normal 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
|
||||
@@ -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]:
|
||||
|
||||
47
src/utils/input_validation.py
Normal file
47
src/utils/input_validation.py
Normal 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
12
src/utils/text_output.py
Normal 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
280
tests/test_text_inputs.py
Normal 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, " ", "1234"]:
|
||||
calls = await dispatch(event(950, text=bad))
|
||||
assert any(getattr(call, "text", None) for call in calls)
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_club_card.state
|
||||
assert (await state.get_data())["nickname"] == "Tester"
|
||||
await dispatch(event(950, text="0008"))
|
||||
assert await state.get_state() == RegistrationStates.waiting_for_phone.state
|
||||
|
||||
|
||||
async def test_client_can_finish_registration_with_their_own_preassigned_four_digit_card():
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_or_create_user(session, 950)
|
||||
user.club_card_number = "1234"
|
||||
user.is_registered = False
|
||||
await session.commit()
|
||||
await start_registration()
|
||||
await dispatch(event(950, text="1234"))
|
||||
assert await context(950).get_state() == RegistrationStates.waiting_for_phone.state
|
||||
await dispatch(event(950, text="-"))
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, 950)
|
||||
assert user.club_card_number == "1234" and user.is_registered
|
||||
|
||||
|
||||
async def test_invalid_phone_keeps_card_and_nickname_until_corrected():
|
||||
state = await start_registration()
|
||||
await dispatch(event(950, text="1234"))
|
||||
for bad in [" ", "1" * 21, "<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("<reason>" in (getattr(call, "text", "") or "") for call in calls)
|
||||
async with async_session_maker() as session:
|
||||
assert await session.scalar(select(BannedUser.telegram_id).where(BannedUser.is_active.is_(True))) == 950
|
||||
Reference in New Issue
Block a user