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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
"""Keep commands out of ordinary form input and messages being relayed."""
from aiogram.filters import Filter
from aiogram.types import Message
class NotCommand(Filter):
def __init__(self, *allowed):
self.allowed = {name.lower() for name in allowed}
async def __call__(self, message: Message):
text = (message.text or "").lstrip()
if not text.startswith("/"):
return True
command = text.split()[0].split("@", 1)[0][1:].lower()
# /skip is an intentional value in the channel description dialog.
return command in self.allowed and len(text.split()) == 1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
"""Explain unmatched input instead of silently dropping it or broadcasting it."""
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
input_fallback_router = Router(name="input_fallback")
@input_fallback_router.message(F.text.lstrip().startswith("/"))
async def unknown_command(message: Message):
await message.answer("Команда не распознана или недоступна на этом шаге. /help — справка, /cancel — отменить текущий ввод.")
@input_fallback_router.message()
async def unmatched_input(message: Message, state: FSMContext):
if await state.get_state():
await message.answer("Этот ввод не подходит для текущего шага. Отправьте данные в формате из подсказки или используйте предложенные кнопки. /cancel — отмена.")
else:
await message.answer("Сейчас бот не ожидает эти данные. /start — главное меню, /register — регистрация. Для переписки сначала откройте чат.")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
"""Validation shared by dialog steps and database services."""
import re
def club_card(value):
value = value.strip() if isinstance(value, str) else ""
if not re.fullmatch(r"[0-9]{1,50}", value):
raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр без букв и разделителей.")
return value # Leading zeroes are part of the card number.
def phone_number(value):
if value is None or value.strip() == "-":
return None
value = value.strip()
if not value or len(value) > 20 or not re.fullmatch(r"\+?[0-9() -]+", value) or not re.search(r"[0-9]", value):
raise ValueError("Введите телефон: цифры, при необходимости +, пробелы, скобки или дефисы; не более 20 символов. Для пропуска отправьте '-'.")
return value
def numeric_id(value, *, maximum=2**63 - 1, signed=False):
value = str(value).strip()
pattern = r"-?[0-9]{1,19}" if signed else r"[0-9]{1,19}"
if not re.fullmatch(pattern, value) or not 0 < abs(int(value)) <= maximum:
raise ValueError("Некорректный ID: требуется число в допустимом диапазоне.")
return int(value)
def lottery_field(field, value):
if field == "title":
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 500:
raise ValueError("Название должно содержать от 1 до 500 символов.")
return value.strip()
if field == "description":
if value is None or value == "" or value.strip() == "-":
return None
if not value.strip() or len(value) > 4096:
raise ValueError("Введите описание до 4096 символов или '-' для пропуска.")
return value.strip()
if field == "prizes":
prizes = [line.strip() for line in value.splitlines() if line.strip()] if isinstance(value, str) else value
if not isinstance(prizes, list) or not 1 <= len(prizes) <= 100 or any(
not isinstance(prize, str) or not 1 <= len(prize.strip()) <= 500 for prize in prizes
):
raise ValueError("Введите от 1 до 100 призов, каждый с новой строки и не длиннее 500 символов.")
return [prize.strip() for prize in prizes]
raise ValueError("Неизвестное поле розыгрыша.")

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

@@ -0,0 +1,12 @@
"""Long plain-text form previews keep their action buttons on the last page."""
async def answer_plain_pages(message, text, **kwargs):
# A Python character occupies at most two Telegram UTF-16 units.
# Plain text needs neither entity splitting nor HTML tag repair.
while len(text) > 2000:
end = text.rfind("\n", 0, 2001)
end = end + 1 if end > 0 else 2000
await message.answer(text[:end], parse_mode=None)
text = text[end:]
return await message.answer(text, parse_mode=None, **kwargs)