feat: доработки функционала бота
1. Подтверждение запуска розыгрыша: - Показ диалога с информацией об участниках и призах - Кнопки 'Да, провести' и 'Отмена' - Индикатор загрузки при проведении 2. Удаление сообщений администратором: - Команда /delete для удаления сообщений бота (ответ на сообщение) - Callback кнопка delete_message - Новый роутер message_admin_router 3. Определение владельцев счетов: - Извлечение номера клубной карты (последние 4 цифры) - Поиск владельца по club_card_number - Отображение владельца в списке обнаруженных счетов - Метод UserService.get_user_by_club_card() 4. Тестирование производительности: - Скрипт generate_test_accounts.py - Генерация файлов с 100, 500, 1000, 2000, 5000 счетов - Готовые тестовые файлы для проверки 5. Улучшения парсинга: - Обработка текста из кабинета с мусорными данными - Построчный парсинг с разбором по пробелам - Поддержка формата 'Viposnova 16-11-2025 22:19:36 17-24-66-42-38-31-53 0.00 2918' 6. Исправления багов: - AttributeError при отображении победителей без user_id - Безопасная обработка winner.user == None
This commit is contained in:
@@ -147,6 +147,23 @@ class UserService:
|
||||
formatted_number = format_account_number(account_number)
|
||||
if not formatted_number:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
|
||||
"""
|
||||
Получить пользователя по номеру клубной карты
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
club_card_number: Номер клубной карты (4 цифры)
|
||||
|
||||
Returns:
|
||||
User или None если не найден
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.account_number == formatted_number)
|
||||
|
||||
@@ -42,6 +42,7 @@ async def detect_account_input(message: Message, state: FSMContext):
|
||||
"""
|
||||
Обнаружение ввода счетов в сообщении
|
||||
Активируется только для администраторов
|
||||
Извлекает номер клубной карты и определяет владельца
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
return
|
||||
@@ -52,16 +53,82 @@ async def detect_account_input(message: Message, state: FSMContext):
|
||||
if not accounts:
|
||||
return # Счета не обнаружены, пропускаем
|
||||
|
||||
# Сохраняем счета в состоянии
|
||||
await state.update_data(detected_accounts=accounts)
|
||||
# Извлекаем номера клубных карт из последних 4 цифр каждого счета
|
||||
from ..core.services import UserService
|
||||
from ..core.registration_services import AccountService
|
||||
|
||||
# Формируем сообщение
|
||||
accounts_text = "\n".join([f"• {acc}" for acc in accounts])
|
||||
async with async_session_maker() as session:
|
||||
accounts_with_owners = []
|
||||
|
||||
for account in accounts:
|
||||
# Извлекаем только номер счета (без карты если есть)
|
||||
parts = account.split()
|
||||
account_number = parts[-1] if parts else account
|
||||
|
||||
# Извлекаем последние 4 цифры (номер клубной карты)
|
||||
digits_only = account_number.replace('-', '').replace(' ', '')
|
||||
if len(digits_only) >= 4:
|
||||
club_card = digits_only[-4:] # Последние 4 цифры
|
||||
|
||||
# Ищем пользователя по номеру клубной карты
|
||||
user = await UserService.get_user_by_club_card(session, club_card)
|
||||
|
||||
if user:
|
||||
owner_info = f"@{user.username}" if user.username else user.first_name
|
||||
accounts_with_owners.append({
|
||||
'account': account,
|
||||
'club_card': club_card,
|
||||
'owner': owner_info,
|
||||
'user_id': user.id
|
||||
})
|
||||
else:
|
||||
accounts_with_owners.append({
|
||||
'account': account,
|
||||
'club_card': club_card,
|
||||
'owner': None,
|
||||
'user_id': None
|
||||
})
|
||||
else:
|
||||
# Счет неверного формата
|
||||
accounts_with_owners.append({
|
||||
'account': account,
|
||||
'club_card': None,
|
||||
'owner': None,
|
||||
'user_id': None
|
||||
})
|
||||
|
||||
# Сохраняем счета в состоянии
|
||||
await state.update_data(
|
||||
detected_accounts=accounts,
|
||||
accounts_with_owners=accounts_with_owners
|
||||
)
|
||||
|
||||
# Формируем сообщение с владельцами
|
||||
accounts_text_parts = []
|
||||
for item in accounts_with_owners:
|
||||
account = item['account']
|
||||
club_card = item['club_card']
|
||||
owner = item['owner']
|
||||
|
||||
if owner:
|
||||
line = f"• {account} → {owner} (карта: {club_card})"
|
||||
elif club_card:
|
||||
line = f"• {account} (карта: {club_card}, владелец не найден)"
|
||||
else:
|
||||
line = f"• {account} (неверный формат)"
|
||||
|
||||
accounts_text_parts.append(line)
|
||||
|
||||
accounts_text = "\n".join(accounts_text_parts)
|
||||
count = len(accounts)
|
||||
|
||||
# Подсчёт найденных владельцев
|
||||
owners_found = sum(1 for item in accounts_with_owners if item['owner'])
|
||||
|
||||
text = (
|
||||
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
|
||||
f"Найдено: <b>{count}</b>\n\n"
|
||||
f"Найдено: <b>{count}</b>\n"
|
||||
f"Владельцев определено: <b>{owners_found}</b>\n\n"
|
||||
f"{accounts_text}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
|
||||
@@ -409,7 +409,14 @@ async def show_lottery_detail(callback: CallbackQuery):
|
||||
text += f"🏆 Результаты:\n"
|
||||
for winner in winners:
|
||||
manual_mark = " 👑" if winner.is_manual else ""
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
|
||||
# Безопасная обработка победителя - может быть без user_id
|
||||
if winner.user:
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
else:
|
||||
# Победитель по номеру счета без связанного пользователя
|
||||
username = f"Счет: {winner.account_number}"
|
||||
|
||||
text += f"{winner.place}. {username}{manual_mark}\n"
|
||||
|
||||
buttons = []
|
||||
@@ -2586,8 +2593,8 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_conduct_"))
|
||||
async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
"""Проведение розыгрыша"""
|
||||
async def conduct_lottery_draw_confirm(callback: CallbackQuery):
|
||||
"""Запрос подтверждения проведения розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
@@ -2611,6 +2618,61 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
await callback.answer("Нет участников для розыгрыша", show_alert=True)
|
||||
return
|
||||
|
||||
# Подсчёт призов
|
||||
prizes_count = len(lottery.prizes) if lottery.prizes else 0
|
||||
|
||||
# Формируем сообщение с подтверждением
|
||||
text = f"⚠️ <b>Подтверждение проведения розыгрыша</b>\n\n"
|
||||
text += f"🎲 <b>Розыгрыш:</b> {lottery.title}\n"
|
||||
text += f"👥 <b>Участников:</b> {participants_count}\n"
|
||||
text += f"🏆 <b>Призов:</b> {prizes_count}\n\n"
|
||||
|
||||
if lottery.prizes:
|
||||
text += "<b>Призы:</b>\n"
|
||||
for i, prize in enumerate(lottery.prizes, 1):
|
||||
text += f"{i}. {prize}\n"
|
||||
text += "\n"
|
||||
|
||||
text += "❗️ <b>Внимание:</b> После проведения розыгрыша результаты нельзя будет изменить!\n\n"
|
||||
text += "Продолжить?"
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✅ Да, провести розыгрыш", callback_data=f"admin_conduct_confirmed_{lottery_id}")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_{lottery_id}")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_"))
|
||||
async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
"""Проведение розыгрыша после подтверждения"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
|
||||
if not lottery:
|
||||
await callback.answer("Розыгрыш не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if lottery.is_completed:
|
||||
await callback.answer("Розыгрыш уже завершён", show_alert=True)
|
||||
return
|
||||
|
||||
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
|
||||
|
||||
if participants_count == 0:
|
||||
await callback.answer("Нет участников для розыгрыша", show_alert=True)
|
||||
return
|
||||
|
||||
# Показываем индикатор загрузки
|
||||
await callback.answer("⏳ Проводится розыгрыш...", show_alert=True)
|
||||
|
||||
# Проводим розыгрыш через сервис
|
||||
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
|
||||
|
||||
|
||||
65
src/handlers/message_management.py
Normal file
65
src/handlers/message_management.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Хэндлеры для управления сообщениями администратором
|
||||
"""
|
||||
import logging
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
|
||||
from ..core.config import ADMIN_IDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
message_admin_router = Router(name="message_admin")
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка, является ли пользователь администратором"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@message_admin_router.message(Command("delete"))
|
||||
async def delete_replied_message(message: Message):
|
||||
"""
|
||||
Удаление сообщения по команде /delete
|
||||
Работает только если команда является ответом на сообщение бота
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
if not message.reply_to_message:
|
||||
await message.answer("⚠️ Ответьте на сообщение бота командой /delete чтобы удалить его")
|
||||
return
|
||||
|
||||
if message.reply_to_message.from_user.id != message.bot.id:
|
||||
await message.answer("⚠️ Можно удалять только сообщения бота")
|
||||
return
|
||||
|
||||
try:
|
||||
# Удаляем сообщение бота
|
||||
await message.reply_to_message.delete()
|
||||
# Удаляем команду
|
||||
await message.delete()
|
||||
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
await message.answer(f"❌ Не удалось удалить сообщение: {str(e)}")
|
||||
|
||||
|
||||
@message_admin_router.callback_query(F.data == "delete_message")
|
||||
async def delete_message_callback(callback: CallbackQuery):
|
||||
"""
|
||||
Удаление сообщения по нажатию кнопки
|
||||
"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
await callback.answer("✅ Сообщение удалено")
|
||||
logger.info(f"Администратор {callback.from_user.id} удалил сообщение {callback.message.message_id} кнопкой")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
@@ -103,6 +103,9 @@ 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 16-11-2025 22:19:36 17-24-66-42-38-31-53 0.00 2918"
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
@@ -114,33 +117,58 @@ def parse_accounts_from_message(text: str) -> List[str]:
|
||||
|
||||
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})'
|
||||
# Обработка построчно - для текста из кабинета с мусорными данными
|
||||
lines = text.strip().split('\n')
|
||||
|
||||
# Находим все совпадения с картой и удаляем их из текста
|
||||
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)))
|
||||
for line in lines:
|
||||
# Разбиваем строку по пробелам
|
||||
parts = line.strip().split()
|
||||
|
||||
# Ищем в каждой части паттерны счетов
|
||||
for i, part in enumerate(parts):
|
||||
# Проверяем, является ли эта часть счетом (7 пар цифр через дефис)
|
||||
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
|
||||
formatted = format_account_number(part)
|
||||
if formatted and formatted not in accounts:
|
||||
# Проверяем предыдущую часть на номер карты (4 цифры)
|
||||
if i > 0 and re.match(r'^\d{4}$', parts[i-1]):
|
||||
card = parts[i-1]
|
||||
full_account = f"{card} {formatted}"
|
||||
if full_account not in accounts:
|
||||
accounts.append(full_account)
|
||||
else:
|
||||
# Добавляем только счет
|
||||
accounts.append(formatted)
|
||||
|
||||
# Паттерн 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)
|
||||
# Если построчная обработка ничего не нашла, используем старый метод
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user