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),
|
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
|
||||||
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
|
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
|
||||||
|
[Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md).
|
||||||
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
|
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
|
||||||
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
|
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
|
||||||
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.
|
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
## Работа с пользователями
|
## Работа с пользователями
|
||||||
|
|
||||||
1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
|
1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
|
||||||
|
Карта вводится цифрами; допустимы и три, и четыре цифры, ведущие нули сохраняются. Если карта заранее привязана к этому же клиенту, он может завершить регистрацию с ней. При ошибке номера или телефона можно повторить ввод на текущем шаге.
|
||||||
2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
|
2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
|
||||||
3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
|
3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
|
||||||
4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие.
|
4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие.
|
||||||
|
|||||||
61
docs/TEXT_INPUT_AUDIT.md
Normal file
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 import Bot, Dispatcher, Router, F
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
|
from aiogram.fsm.storage.memory import MemoryStorage, SimpleEventIsolation
|
||||||
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
|
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
|
||||||
from aiogram.client.session.aiohttp import AiohttpSession
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
@@ -164,7 +164,7 @@ async def cmd_register(message: Message, state: FSMContext):
|
|||||||
await btn_registration(message, state)
|
await btn_registration(message, state)
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text.lower().in_(["регистрация", "регистр", "register"]))
|
@router.message(StateFilter(None), F.text.lower().in_(["регистрация", "регистр", "register"]))
|
||||||
async def text_registration(message: Message, state: FSMContext):
|
async def text_registration(message: Message, state: FSMContext):
|
||||||
"""Обработчик текста для регистрации"""
|
"""Обработчик текста для регистрации"""
|
||||||
await btn_registration(message, state)
|
await btn_registration(message, state)
|
||||||
@@ -192,9 +192,9 @@ async def btn_help(message: Message):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "⚙️ Админ панель")
|
@router.message(F.text == "⚙️ Админ панель")
|
||||||
async def btn_admin(message: Message):
|
async def btn_admin(message: Message, state: FSMContext):
|
||||||
"""Обработчик кнопки 'Админ панель'"""
|
"""Обработчик кнопки 'Админ панель'"""
|
||||||
await cmd_admin(message)
|
await cmd_admin(message, state)
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text == "🚪 Выйти из чата")
|
@router.message(F.text == "🚪 Выйти из чата")
|
||||||
@@ -211,7 +211,7 @@ async def btn_main_menu(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("admin"))
|
@router.message(CaseInsensitiveCommand("admin"))
|
||||||
async def cmd_admin(message: Message):
|
async def cmd_admin(message: Message, state: FSMContext):
|
||||||
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
|
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
@@ -238,6 +238,7 @@ async def cmd_admin(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Отправляем сообщение с кнопкой админ панели
|
# Отправляем сообщение с кнопкой админ панели
|
||||||
|
await state.clear()
|
||||||
from src.components.ui import KeyboardBuilderImpl
|
from src.components.ui import KeyboardBuilderImpl
|
||||||
kb = KeyboardBuilderImpl()
|
kb = KeyboardBuilderImpl()
|
||||||
keyboard = kb.get_admin_keyboard()
|
keyboard = kb.get_admin_keyboard()
|
||||||
@@ -329,6 +330,8 @@ def configure_dispatcher():
|
|||||||
|
|
||||||
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
||||||
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
||||||
|
from src.handlers.input_fallback import input_fallback_router
|
||||||
|
dp.include_router(input_fallback_router)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Optional, List
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from src.utils.account_utils import format_account_number
|
from src.utils.account_utils import format_account_number
|
||||||
|
from src.utils.input_validation import club_card, phone_number
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
@@ -17,16 +18,17 @@ class RegistrationService:
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
telegram_id: int,
|
telegram_id: int,
|
||||||
club_card_number: str,
|
club_card_number: str,
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None,
|
||||||
|
nickname: Optional[str] = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Зарегистрировать нового пользователя с клубной картой"""
|
"""Зарегистрировать нового пользователя с клубной картой"""
|
||||||
if not club_card_number or not club_card_number.isascii() or not club_card_number.isdigit() or len(club_card_number) > 50:
|
club_card_number = club_card(club_card_number)
|
||||||
raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр")
|
phone = phone_number(phone)
|
||||||
if phone and len(phone) > 20:
|
if nickname is not None and not 2 <= len(nickname) <= 20:
|
||||||
raise ValueError("Телефон должен быть не длиннее 20 символов")
|
raise ValueError("Никнейм должен содержать от 2 до 20 символов")
|
||||||
# Проверяем, не занята ли клубная карта
|
# Проверяем, не занята ли клубная карта
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(User).where(User.club_card_number == club_card_number)
|
select(User).where(User.club_card_number == club_card_number, User.telegram_id != telegram_id)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
|
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
|
||||||
@@ -39,10 +41,14 @@ class RegistrationService:
|
|||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise ValueError("Пользователь не найден")
|
raise ValueError("Пользователь не найден")
|
||||||
|
if user.is_registered:
|
||||||
|
raise ValueError("Вы уже зарегистрированы. Для изменения карты обратитесь к администратору.")
|
||||||
|
|
||||||
# Обновляем данные пользователя
|
# Обновляем данные пользователя
|
||||||
user.club_card_number = club_card_number
|
user.club_card_number = club_card_number
|
||||||
user.phone = phone
|
user.phone = phone
|
||||||
|
if nickname is not None:
|
||||||
|
user.nickname = nickname
|
||||||
user.is_registered = True
|
user.is_registered = True
|
||||||
user.generate_verification_code()
|
user.generate_verification_code()
|
||||||
|
|
||||||
@@ -51,8 +57,6 @@ class RegistrationService:
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None
|
raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None
|
||||||
await session.refresh(user)
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class UserService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
|
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||||
"""Получить пользователя по Telegram ID"""
|
"""Получить пользователя по Telegram ID"""
|
||||||
|
if not isinstance(telegram_id, int) or not 0 < telegram_id < 2**63:
|
||||||
|
return None
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(User).where(User.telegram_id == telegram_id)
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
)
|
)
|
||||||
@@ -157,10 +159,11 @@ class LotteryService:
|
|||||||
async def create_lottery(session: AsyncSession, title: str, description: str,
|
async def create_lottery(session: AsyncSession, title: str, description: str,
|
||||||
prizes: List[str], creator_id: int) -> Lottery:
|
prizes: List[str], creator_id: int) -> Lottery:
|
||||||
"""Создать новый розыгрыш"""
|
"""Создать новый розыгрыш"""
|
||||||
|
from src.utils.input_validation import lottery_field
|
||||||
lottery = Lottery(
|
lottery = Lottery(
|
||||||
title=title,
|
title=lottery_field("title", title),
|
||||||
description=description,
|
description=lottery_field("description", description),
|
||||||
prizes=prizes,
|
prizes=lottery_field("prizes", prizes),
|
||||||
creator_id=creator_id
|
creator_id=creator_id
|
||||||
)
|
)
|
||||||
session.add(lottery)
|
session.add(lottery)
|
||||||
@@ -171,6 +174,8 @@ class LotteryService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
|
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
|
||||||
"""Получить розыгрыш по ID"""
|
"""Получить розыгрыш по ID"""
|
||||||
|
if not isinstance(lottery_id, int) or not 0 < lottery_id < 2**31:
|
||||||
|
return None
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Lottery)
|
select(Lottery)
|
||||||
.options(selectinload(Lottery.participations).selectinload(Participation.user))
|
.options(selectinload(Lottery.participations).selectinload(Participation.user))
|
||||||
@@ -203,6 +208,9 @@ class LotteryService:
|
|||||||
if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
|
if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
|
from src.utils.input_validation import lottery_field
|
||||||
|
updates = {key: lottery_field(key, value) if key in {"title", "description", "prizes"} else value
|
||||||
|
for key, value in updates.items()}
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(Lottery)
|
update(Lottery)
|
||||||
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class UserManagementService:
|
|||||||
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
|
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
|
||||||
|
|
||||||
# Если запрос - число, ищем по telegram_id
|
# Если запрос - число, ищем по telegram_id
|
||||||
if query.isdigit():
|
if query.isascii() and query.isdigit() and len(query) <= 19 and 0 < int(query) < 2**63:
|
||||||
search_conditions.append(User.telegram_id == int(query))
|
search_conditions.append(User.telegram_id == int(query))
|
||||||
|
|
||||||
conditions.append(or_(*search_conditions))
|
conditions.append(or_(*search_conditions))
|
||||||
|
|||||||
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):
|
if not await is_admin(message.from_user.id):
|
||||||
return
|
from aiogram.dispatcher.event.bases import SkipHandler
|
||||||
|
raise SkipHandler()
|
||||||
|
|
||||||
# Парсим счета из сообщения
|
# Парсим счета из сообщения
|
||||||
accounts = parse_accounts_from_message(message.text)
|
accounts = parse_accounts_from_message(message.text)
|
||||||
|
|
||||||
if not accounts:
|
if not accounts:
|
||||||
return # Счета не обнаружены, пропускаем
|
from aiogram.dispatcher.event.bases import SkipHandler
|
||||||
|
raise SkipHandler()
|
||||||
|
|
||||||
# Извлекаем номера клубных карт и определяем владельцев
|
# Извлекаем номера клубных карт и определяем владельцев
|
||||||
from ..core.services import UserService
|
from ..core.services import UserService
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Админские обработчики для управления счетами и верификации"""
|
"""Админские обработчики для управления счетами и верификации"""
|
||||||
|
from src.utils.account_input import parse_account_records
|
||||||
|
from src.utils.text_output import answer_plain_pages
|
||||||
|
from src.utils.input_validation import numeric_id
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
@@ -121,7 +125,7 @@ async def process_single_account(message: Message, club_card: str, account_numbe
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AddAccountStates.waiting_for_data, F.text)
|
@router.message(AddAccountStates.waiting_for_data, F.text, NotCommand())
|
||||||
async def process_accounts_data(message: Message, state: FSMContext):
|
async def process_accounts_data(message: Message, state: FSMContext):
|
||||||
"""Обработка данных счетов (один или несколько)"""
|
"""Обработка данных счетов (один или несколько)"""
|
||||||
if message.text.strip().lower() == '/cancel':
|
if message.text.strip().lower() == '/cancel':
|
||||||
@@ -140,7 +144,6 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
f"Вы отправили: {len(lines)} строк\n\n"
|
f"Вы отправили: {len(lines)} строк\n\n"
|
||||||
f"Разделите данные на несколько частей."
|
f"Разделите данные на несколько частей."
|
||||||
)
|
)
|
||||||
await state.clear()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Отправляем начальное уведомление
|
# Отправляем начальное уведомление
|
||||||
@@ -150,46 +153,17 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
accounts_data = []
|
accounts_data = []
|
||||||
errors = []
|
entries, errors = parse_account_records(message.text)
|
||||||
|
|
||||||
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
|
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
|
||||||
|
|
||||||
# Универсальный парсер: поддержка однострочного и многострочного формата
|
# Универсальный парсер: поддержка однострочного и многострочного формата
|
||||||
i = 0
|
for entry in entries:
|
||||||
while i < len(lines):
|
parts = entry.split()
|
||||||
line = lines[i].strip()
|
if len(parts) != 2:
|
||||||
|
errors.append("Для счёта не указана клубная карта: " + entry)
|
||||||
# Пропускаем пустые строки и строки с названиями/датами
|
|
||||||
if not line or any(x in line.lower() for x in ['viposnova', '0.00', ':']):
|
|
||||||
i += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Проверяем, есть ли в строке пробел (однострочный формат: "карта счет")
|
|
||||||
if ' ' in line:
|
|
||||||
# Однострочный формат: разделяем по первому пробелу
|
|
||||||
parts = line.split(maxsplit=1)
|
|
||||||
if len(parts) == 2:
|
|
||||||
club_card, account_number = parts
|
club_card, account_number = parts
|
||||||
else:
|
|
||||||
errors.append(f"Строка {i+1}: неверный формат")
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Многострочный формат: текущая строка - счет, следующая - карта
|
|
||||||
account_number = line
|
|
||||||
i += 1
|
|
||||||
if i >= len(lines):
|
|
||||||
errors.append(f"Строка {i}: отсутствует номер карты после счета {account_number}")
|
|
||||||
break
|
|
||||||
|
|
||||||
club_card = lines[i].strip()
|
|
||||||
# Пропускаем, если следующая строка содержит мусор
|
|
||||||
if not club_card or any(x in club_card.lower() for x in ['viposnova', '0.00', ':']):
|
|
||||||
errors.append(f"Строка {i}: некорректный номер карты после счета {account_number}")
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Создаем счет
|
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
account = await AccountService.create_account(
|
account = await AccountService.create_account(
|
||||||
@@ -223,7 +197,6 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Счет {account_number}: {public_error(e)}")
|
errors.append(f"Счет {account_number}: {public_error(e)}")
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# Удаляем progress сообщение
|
# Удаляем progress сообщение
|
||||||
try:
|
try:
|
||||||
@@ -317,12 +290,11 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
|||||||
text += f"\n... и еще {len(errors) - 5} ошибок\n"
|
text += f"\n... и еще {len(errors) - 5} ошибок\n"
|
||||||
|
|
||||||
if not accounts_data:
|
if not accounts_data:
|
||||||
await message.answer(text)
|
await answer_plain_pages(message, text)
|
||||||
await state.clear()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Сохраняем данные и предлагаем добавить в розыгрыш
|
# Сохраняем данные и предлагаем добавить в розыгрыш
|
||||||
await state.update_data(accounts=accounts_data)
|
await state.update_data(accounts=[{key: value for key, value in account.items() if key != "owner"} for account in accounts_data])
|
||||||
await show_lottery_selection(message, text, state)
|
await show_lottery_selection(message, text, state)
|
||||||
|
|
||||||
|
|
||||||
@@ -332,7 +304,7 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon
|
|||||||
lotteries = await LotteryService.get_active_lotteries(session)
|
lotteries = await LotteryService.get_active_lotteries(session)
|
||||||
|
|
||||||
if not lotteries:
|
if not lotteries:
|
||||||
await message.answer(
|
await answer_plain_pages(message,
|
||||||
prev_text + "ℹ️ Нет активных розыгрышей для добавления счетов"
|
prev_text + "ℹ️ Нет активных розыгрышей для добавления счетов"
|
||||||
)
|
)
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -353,7 +325,7 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon
|
|||||||
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
|
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer(
|
await answer_plain_pages(message,
|
||||||
prev_text + "➕ **Добавить счета в розыгрыш?**\n\n"
|
prev_text + "➕ **Добавить счета в розыгрыш?**\n\n"
|
||||||
"Выберите розыгрыш из списка:",
|
"Выберите розыгрыш из списка:",
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
@@ -503,7 +475,7 @@ async def verify_winner_command(message: Message):
|
|||||||
verification_code = parts[1].upper()
|
verification_code = parts[1].upper()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lottery_id = int(parts[2])
|
lottery_id = numeric_id(parts[2], maximum=2**31 - 1)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ lottery_id должен быть числом")
|
await message.answer("❌ lottery_id должен быть числом")
|
||||||
return
|
return
|
||||||
@@ -588,7 +560,7 @@ async def winner_status_command(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lottery_id = int(parts[1])
|
lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ lottery_id должен быть числом")
|
await message.answer("❌ lottery_id должен быть числом")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Админские обработчики для управления чатом"""
|
"""Админские обработчики для управления чатом"""
|
||||||
|
from html import escape
|
||||||
|
from src.utils.input_validation import numeric_id
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.filters import Command
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
@@ -20,6 +22,23 @@ from src.core.permissions import admin_only
|
|||||||
router = Router(name='admin_chat_router')
|
router = Router(name='admin_chat_router')
|
||||||
|
|
||||||
|
|
||||||
|
async def reply_target(message: Message):
|
||||||
|
"""Resolve the human author of a bot copy only within the operator's dialog."""
|
||||||
|
replied = message.reply_to_message
|
||||||
|
if replied.from_user and not replied.from_user.is_bot:
|
||||||
|
return replied.from_user.id
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.core.models import User, P2PMessage
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
original = await ChatMessageService.get_message_by_telegram_id(
|
||||||
|
session, replied.message_id, chat_id=message.chat.id)
|
||||||
|
if original:
|
||||||
|
return await session.scalar(select(User.telegram_id).where(User.id == original.user_id))
|
||||||
|
operator_id = select(User.id).where(User.telegram_id == message.from_user.id).scalar_subquery()
|
||||||
|
return await session.scalar(select(User.telegram_id).join(P2PMessage, P2PMessage.sender_id == User.id).where(
|
||||||
|
P2PMessage.recipient_id == operator_id, P2PMessage.recipient_message_id == replied.message_id))
|
||||||
|
|
||||||
|
|
||||||
def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
|
def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
|
||||||
"""Клавиатура выбора режима чата"""
|
"""Клавиатура выбора режима чата"""
|
||||||
return InlineKeyboardMarkup(inline_keyboard=[
|
return InlineKeyboardMarkup(inline_keyboard=[
|
||||||
@@ -89,7 +108,11 @@ async def cmd_set_forward(message: Message):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
chat_id = args[1].strip()
|
try:
|
||||||
|
chat_id = str(numeric_id(args[1], signed=True))
|
||||||
|
except ValueError as error:
|
||||||
|
await message.answer(str(error))
|
||||||
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
|
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
|
||||||
@@ -135,7 +158,10 @@ async def cmd_ban(message: Message):
|
|||||||
|
|
||||||
# Проверяем является ли это ответом на сообщение
|
# Проверяем является ли это ответом на сообщение
|
||||||
if message.reply_to_message:
|
if message.reply_to_message:
|
||||||
target_user_id = message.reply_to_message.from_user.id
|
target_user_id = await reply_target(message)
|
||||||
|
if target_user_id is None:
|
||||||
|
await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
|
||||||
|
return
|
||||||
reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
|
reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
|
||||||
else:
|
else:
|
||||||
args = message.text.split(maxsplit=2)
|
args = message.text.split(maxsplit=2)
|
||||||
@@ -150,7 +176,7 @@ async def cmd_ban(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_user_id = int(args[1])
|
target_user_id = numeric_id(args[1])
|
||||||
reason = args[2] if len(args) > 2 else None
|
reason = args[2] if len(args) > 2 else None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ Неверный ID пользователя")
|
await message.answer("❌ Неверный ID пользователя")
|
||||||
@@ -182,11 +208,11 @@ async def cmd_ban(message: Message):
|
|||||||
reason=reason
|
reason=reason
|
||||||
)
|
)
|
||||||
|
|
||||||
reason_text = f"\n📝 Причина: {reason}" if reason else ""
|
reason_text = f"\n📝 Причина: {escape(reason[:500])}" if reason else ""
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"🚫 <b>Пользователь забанен</b>\n\n"
|
f"🚫 <b>Пользователь забанен</b>\n\n"
|
||||||
f"👤 Пользователь: {user.name or 'Неизвестен'}\n"
|
f"👤 Пользователь: {escape(user.nickname or user.first_name or 'Неизвестен')}\n"
|
||||||
f"🆔 ID: <code>{target_user_id}</code>"
|
f"🆔 ID: <code>{target_user_id}</code>"
|
||||||
f"{reason_text}",
|
f"{reason_text}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
@@ -200,7 +226,10 @@ async def cmd_unban(message: Message):
|
|||||||
|
|
||||||
# Проверяем является ли это ответом на сообщение
|
# Проверяем является ли это ответом на сообщение
|
||||||
if message.reply_to_message:
|
if message.reply_to_message:
|
||||||
target_user_id = message.reply_to_message.from_user.id
|
target_user_id = await reply_target(message)
|
||||||
|
if target_user_id is None:
|
||||||
|
await message.answer("Не удалось определить автора. Укажите его Telegram ID в команде.")
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
args = message.text.split()
|
args = message.text.split()
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
@@ -214,7 +243,7 @@ async def cmd_unban(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_user_id = int(args[1])
|
target_user_id = numeric_id(args[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ Неверный ID пользователя")
|
await message.answer("❌ Неверный ID пользователя")
|
||||||
return
|
return
|
||||||
@@ -252,11 +281,11 @@ async def cmd_banlist(message: Message):
|
|||||||
user = ban.user
|
user = ban.user
|
||||||
admin = ban.admin
|
admin = ban.admin
|
||||||
|
|
||||||
text += f"👤 {user.name or 'Неизвестен'} (<code>{ban.telegram_id}</code>)\n"
|
text += f"👤 {escape(user.nickname or user.first_name or 'Неизвестен')} (<code>{ban.telegram_id}</code>)\n"
|
||||||
text += f"🔨 Забанил: {admin.name if admin else 'Неизвестен'}\n"
|
text += f"🔨 Забанил: {escape((admin.nickname or admin.first_name or 'Неизвестен') if admin else 'Неизвестен')}\n"
|
||||||
|
|
||||||
if ban.reason:
|
if ban.reason:
|
||||||
text += f"📝 Причина: {ban.reason}\n"
|
text += f"📝 Причина: {escape(ban.reason[:500])}\n"
|
||||||
|
|
||||||
text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
|
text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
Хендлеры для управления кастомными эмодзи админом
|
Хендлеры для управления кастомными эмодзи админом
|
||||||
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
||||||
"""
|
"""
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
import logging
|
import logging
|
||||||
from html import escape
|
from html import escape
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import StateFilter
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -42,7 +44,7 @@ async def add_emoji_start(message: Message, state: FSMContext):
|
|||||||
await state.set_state(EmojiStates.waiting_for_emoji)
|
await state.set_state(EmojiStates.waiting_for_emoji)
|
||||||
|
|
||||||
|
|
||||||
@router.message(EmojiStates.waiting_for_emoji, F.text)
|
@router.message(EmojiStates.waiting_for_emoji, F.text, NotCommand())
|
||||||
async def receive_emoji(message: Message, state: FSMContext):
|
async def receive_emoji(message: Message, state: FSMContext):
|
||||||
"""Получить эмодзи от админа и сохранить его emoji_id"""
|
"""Получить эмодзи от админа и сохранить его emoji_id"""
|
||||||
entities = [entity for entity in message.entities or () if entity.type == "custom_emoji"]
|
entities = [entity for entity in message.entities or () if entity.type == "custom_emoji"]
|
||||||
@@ -66,7 +68,7 @@ async def receive_emoji(message: Message, state: FSMContext):
|
|||||||
await state.set_state(EmojiStates.waiting_for_description)
|
await state.set_state(EmojiStates.waiting_for_description)
|
||||||
|
|
||||||
|
|
||||||
@router.message(EmojiStates.waiting_for_description, F.text)
|
@router.message(EmojiStates.waiting_for_description, F.text, NotCommand())
|
||||||
async def receive_emoji_description(message: Message, state: FSMContext):
|
async def receive_emoji_description(message: Message, state: FSMContext):
|
||||||
"""Получить описание эмодзи и сохранить в БД"""
|
"""Получить описание эмодзи и сохранить в БД"""
|
||||||
if not message.text:
|
if not message.text:
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Расширенная админ-панель для управления розыгрышами
|
Расширенная админ-панель для управления розыгрышами
|
||||||
"""
|
"""
|
||||||
|
from src.utils.input_validation import lottery_field, numeric_id
|
||||||
|
from src.utils.text_output import answer_plain_pages
|
||||||
|
from html import escape
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
from src.core.access import is_admin
|
from src.core.access import is_admin
|
||||||
from src.core.access import is_admin as check_admin_access
|
from src.core.access import is_admin as check_admin_access
|
||||||
@@ -180,11 +184,12 @@ def get_winner_management_keyboard() -> InlineKeyboardMarkup:
|
|||||||
|
|
||||||
|
|
||||||
@admin_router.callback_query(F.data == "admin_panel")
|
@admin_router.callback_query(F.data == "admin_panel")
|
||||||
async def show_admin_panel(callback: CallbackQuery):
|
async def show_admin_panel(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Показать админ-панель"""
|
"""Показать админ-панель"""
|
||||||
if not await check_admin_access(callback.from_user.id):
|
if not await check_admin_access(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Быстрая статистика
|
# Быстрая статистика
|
||||||
@@ -215,11 +220,12 @@ async def show_admin_panel(callback: CallbackQuery):
|
|||||||
# ======================
|
# ======================
|
||||||
|
|
||||||
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
|
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
|
||||||
async def show_lottery_management(callback: CallbackQuery):
|
async def show_lottery_management(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Управление розыгрышами"""
|
"""Управление розыгрышами"""
|
||||||
if not await check_admin_access(callback.from_user.id):
|
if not await check_admin_access(callback.from_user.id):
|
||||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||||
return
|
return
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
text = "🎲 Управление розыгрышами\n\n"
|
text = "🎲 Управление розыгрышами\n\n"
|
||||||
text += "Здесь вы можете создавать, редактировать и управлять розыгрышами.\n\n"
|
text += "Здесь вы можете создавать, редактировать и управлять розыгрышами.\n\n"
|
||||||
@@ -243,6 +249,7 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
logging.info(f"✅ Админ {callback.from_user.id} начинает создание розыгрыша")
|
logging.info(f"✅ Админ {callback.from_user.id} начинает создание розыгрыша")
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
text = "📝 Создание нового розыгрыша\n\n"
|
text = "📝 Создание нового розыгрыша\n\n"
|
||||||
text += "Шаг 1 из 4\n\n"
|
text += "Шаг 1 из 4\n\n"
|
||||||
text += "Введите название розыгрыша:"
|
text += "Введите название розыгрыша:"
|
||||||
@@ -261,13 +268,19 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
|
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.lottery_title))
|
@admin_router.message(StateFilter(AdminStates.lottery_title), F.text, NotCommand())
|
||||||
async def process_lottery_title(message: Message, state: FSMContext):
|
async def process_lottery_title(message: Message, state: FSMContext):
|
||||||
"""Обработка названия розыгрыша (создание или редактирование)"""
|
"""Обработка названия розыгрыша (создание или редактирование)"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
await message.answer("❌ Недостаточно прав")
|
await message.answer("❌ Недостаточно прав")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = lottery_field("title", message.text)
|
||||||
|
except ValueError as error:
|
||||||
|
await message.answer(str(error))
|
||||||
|
return
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
edit_lottery_id = data.get('edit_lottery_id')
|
edit_lottery_id = data.get('edit_lottery_id')
|
||||||
|
|
||||||
@@ -277,51 +290,49 @@ async def process_lottery_title(message: Message, state: FSMContext):
|
|||||||
success = await LotteryService.update_lottery(
|
success = await LotteryService.update_lottery(
|
||||||
session,
|
session,
|
||||||
edit_lottery_id,
|
edit_lottery_id,
|
||||||
title=message.text
|
title=value
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
await message.answer(f"✅ Название изменено на: {message.text}")
|
await message.answer(f"✅ Название изменено на: {value}")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
# Возвращаемся к выбору полей
|
# Возвращаемся к выбору полей
|
||||||
from aiogram.types import CallbackQuery
|
await show_edit_fields(message, edit_lottery_id)
|
||||||
fake_callback = CallbackQuery(
|
|
||||||
id="fake",
|
|
||||||
from_user=message.from_user,
|
|
||||||
chat_instance="fake",
|
|
||||||
data=f"admin_edit_lottery_select_{edit_lottery_id}",
|
|
||||||
message=message
|
|
||||||
)
|
|
||||||
await choose_edit_field(fake_callback, state)
|
|
||||||
else:
|
else:
|
||||||
await message.answer("❌ Ошибка при изменении названия")
|
await message.answer("❌ Ошибка при изменении названия")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если это создание нового розыгрыша
|
# Если это создание нового розыгрыша
|
||||||
await state.update_data(title=message.text)
|
await state.update_data(title=value)
|
||||||
|
|
||||||
text = f"📝 Создание нового розыгрыша\n\n"
|
text = f"📝 Создание нового розыгрыша\n\n"
|
||||||
text += f"Шаг 2 из 4\n\n"
|
text += f"Шаг 2 из 4\n\n"
|
||||||
text += f"✅ Название: {message.text}\n\n"
|
text += f"✅ Название: {value}\n\n"
|
||||||
text += f"Введите описание розыгрыша (или '-' для пропуска):"
|
text += f"Введите описание розыгрыша (или '-' для пропуска):"
|
||||||
|
|
||||||
await message.answer(text)
|
await answer_plain_pages(message, text)
|
||||||
await state.set_state(AdminStates.lottery_description)
|
await state.set_state(AdminStates.lottery_description)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.lottery_description))
|
@admin_router.message(StateFilter(AdminStates.lottery_description), F.text, NotCommand())
|
||||||
async def process_lottery_description(message: Message, state: FSMContext):
|
async def process_lottery_description(message: Message, state: FSMContext):
|
||||||
"""Обработка описания розыгрыша (создание или редактирование)"""
|
"""Обработка описания розыгрыша (создание или редактирование)"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
await message.answer("❌ Недостаточно прав")
|
await message.answer("❌ Недостаточно прав")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = lottery_field("description", message.text)
|
||||||
|
except ValueError as error:
|
||||||
|
await message.answer(str(error))
|
||||||
|
return
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
edit_lottery_id = data.get('edit_lottery_id')
|
edit_lottery_id = data.get('edit_lottery_id')
|
||||||
|
|
||||||
# Если это редактирование существующего розыгрыша
|
# Если это редактирование существующего розыгрыша
|
||||||
if edit_lottery_id:
|
if edit_lottery_id:
|
||||||
description = None if message.text == "-" else message.text
|
description = value
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
success = await LotteryService.update_lottery(
|
success = await LotteryService.update_lottery(
|
||||||
session,
|
session,
|
||||||
@@ -333,21 +344,13 @@ async def process_lottery_description(message: Message, state: FSMContext):
|
|||||||
await message.answer(f"✅ Описание изменено")
|
await message.answer(f"✅ Описание изменено")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
# Возвращаемся к выбору полей
|
# Возвращаемся к выбору полей
|
||||||
from aiogram.types import CallbackQuery
|
await show_edit_fields(message, edit_lottery_id)
|
||||||
fake_callback = CallbackQuery(
|
|
||||||
id="fake",
|
|
||||||
from_user=message.from_user,
|
|
||||||
chat_instance="fake",
|
|
||||||
data=f"admin_edit_lottery_select_{edit_lottery_id}",
|
|
||||||
message=message
|
|
||||||
)
|
|
||||||
await choose_edit_field(fake_callback, state)
|
|
||||||
else:
|
else:
|
||||||
await message.answer("❌ Ошибка при изменении описания")
|
await message.answer("❌ Ошибка при изменении описания")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если это создание нового розыгрыша
|
# Если это создание нового розыгрыша
|
||||||
description = None if message.text == "-" else message.text
|
description = value
|
||||||
await state.update_data(description=description)
|
await state.update_data(description=description)
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
@@ -363,21 +366,27 @@ async def process_lottery_description(message: Message, state: FSMContext):
|
|||||||
text += f"🥉 AirPods Pro\n"
|
text += f"🥉 AirPods Pro\n"
|
||||||
text += f"🏆 10,000 рублей"
|
text += f"🏆 10,000 рублей"
|
||||||
|
|
||||||
await message.answer(text)
|
await answer_plain_pages(message, text)
|
||||||
await state.set_state(AdminStates.lottery_prizes)
|
await state.set_state(AdminStates.lottery_prizes)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.lottery_prizes))
|
@admin_router.message(StateFilter(AdminStates.lottery_prizes), F.text, NotCommand())
|
||||||
async def process_lottery_prizes(message: Message, state: FSMContext):
|
async def process_lottery_prizes(message: Message, state: FSMContext):
|
||||||
"""Обработка призов розыгрыша (создание или редактирование)"""
|
"""Обработка призов розыгрыша (создание или редактирование)"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
await message.answer("❌ Недостаточно прав")
|
await message.answer("❌ Недостаточно прав")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = lottery_field("prizes", message.text)
|
||||||
|
except ValueError as error:
|
||||||
|
await message.answer(str(error))
|
||||||
|
return
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
edit_lottery_id = data.get('edit_lottery_id')
|
edit_lottery_id = data.get('edit_lottery_id')
|
||||||
|
|
||||||
prizes = [prize.strip() for prize in message.text.split('\n') if prize.strip()]
|
prizes = value
|
||||||
|
|
||||||
# Если это редактирование существующего розыгрыша
|
# Если это редактирование существующего розыгрыша
|
||||||
if edit_lottery_id:
|
if edit_lottery_id:
|
||||||
@@ -392,15 +401,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
|
|||||||
await message.answer(f"✅ Призы изменены")
|
await message.answer(f"✅ Призы изменены")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
# Возвращаемся к выбору полей
|
# Возвращаемся к выбору полей
|
||||||
from aiogram.types import CallbackQuery
|
await show_edit_fields(message, edit_lottery_id)
|
||||||
fake_callback = CallbackQuery(
|
|
||||||
id="fake",
|
|
||||||
from_user=message.from_user,
|
|
||||||
chat_instance="fake",
|
|
||||||
data=f"admin_edit_lottery_select_{edit_lottery_id}",
|
|
||||||
message=message
|
|
||||||
)
|
|
||||||
await choose_edit_field(fake_callback, state)
|
|
||||||
else:
|
else:
|
||||||
await message.answer("❌ Ошибка при изменении призов")
|
await message.answer("❌ Ошибка при изменении призов")
|
||||||
return
|
return
|
||||||
@@ -420,7 +421,7 @@ async def process_lottery_prizes(message: Message, state: FSMContext):
|
|||||||
|
|
||||||
text += f"\n✅ Подтвердите создание розыгрыша:"
|
text += f"\n✅ Подтвердите создание розыгрыша:"
|
||||||
|
|
||||||
await message.answer(
|
await answer_plain_pages(message,
|
||||||
text,
|
text,
|
||||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[InlineKeyboardButton(text="✅ Создать", callback_data="confirm_create_lottery")],
|
[InlineKeyboardButton(text="✅ Создать", callback_data="confirm_create_lottery")],
|
||||||
@@ -765,7 +766,7 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.add_participant_user)
|
await state.set_state(AdminStates.add_participant_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text)
|
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text, NotCommand())
|
||||||
async def process_add_participant(message: Message, state: FSMContext):
|
async def process_add_participant(message: Message, state: FSMContext):
|
||||||
"""Обработка добавления участника"""
|
"""Обработка добавления участника"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -886,7 +887,7 @@ async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMC
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text)
|
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text, NotCommand())
|
||||||
async def process_remove_participant(message: Message, state: FSMContext):
|
async def process_remove_participant(message: Message, state: FSMContext):
|
||||||
"""Обработка удаления участника"""
|
"""Обработка удаления участника"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -1161,7 +1162,7 @@ async def start_search_participants(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.participant_search)
|
await state.set_state(AdminStates.participant_search)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.participant_search), F.text)
|
@admin_router.message(StateFilter(AdminStates.participant_search), F.text, NotCommand())
|
||||||
async def process_search_participants(message: Message, state: FSMContext):
|
async def process_search_participants(message: Message, state: FSMContext):
|
||||||
"""Обработка поиска участников"""
|
"""Обработка поиска участников"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -1169,6 +1170,9 @@ async def process_search_participants(message: Message, state: FSMContext):
|
|||||||
return
|
return
|
||||||
|
|
||||||
search_term = message.text.strip()
|
search_term = message.text.strip()
|
||||||
|
if not 1 <= len(search_term) <= 100:
|
||||||
|
await message.answer("Поисковый запрос должен содержать от 1 до 100 символов. /cancel — отмена.")
|
||||||
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
users = await UserService.search_users(session, search_term)
|
users = await UserService.search_users(session, search_term)
|
||||||
@@ -1279,7 +1283,7 @@ async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.add_participant_bulk)
|
await state.set_state(AdminStates.add_participant_bulk)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.add_participant_bulk))
|
@admin_router.message(StateFilter(AdminStates.add_participant_bulk), F.text, NotCommand())
|
||||||
async def process_bulk_add_participant(message: Message, state: FSMContext):
|
async def process_bulk_add_participant(message: Message, state: FSMContext):
|
||||||
"""Обработка массового добавления участников"""
|
"""Обработка массового добавления участников"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -1290,7 +1294,10 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
|
|||||||
lottery_id = data['bulk_add_lottery_id']
|
lottery_id = data['bulk_add_lottery_id']
|
||||||
|
|
||||||
# Парсим входные данные
|
# Парсим входные данные
|
||||||
user_inputs = [x.strip() for x in message.text.split(',') if x.strip()]
|
user_inputs = [x.strip() for x in message.text.replace('\n', ',').split(',') if x.strip()]
|
||||||
|
if not user_inputs:
|
||||||
|
await message.answer('Введите хотя бы один Telegram ID или @username, либо /cancel.')
|
||||||
|
return
|
||||||
telegram_ids = []
|
telegram_ids = []
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -1302,11 +1309,14 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
|
|||||||
if user:
|
if user:
|
||||||
telegram_ids.append(user.telegram_id)
|
telegram_ids.append(user.telegram_id)
|
||||||
elif user_input.isdigit():
|
elif user_input.isdigit():
|
||||||
telegram_ids.append(int(user_input))
|
telegram_ids.append(numeric_id(user_input))
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Массовое добавление
|
# Массовое добавление
|
||||||
|
if not telegram_ids or len(telegram_ids) != len(user_inputs):
|
||||||
|
await message.answer("Часть ID или username неверна либо не найдена. Исправьте список и повторите ввод. Изменения не внесены.")
|
||||||
|
return
|
||||||
results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids)
|
results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids)
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
|
|
||||||
@@ -1410,7 +1420,7 @@ async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.remove_participant_bulk)
|
await state.set_state(AdminStates.remove_participant_bulk)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk))
|
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk), F.text, NotCommand())
|
||||||
async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
||||||
"""Обработка массового удаления участников"""
|
"""Обработка массового удаления участников"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -1421,7 +1431,10 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
|||||||
lottery_id = data['bulk_remove_lottery_id']
|
lottery_id = data['bulk_remove_lottery_id']
|
||||||
|
|
||||||
# Парсим входные данные
|
# Парсим входные данные
|
||||||
user_inputs = [x.strip() for x in message.text.split(',') if x.strip()]
|
user_inputs = [x.strip() for x in message.text.replace('\n', ',').split(',') if x.strip()]
|
||||||
|
if not user_inputs:
|
||||||
|
await message.answer('Введите хотя бы один Telegram ID или @username, либо /cancel.')
|
||||||
|
return
|
||||||
telegram_ids = []
|
telegram_ids = []
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -1433,11 +1446,14 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
|||||||
if user:
|
if user:
|
||||||
telegram_ids.append(user.telegram_id)
|
telegram_ids.append(user.telegram_id)
|
||||||
elif user_input.isdigit():
|
elif user_input.isdigit():
|
||||||
telegram_ids.append(int(user_input))
|
telegram_ids.append(numeric_id(user_input))
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Массовое удаление
|
# Массовое удаление
|
||||||
|
if not telegram_ids or len(telegram_ids) != len(user_inputs):
|
||||||
|
await message.answer("Часть ID или username неверна либо не найдена. Исправьте список и повторите ввод. Изменения не внесены.")
|
||||||
|
return
|
||||||
results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids)
|
results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids)
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
|
|
||||||
@@ -1501,7 +1517,7 @@ async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext)
|
|||||||
await state.set_state(AdminStates.add_to_lottery_user)
|
await state.set_state(AdminStates.add_to_lottery_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text)
|
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text, NotCommand())
|
||||||
async def process_add_to_lottery(message: Message, state: FSMContext):
|
async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||||
"""Обработка добавления участника в конкретный розыгрыш"""
|
"""Обработка добавления участника в конкретный розыгрыш"""
|
||||||
if not await is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
@@ -1637,7 +1653,7 @@ async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMCon
|
|||||||
await state.set_state(AdminStates.remove_from_lottery_user)
|
await state.set_state(AdminStates.remove_from_lottery_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text)
|
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text, NotCommand())
|
||||||
async def process_remove_from_lottery(message: Message, state: FSMContext):
|
async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||||
"""Обработка удаления участника из конкретного розыгрыша"""
|
"""Обработка удаления участника из конкретного розыгрыша"""
|
||||||
if not await is_admin(message.from_user.id):
|
if not await is_admin(message.from_user.id):
|
||||||
@@ -2002,7 +2018,7 @@ async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.add_participant_bulk_accounts)
|
await state.set_state(AdminStates.add_participant_bulk_accounts)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.add_participant_bulk_accounts))
|
@admin_router.message(StateFilter(AdminStates.add_participant_bulk_accounts), F.text, NotCommand())
|
||||||
async def process_bulk_add_accounts(message: Message, state: FSMContext):
|
async def process_bulk_add_accounts(message: Message, state: FSMContext):
|
||||||
"""Обработка массового добавления участников по номерам счетов"""
|
"""Обработка массового добавления участников по номерам счетов"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -2015,6 +2031,9 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext):
|
|||||||
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
|
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
|
||||||
from ..utils.account_utils import parse_accounts_from_message
|
from ..utils.account_utils import parse_accounts_from_message
|
||||||
account_inputs = parse_accounts_from_message(message.text)
|
account_inputs = parse_accounts_from_message(message.text)
|
||||||
|
if not account_inputs:
|
||||||
|
await message.answer("Счета не распознаны. Используйте формат 11-22-33-44-55-66-77 или /cancel.")
|
||||||
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Массовое добавление по номерам счетов
|
# Массовое добавление по номерам счетов
|
||||||
@@ -2127,7 +2146,7 @@ async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext
|
|||||||
await state.set_state(AdminStates.remove_participant_bulk_accounts)
|
await state.set_state(AdminStates.remove_participant_bulk_accounts)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk_accounts))
|
@admin_router.message(StateFilter(AdminStates.remove_participant_bulk_accounts), F.text, NotCommand())
|
||||||
async def process_bulk_remove_accounts(message: Message, state: FSMContext):
|
async def process_bulk_remove_accounts(message: Message, state: FSMContext):
|
||||||
"""Обработка массового удаления участников по номерам счетов"""
|
"""Обработка массового удаления участников по номерам счетов"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -2140,6 +2159,9 @@ async def process_bulk_remove_accounts(message: Message, state: FSMContext):
|
|||||||
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
|
# Используем функцию парсинга из account_utils для корректной обработки формата "КАРТА СЧЕТ"
|
||||||
from ..utils.account_utils import parse_accounts_from_message
|
from ..utils.account_utils import parse_accounts_from_message
|
||||||
account_inputs = parse_accounts_from_message(message.text)
|
account_inputs = parse_accounts_from_message(message.text)
|
||||||
|
if not account_inputs:
|
||||||
|
await message.answer("Счета не распознаны. Используйте формат 11-22-33-44-55-66-77 или /cancel.")
|
||||||
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Массовое удаление по номерам счетов
|
# Массовое удаление по номерам счетов
|
||||||
@@ -2408,13 +2430,23 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
|
|||||||
lottery_id = int(callback.data.split("_")[-1])
|
lottery_id = int(callback.data.split("_")[-1])
|
||||||
await state.update_data(edit_lottery_id=lottery_id)
|
await state.update_data(edit_lottery_id=lottery_id)
|
||||||
|
|
||||||
|
await state.set_state(None)
|
||||||
|
await callback.answer()
|
||||||
|
await show_edit_fields(callback.message, lottery_id, edit=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_edit_fields(message: Message, lottery_id: int, edit=False):
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
|
|
||||||
|
if lottery is None:
|
||||||
|
await message.answer("Розыгрыш не найден. Откройте /admin.")
|
||||||
|
return
|
||||||
|
|
||||||
text = f"📝 Редактирование: {lottery.title}\n\n"
|
text = f"📝 Редактирование: {lottery.title}\n\n"
|
||||||
text += "Выберите, что хотите изменить:\n\n"
|
text += "Выберите, что хотите изменить:\n\n"
|
||||||
text += f"📝 Название: {lottery.title}\n"
|
text += f"📝 Название: {lottery.title}\n"
|
||||||
text += f"📄 Описание: {lottery.description[:50]}{'...' if len(lottery.description) > 50 else ''}\n"
|
text += f"📄 Описание: {(lottery.description or "")[:50]}{'...' if len(lottery.description or "") > 50 else ''}\n"
|
||||||
text += f"🎁 Призы: {len(getattr(lottery, 'prizes', []))} шт.\n"
|
text += f"🎁 Призы: {len(getattr(lottery, 'prizes', []))} шт.\n"
|
||||||
text += f"🎭 Отображение: {getattr(lottery, 'winner_display_type', 'username')}\n"
|
text += f"🎭 Отображение: {getattr(lottery, 'winner_display_type', 'username')}\n"
|
||||||
text += f"🟢 Активен: {'Да' if getattr(lottery, 'is_active', True) else 'Нет'}"
|
text += f"🟢 Активен: {'Да' if getattr(lottery, 'is_active', True) else 'Нет'}"
|
||||||
@@ -2431,7 +2463,7 @@ async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
|
|||||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_edit_lottery")]
|
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_edit_lottery")]
|
||||||
]
|
]
|
||||||
|
|
||||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
await (message.edit_text if edit else message.answer)(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||||
|
|
||||||
|
|
||||||
@admin_router.callback_query(F.data.startswith("admin_toggle_active_"))
|
@admin_router.callback_query(F.data.startswith("admin_toggle_active_"))
|
||||||
@@ -2776,7 +2808,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.set_winner_place)
|
await state.set_state(AdminStates.set_winner_place)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.set_winner_place))
|
@admin_router.message(StateFilter(AdminStates.set_winner_place), F.text, NotCommand())
|
||||||
async def process_winner_place(message: Message, state: FSMContext):
|
async def process_winner_place(message: Message, state: FSMContext):
|
||||||
"""Обработка места победителя"""
|
"""Обработка места победителя"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -2784,7 +2816,7 @@ async def process_winner_place(message: Message, state: FSMContext):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
place = int(message.text)
|
place = numeric_id(message.text, maximum=2**31 - 1)
|
||||||
if place < 1:
|
if place < 1:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -2798,6 +2830,9 @@ async def process_winner_place(message: Message, state: FSMContext):
|
|||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||||
|
|
||||||
|
if not lottery or place > len(lottery.prizes or []):
|
||||||
|
await message.answer("Номер места должен соответствовать одному из призов розыгрыша.")
|
||||||
|
return
|
||||||
if lottery.manual_winners and str(place) in lottery.manual_winners:
|
if lottery.manual_winners and str(place) in lottery.manual_winners:
|
||||||
existing_id = lottery.manual_winners[str(place)]
|
existing_id = lottery.manual_winners[str(place)]
|
||||||
existing_user = await UserService.get_user_by_telegram_id(session, existing_id)
|
existing_user = await UserService.get_user_by_telegram_id(session, existing_id)
|
||||||
@@ -2824,7 +2859,7 @@ async def process_winner_place(message: Message, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.set_winner_user)
|
await state.set_state(AdminStates.set_winner_user)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text)
|
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text, NotCommand())
|
||||||
async def process_winner_user(message: Message, state: FSMContext):
|
async def process_winner_user(message: Message, state: FSMContext):
|
||||||
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
|
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -4397,7 +4432,7 @@ async def admin_import_users_start(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.import_users_json)
|
await state.set_state(AdminStates.import_users_json)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
|
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document, NotCommand())
|
||||||
async def admin_import_users_process(message: Message, state: FSMContext):
|
async def admin_import_users_process(message: Message, state: FSMContext):
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
return
|
return
|
||||||
@@ -4644,7 +4679,7 @@ BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть со
|
|||||||
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
|
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker)
|
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker, NotCommand())
|
||||||
async def admin_broadcast_send(message: Message, state: FSMContext):
|
async def admin_broadcast_send(message: Message, state: FSMContext):
|
||||||
"""Обработка и отправка рассылки"""
|
"""Обработка и отправка рассылки"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -4876,14 +4911,14 @@ async def admin_broadcast_add_channel_start(callback: CallbackQuery, state: FSMC
|
|||||||
await state.set_state(AdminStates.broadcast_add_channel_id)
|
await state.set_state(AdminStates.broadcast_add_channel_id)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_id), F.text)
|
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_id), F.text, NotCommand())
|
||||||
async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
||||||
"""Обработка ID канала"""
|
"""Обработка ID канала"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chat_id = int(message.text.strip())
|
chat_id = numeric_id(message.text, signed=True)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ Неверный формат ID. Отправьте число, например: -1001234567890"
|
"❌ Неверный формат ID. Отправьте число, например: -1001234567890"
|
||||||
@@ -4903,7 +4938,6 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
|||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ Неверный тип чата. Поддерживаются только каналы и группы."
|
"❌ Неверный тип чата. Поддерживаются только каналы и группы."
|
||||||
)
|
)
|
||||||
await state.clear()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Сохраняем данные
|
# Сохраняем данные
|
||||||
@@ -4917,7 +4951,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
|||||||
# Запрашиваем описание
|
# Запрашиваем описание
|
||||||
text = (
|
text = (
|
||||||
f"✅ <b>Канал найден!</b>\n\n"
|
f"✅ <b>Канал найден!</b>\n\n"
|
||||||
f"📱 <b>Название:</b> {chat.title}\n"
|
f"📱 <b>Название:</b> {escape(chat.title or "")}\n"
|
||||||
f"🆔 <b>ID:</b> <code>{chat_id}</code>\n"
|
f"🆔 <b>ID:</b> <code>{chat_id}</code>\n"
|
||||||
f"📝 <b>Тип:</b> {'Канал' if chat_type == 'channel' else 'Группа'}\n"
|
f"📝 <b>Тип:</b> {'Канал' if chat_type == 'channel' else 'Группа'}\n"
|
||||||
)
|
)
|
||||||
@@ -4939,10 +4973,9 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
|||||||
f"Детали: {public_error(e)}",
|
f"Детали: {public_error(e)}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_title), F.text)
|
@admin_router.message(StateFilter(AdminStates.broadcast_add_channel_title), F.text, NotCommand("skip"))
|
||||||
async def admin_broadcast_add_channel_description(message: Message, state: FSMContext):
|
async def admin_broadcast_add_channel_description(message: Message, state: FSMContext):
|
||||||
"""Обработка описания канала"""
|
"""Обработка описания канала"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
@@ -4950,7 +4983,7 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
|
|||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
|
|
||||||
description = None if message.text.strip() == '/skip' else message.text.strip()
|
description = None if message.text.strip().split('@', 1)[0].lower() == '/skip' else message.text.strip()
|
||||||
|
|
||||||
# Сохраняем в БД
|
# Сохраняем в БД
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -4977,10 +5010,11 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
|
|||||||
existing.description = description
|
existing.description = description
|
||||||
existing.chat_type = data['chat_type']
|
existing.chat_type = data['chat_type']
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"✅ <b>Канал обновлен!</b>\n\n"
|
"✅ <b>Канал обновлен!</b>\n\n"
|
||||||
f"📱 {data['title']}",
|
f"📱 {escape(data['title'])}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -4995,10 +5029,11 @@ async def admin_broadcast_add_channel_description(message: Message, state: FSMCo
|
|||||||
)
|
)
|
||||||
session.add(channel)
|
session.add(channel)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"✅ <b>Канал добавлен!</b>\n\n"
|
"✅ <b>Канал добавлен!</b>\n\n"
|
||||||
f"📱 {data['title']}\n"
|
f"📱 {escape(data['title'])}\n"
|
||||||
f"Теперь вы можете использовать его для рассылок.",
|
f"Теперь вы можете использовать его для рассылок.",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
@@ -5209,13 +5244,16 @@ async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.set_state(AdminStates.user_management_search)
|
await state.set_state(AdminStates.user_management_search)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.message(AdminStates.user_management_search, F.text)
|
@admin_router.message(AdminStates.user_management_search, F.text, NotCommand())
|
||||||
async def admin_users_search_process(message: Message, state: FSMContext):
|
async def admin_users_search_process(message: Message, state: FSMContext):
|
||||||
"""Обработка поискового запроса"""
|
"""Обработка поискового запроса"""
|
||||||
if not await check_admin_access(message.from_user.id):
|
if not await check_admin_access(message.from_user.id):
|
||||||
return
|
return
|
||||||
|
|
||||||
query = message.text.strip()
|
query = message.text.strip()
|
||||||
|
if not 1 <= len(query) <= 100:
|
||||||
|
await message.answer("Поисковый запрос должен содержать от 1 до 100 символов. /cancel — отмена.")
|
||||||
|
return
|
||||||
|
|
||||||
if query == "/cancel":
|
if query == "/cancel":
|
||||||
await message.answer("❌ Поиск отменен")
|
await message.answer("❌ Поиск отменен")
|
||||||
@@ -5233,12 +5271,12 @@ async def admin_users_search_process(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not users:
|
if not users:
|
||||||
text = f"❌ По запросу «{query}» ничего не найдено"
|
text = f"❌ По запросу «{escape(query)}» ничего не найдено"
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton(text="◀️ В управление пользователями", callback_data="admin_users")]
|
[InlineKeyboardButton(text="◀️ В управление пользователями", callback_data="admin_users")]
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
text = f"🔍 <b>Результаты поиска:</b> «{query}»\n"
|
text = f"🔍 <b>Результаты поиска:</b> «{escape(query)}»\n"
|
||||||
text += f"Найдено: {total} пользователей\n\n"
|
text += f"Найдено: {total} пользователей\n\n"
|
||||||
|
|
||||||
buttons = []
|
buttons = []
|
||||||
@@ -5262,7 +5300,7 @@ async def admin_users_search_process(message: Message, state: FSMContext):
|
|||||||
if total > 15:
|
if total > 15:
|
||||||
nav_buttons.append(InlineKeyboardButton(
|
nav_buttons.append(InlineKeyboardButton(
|
||||||
text="➡️ Далее",
|
text="➡️ Далее",
|
||||||
callback_data=f"admin_users_search_page:{query}:2"
|
callback_data=f"admin_users_search_page:{escape(query)}:2"
|
||||||
))
|
))
|
||||||
buttons.append(nav_buttons)
|
buttons.append(nav_buttons)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
|
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.filters import Command
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
@@ -10,7 +11,7 @@ from src.core.database import async_session_maker
|
|||||||
from src.core.services import LotteryService
|
from src.core.services import LotteryService
|
||||||
from src.handlers.account_services import AccountParticipationService
|
from src.handlers.account_services import AccountParticipationService
|
||||||
from src.middlewares.access import AccessMiddleware
|
from src.middlewares.access import AccessMiddleware
|
||||||
from src.utils.account_utils import parse_accounts_from_message
|
from src.utils.account_input import parse_account_records
|
||||||
|
|
||||||
cashier_router = Router(name="cashier")
|
cashier_router = Router(name="cashier")
|
||||||
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
|
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
|
||||||
@@ -58,9 +59,12 @@ async def choose_draw(callback: CallbackQuery, state: FSMContext):
|
|||||||
await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.")
|
await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.")
|
||||||
|
|
||||||
|
|
||||||
@cashier_router.message(CashierStates.accounts, F.text)
|
@cashier_router.message(CashierStates.accounts, F.text, NotCommand())
|
||||||
async def add_accounts(message: Message, state: FSMContext):
|
async def add_accounts(message: Message, state: FSMContext):
|
||||||
accounts = parse_accounts_from_message(message.text)
|
accounts, errors = parse_account_records(message.text)
|
||||||
|
if errors:
|
||||||
|
await message.answer("Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
|
||||||
|
return
|
||||||
if not accounts or len(accounts) > 1000:
|
if not accounts or len(accounts) > 1000:
|
||||||
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
|
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Обработчики пользовательских сообщений в чате"""
|
"""Обработчики пользовательских сообщений в чате"""
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
from src.core.access import is_admin
|
from src.core.access import is_admin
|
||||||
from src.utils.delivery import background_delivery
|
from src.utils.delivery import background_delivery
|
||||||
@@ -90,7 +91,7 @@ async def enter_chat(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat))
|
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat), NotCommand())
|
||||||
async def exit_chat_command(message: Message, state: FSMContext):
|
async def exit_chat_command(message: Message, state: FSMContext):
|
||||||
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
|
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
|
||||||
await exit_chat(message, state)
|
await exit_chat(message, state)
|
||||||
@@ -140,7 +141,7 @@ async def exit_chat(message: Message, state: FSMContext, actor=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(ChatStates.in_chat), F.text)
|
@router.message(StateFilter(ChatStates.in_chat), F.text, NotCommand())
|
||||||
async def check_exit_keywords(message: Message, state: FSMContext):
|
async def check_exit_keywords(message: Message, state: FSMContext):
|
||||||
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
|
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
|
||||||
import logging
|
import logging
|
||||||
@@ -156,15 +157,8 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
|||||||
# Выходим из чата и показываем главное меню
|
# Выходим из чата и показываем главное меню
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
from src.components.ui import UserUI
|
from main import cmd_start
|
||||||
keyboard = UserUI.get_main_menu_keyboard(message.from_user.id)
|
await cmd_start(message, state)
|
||||||
|
|
||||||
await message.answer(
|
|
||||||
"🏠 <b>Главное меню</b>\n\n"
|
|
||||||
"Вы вышли из режима чата.",
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
return # Не обрабатываем дальше
|
return # Не обрабатываем дальше
|
||||||
else:
|
else:
|
||||||
# Для /exit просто выходим
|
# Для /exit просто выходим
|
||||||
@@ -544,7 +538,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.photo, StateFilter(ChatStates.in_chat))
|
@router.message(F.photo, StateFilter(ChatStates.in_chat), NotCommand())
|
||||||
async def handle_photo_message(message: Message, state: FSMContext):
|
async def handle_photo_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик фото"""
|
"""Обработчик фото"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
@@ -614,7 +608,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Фото переслано в канал")
|
await message.answer("✅ Фото переслано в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.video, StateFilter(ChatStates.in_chat))
|
@router.message(F.video, StateFilter(ChatStates.in_chat), NotCommand())
|
||||||
async def handle_video_message(message: Message, state: FSMContext):
|
async def handle_video_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик видео"""
|
"""Обработчик видео"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
@@ -681,7 +675,7 @@ async def handle_video_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Видео переслано в канал")
|
await message.answer("✅ Видео переслано в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.document, StateFilter(ChatStates.in_chat))
|
@router.message(F.document, StateFilter(ChatStates.in_chat), NotCommand())
|
||||||
async def handle_document_message(message: Message, state: FSMContext):
|
async def handle_document_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик документов"""
|
"""Обработчик документов"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
@@ -748,7 +742,7 @@ async def handle_document_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Документ переслан в канал")
|
await message.answer("✅ Документ переслан в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.animation, StateFilter(ChatStates.in_chat))
|
@router.message(F.animation, StateFilter(ChatStates.in_chat), NotCommand())
|
||||||
async def handle_animation_message(message: Message, state: FSMContext):
|
async def handle_animation_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик GIF анимаций"""
|
"""Обработчик GIF анимаций"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
@@ -815,7 +809,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("✅ Анимация переслана в канал")
|
await message.answer("✅ Анимация переслана в канал")
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.sticker, StateFilter(ChatStates.in_chat))
|
@router.message(F.sticker, StateFilter(ChatStates.in_chat), NotCommand())
|
||||||
async def handle_sticker_message(message: Message, state: FSMContext):
|
async def handle_sticker_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик стикеров"""
|
"""Обработчик стикеров"""
|
||||||
# Защита от дубликатов
|
# Защита от дубликатов
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from src.core.access import is_admin
|
from src.core.access import is_admin
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.filters import Command
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
|
|||||||
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
|
import logging
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import StateFilter
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from ..core.config import ADMIN_IDS
|
from ..core.config import ADMIN_IDS
|
||||||
@@ -69,17 +70,17 @@ async def delete_message_callback(callback: CallbackQuery):
|
|||||||
|
|
||||||
|
|
||||||
# Функция-фильтр для проверки триггерных слов
|
# Функция-фильтр для проверки триггерных слов
|
||||||
def is_delete_trigger(message: Message) -> bool:
|
async def is_delete_trigger(message: Message) -> bool:
|
||||||
"""Проверяет, является ли сообщение триггером для удаления"""
|
"""Проверяет, является ли сообщение триггером для удаления"""
|
||||||
if not message.text:
|
if not message.text:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
text_lower = message.text.lower().strip()
|
text_lower = message.text.lower().strip()
|
||||||
triggers = ["удалить", "delete", "del", "🗑️", "🗑", "❌"]
|
triggers = ["удалить", "delete", "del", "🗑️", "🗑", "❌"]
|
||||||
return any(trigger in text_lower for trigger in triggers)
|
return text_lower in triggers and await is_admin(message.from_user.id)
|
||||||
|
|
||||||
|
|
||||||
@message_admin_router.message(F.reply_to_message, is_delete_trigger)
|
@message_admin_router.message(F.reply_to_message, is_delete_trigger, StateFilter(None, "ChatStates:in_chat", "P2PChatStates:chatting"))
|
||||||
async def quick_delete_replied_message(message: Message):
|
async def quick_delete_replied_message(message: Message):
|
||||||
"""
|
"""
|
||||||
Быстрое удаление сообщения по reply с триггерными словами или emoji
|
Быстрое удаление сообщения по reply с триггерными словами или emoji
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.filters import Command
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from aiogram.types import CallbackQuery, FSInputFile, Message
|
from aiogram.types import CallbackQuery, FSInputFile, Message
|
||||||
|
|
||||||
from src.core.access import get_role
|
from src.core.access import get_role
|
||||||
@@ -34,7 +34,7 @@ async def send_guide(event, kind):
|
|||||||
|
|
||||||
@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
|
@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
|
||||||
async def help_command(message: Message):
|
async def help_command(message: Message):
|
||||||
kind = message.text.split()[0].split("@")[0].removeprefix("/").removesuffix("_help")
|
kind = message.text.split()[0].split("@")[0].lower().removeprefix("/").removesuffix("_help")
|
||||||
await send_guide(message, kind)
|
await send_guide(message, kind)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Обработчики P2P чата между пользователями"""
|
"""Обработчики P2P чата между пользователями"""
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
from src.utils.telegram_messages import copy_preserving_entities
|
from src.utils.telegram_messages import copy_preserving_entities
|
||||||
from html import escape
|
from html import escape
|
||||||
from src.core.access import is_admin
|
from src.core.access import is_admin
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import StateFilter
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
@@ -356,7 +358,7 @@ async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
# Обработчик сообщений в состоянии chatting
|
# Обработчик сообщений в состоянии chatting
|
||||||
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
|
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document, NotCommand())
|
||||||
async def handle_p2p_message(message: Message, state: FSMContext):
|
async def handle_p2p_message(message: Message, state: FSMContext):
|
||||||
"""Обработка P2P сообщения от пользователя"""
|
"""Обработка P2P сообщения от пользователя"""
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
||||||
|
from src.utils.input_validation import numeric_id
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import random
|
import random
|
||||||
@@ -36,7 +37,7 @@ async def check_unclaimed_winners(message: Message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lottery_id = int(parts[1])
|
lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await message.answer("❌ lottery_id должен быть числом")
|
await message.answer("❌ lottery_id должен быть числом")
|
||||||
return
|
return
|
||||||
@@ -132,7 +133,11 @@ async def redraw_lottery(message: Message):
|
|||||||
if len(parts) != 2 or not parts[1].isdigit():
|
if len(parts) != 2 or not parts[1].isdigit():
|
||||||
await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
|
await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
|
||||||
return
|
return
|
||||||
lottery_id = int(parts[1])
|
try:
|
||||||
|
lottery_id = numeric_id(parts[1], maximum=2**31 - 1)
|
||||||
|
except ValueError as error:
|
||||||
|
await message.answer(str(error))
|
||||||
|
return
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
winners = await redraw_unclaimed(session, lottery_id)
|
winners = await redraw_unclaimed(session, lottery_id)
|
||||||
if winners:
|
if winners:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Обработчики для регистрации пользователей"""
|
"""Обработчики для регистрации пользователей"""
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from src.utils.errors import public_error
|
from src.utils.errors import public_error
|
||||||
|
from src.utils.input_validation import club_card, phone_number
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import StateFilter
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ async def start_registration(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_nickname), F.text)
|
@router.message(StateFilter(RegistrationStates.waiting_for_nickname), F.text, NotCommand())
|
||||||
async def process_nickname(message: Message, state: FSMContext):
|
async def process_nickname(message: Message, state: FSMContext):
|
||||||
"""Обработка никнейма"""
|
"""Обработка никнейма"""
|
||||||
nickname = message.text.strip()
|
nickname = message.text.strip()
|
||||||
@@ -110,62 +113,59 @@ async def process_nickname(message: Message, state: FSMContext):
|
|||||||
# Сохраняем никнейм
|
# Сохраняем никнейм
|
||||||
await state.update_data(nickname=nickname)
|
await state.update_data(nickname=nickname)
|
||||||
|
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_club_card)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"✅ Отлично! Ваш никнейм: {nickname}\n\n"
|
f"✅ Отлично! Ваш никнейм: {nickname}\n\n"
|
||||||
"Шаг 2 из 3: Клубная карта\n\n"
|
"Шаг 2 из 3: Клубная карта\n\n"
|
||||||
"📝 Введите номер вашей клубной карты:"
|
"📝 Введите номер вашей клубной карты:"
|
||||||
)
|
)
|
||||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text)
|
@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text, NotCommand())
|
||||||
async def process_club_card(message: Message, state: FSMContext):
|
async def process_club_card(message: Message, state: FSMContext):
|
||||||
"""Обработка номера клубной карты"""
|
"""Обработка номера клубной карты"""
|
||||||
club_card_number = message.text.strip()
|
try:
|
||||||
|
club_card_number = club_card(message.text)
|
||||||
|
except ValueError as error:
|
||||||
|
await message.answer(f"❌ {error}\nПопробуйте ещё раз.")
|
||||||
|
return
|
||||||
|
|
||||||
# Проверяем, не занята ли карта
|
# Проверяем, не занята ли карта
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
|
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
|
||||||
|
|
||||||
if existing_user:
|
if existing_user and existing_user.telegram_id != message.from_user.id:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
|
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
|
||||||
"Если это ваша карта, обратитесь к администратору."
|
"Если это ваша карта, обратитесь к администратору."
|
||||||
)
|
)
|
||||||
await state.clear()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
await state.update_data(club_card_number=club_card_number)
|
await state.update_data(club_card_number=club_card_number)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_phone)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"Шаг 3 из 3: Телефон\n\n"
|
"Шаг 3 из 3: Телефон\n\n"
|
||||||
"📱 Введите ваш номер телефона\n"
|
"📱 Введите ваш номер телефона\n"
|
||||||
"(или отправьте '-' чтобы пропустить):"
|
"(или отправьте '-' чтобы пропустить):"
|
||||||
)
|
)
|
||||||
await state.set_state(RegistrationStates.waiting_for_phone)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text)
|
@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text, NotCommand())
|
||||||
async def process_phone(message: Message, state: FSMContext):
|
async def process_phone(message: Message, state: FSMContext):
|
||||||
"""Обработка номера телефона"""
|
"""Обработка номера телефона"""
|
||||||
phone_input = message.text.strip()
|
try:
|
||||||
|
phone = phone_number(message.text)
|
||||||
# Проверяем, не отправил ли пользователь просто "-"
|
except ValueError as error:
|
||||||
if phone_input == "-":
|
await message.answer(f"❌ {error}")
|
||||||
phone = None
|
|
||||||
else:
|
|
||||||
# Валидируем телефон: не должно быть пустых или некорректных значений
|
|
||||||
if not phone_input:
|
|
||||||
await message.answer(
|
|
||||||
"❌ Неверный номер телефона.\n\n"
|
|
||||||
"Пожалуйста, введите корректный номер или отправьте '-' чтобы пропустить."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
phone = phone_input
|
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
club_card_number = data['club_card_number']
|
club_card_number = data.get('club_card_number')
|
||||||
nickname = data.get('nickname')
|
nickname = data.get('nickname')
|
||||||
|
if not club_card_number or not nickname:
|
||||||
|
await begin_registration(message, state)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -173,15 +173,21 @@ async def process_phone(message: Message, state: FSMContext):
|
|||||||
session,
|
session,
|
||||||
telegram_id=message.from_user.id,
|
telegram_id=message.from_user.id,
|
||||||
club_card_number=club_card_number,
|
club_card_number=club_card_number,
|
||||||
phone=phone
|
phone=phone,
|
||||||
|
nickname=nickname,
|
||||||
)
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
# The card may have been claimed after step 2, while this user entered a phone.
|
||||||
|
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.
|
||||||
if nickname:
|
await state.clear()
|
||||||
user.nickname = nickname
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user)
|
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"✅ Регистрация завершена!\n\n"
|
"✅ Регистрация завершена!\n\n"
|
||||||
f"🎭 Никнейм: {user.nickname}\n"
|
f"🎭 Никнейм: {user.nickname}\n"
|
||||||
@@ -192,14 +198,6 @@ async def process_phone(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode="HTML")
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
|
|
||||||
await state.clear()
|
|
||||||
except Exception as e:
|
|
||||||
await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("my_code"))
|
@router.message(Command("my_code"))
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""One staff-management dialog, restricted at every step to system admins."""
|
"""One staff-management dialog, restricted at every step to system admins."""
|
||||||
|
from src.filters.dialog_input import NotCommand
|
||||||
from html import escape
|
from html import escape
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from aiogram import BaseMiddleware, F, Router
|
from aiogram import BaseMiddleware, F, Router
|
||||||
from aiogram.filters import Command
|
from src.filters.case_insensitive import CaseInsensitiveCommand as Command
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||||
@@ -124,7 +125,7 @@ async def staff_command(message: Message, state: FSMContext):
|
|||||||
"user" if removal else role, role if removal else None)
|
"user" if removal else role, role if removal else None)
|
||||||
|
|
||||||
|
|
||||||
@staff_router.message(StaffStates.target, F.text)
|
@staff_router.message(StaffStates.target, F.text, NotCommand())
|
||||||
async def staff_target(message: Message, state: FSMContext):
|
async def staff_target(message: Message, state: FSMContext):
|
||||||
try:
|
try:
|
||||||
target_id = parse_target(message.text.strip())
|
target_id = parse_target(message.text.strip())
|
||||||
@@ -154,7 +155,7 @@ async def confirm_role(callback: CallbackQuery, state: FSMContext):
|
|||||||
"Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
|
"Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
|
||||||
|
|
||||||
|
|
||||||
@staff_router.message(StaffStates.confirm)
|
@staff_router.message(StaffStates.confirm, NotCommand())
|
||||||
async def confirmation_pending(message: Message):
|
async def confirmation_pending(message: Message):
|
||||||
await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")
|
await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")
|
||||||
|
|
||||||
|
|||||||
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]:
|
def parse_accounts_from_message(text: str) -> List[str]:
|
||||||
"""
|
"""Return all recognized records, preserving card numbers and leading zeroes."""
|
||||||
Извлекает все валидные номера счетов из текста сообщения.
|
from .account_input import parse_account_records
|
||||||
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
|
return parse_account_records(text or "")[0]
|
||||||
или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
|
|
||||||
|
|
||||||
Также обрабатывает многострочный текст из кабинета:
|
|
||||||
Запись начинается со слова "Viposnova" и содержит несколько строк до следующего "Viposnova":
|
|
||||||
"Viposnova 16-11-2025 22:19:36
|
|
||||||
17-24-66-42-38-31-53
|
|
||||||
0.00 2918"
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Текст сообщения
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[str]: Список найденных строк (может включать номер карты и счета через пробел)
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
|
|
||||||
accounts = []
|
|
||||||
|
|
||||||
# Группируем строки по записям (от "Viposnova" до следующего "Viposnova")
|
|
||||||
lines = text.strip().split('\n')
|
|
||||||
current_record = []
|
|
||||||
records = []
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
# Если строка начинается с Viposnova и у нас уже есть текущая запись - сохраняем её
|
|
||||||
if stripped.startswith('Viposnova') and current_record:
|
|
||||||
records.append(' '.join(current_record))
|
|
||||||
current_record = [stripped]
|
|
||||||
else:
|
|
||||||
current_record.append(stripped)
|
|
||||||
|
|
||||||
# Добавляем последнюю запись
|
|
||||||
if current_record:
|
|
||||||
records.append(' '.join(current_record))
|
|
||||||
|
|
||||||
# Обрабатываем каждую запись
|
|
||||||
for record in records:
|
|
||||||
parts = record.split()
|
|
||||||
|
|
||||||
# Ищем счет в записи
|
|
||||||
account_number = None
|
|
||||||
account_idx = None
|
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
|
|
||||||
account_number = format_account_number(part)
|
|
||||||
account_idx = i
|
|
||||||
break
|
|
||||||
|
|
||||||
if not account_number or account_number in accounts:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Ищем клубную карту (4-значное число после счета)
|
|
||||||
card = None
|
|
||||||
if account_idx is not None:
|
|
||||||
for j in range(account_idx + 1, len(parts)):
|
|
||||||
if re.match(r'^\d{4}$', parts[j]):
|
|
||||||
card = parts[j]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Добавляем результат
|
|
||||||
if card:
|
|
||||||
full_account = f"{card} {account_number}"
|
|
||||||
if full_account not in accounts:
|
|
||||||
accounts.append(full_account)
|
|
||||||
else:
|
|
||||||
accounts.append(account_number)
|
|
||||||
|
|
||||||
# Если построчная обработка ничего не нашла, используем старый метод
|
|
||||||
if not accounts:
|
|
||||||
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
|
|
||||||
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
|
|
||||||
|
|
||||||
# Находим все совпадения с картой и удаляем их из текста
|
|
||||||
text_copy = text
|
|
||||||
for match in re.finditer(pattern_with_card, text):
|
|
||||||
card = match.group(1)
|
|
||||||
account = match.group(2)
|
|
||||||
formatted = format_account_number(account)
|
|
||||||
if formatted:
|
|
||||||
full_account = f"{card} {formatted}"
|
|
||||||
if full_account not in accounts:
|
|
||||||
accounts.append(full_account)
|
|
||||||
# Удаляем это совпадение из копии текста, чтобы не найти повторно
|
|
||||||
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
|
|
||||||
|
|
||||||
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
|
|
||||||
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
|
|
||||||
matches_only = re.findall(pattern_only_account, text_copy)
|
|
||||||
|
|
||||||
for match in matches_only:
|
|
||||||
formatted = format_account_number(match)
|
|
||||||
if formatted and formatted not in accounts:
|
|
||||||
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
|
|
||||||
is_duplicate = any(formatted in acc for acc in accounts)
|
|
||||||
if not is_duplicate:
|
|
||||||
accounts.append(formatted)
|
|
||||||
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
|
|
||||||
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:
|
def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]:
|
||||||
|
|||||||
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