From c0407fdb11e45701825574089f2130b7c142d4d3 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Mon, 9 Feb 2026 20:22:32 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=B2=D1=81=D0=B5=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок 1: Система никнеймов - ✅ Добавлено поле nickname в модель User - ✅ Создана миграция для nickname - ✅ Обновлена регистрация (3 шага: nickname → карта → телефон) - ✅ Валидация nickname (длина 2-20, проверка служебных слов) - ✅ Подписи в чате используют nickname Блок 2: Админские функции - ✅ Массовая рассылка (кнопка в админке, поддержка текста/фото/видео/документов) - ✅ Экспорт пользователей в JSON (бэкап с метаданными) - ✅ Импорт пользователей из JSON (восстановление с обновлением) Блок 3: Улучшения розыгрышей - ✅ Рассылка результатов розыгрыша всем участникам (кроме победителей) - ✅ Сообщения подтверждения показывают nickname + клубную карту - ✅ Ручное назначение победителя по номеру счета/telegram ID/username --- ...0_36_64c4f8a81afa_add_nickname_to_users.py | 26 + src/core/models.py | 1 + src/core/services.py | 8 +- src/handlers/admin_panel.py | 604 +++++++++++++++++- src/handlers/chat_handlers.py | 18 +- src/handlers/redraw_handlers.py | 32 +- src/handlers/registration_handlers.py | 84 ++- 7 files changed, 725 insertions(+), 48 deletions(-) create mode 100644 migrations/versions/20260209_2010_36_64c4f8a81afa_add_nickname_to_users.py diff --git a/migrations/versions/20260209_2010_36_64c4f8a81afa_add_nickname_to_users.py b/migrations/versions/20260209_2010_36_64c4f8a81afa_add_nickname_to_users.py new file mode 100644 index 0000000..e28b0a0 --- /dev/null +++ b/migrations/versions/20260209_2010_36_64c4f8a81afa_add_nickname_to_users.py @@ -0,0 +1,26 @@ +"""add_nickname_to_users + +Revision ID: 64c4f8a81afa +Revises: beb47ddbfc33 +Create Date: 2026-02-09 20:10:36.120201 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '64c4f8a81afa' +down_revision = 'beb47ddbfc33' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Добавляем поле nickname в таблицу users + op.add_column('users', sa.Column('nickname', sa.String(length=100), nullable=True)) + + +def downgrade() -> None: + # Удаляем поле nickname из таблицы users + op.drop_column('users', 'nickname') \ No newline at end of file diff --git a/src/core/models.py b/src/core/models.py index 70c6b69..69ea3d0 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -14,6 +14,7 @@ class User(Base): username = Column(String(255)) first_name = Column(String(255)) last_name = Column(String(255)) + nickname = Column(String(100), nullable=True) # Никнейм пользователя для чата phone = Column(String(20), nullable=True) # Телефон для верификации club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию diff --git a/src/core/services.py b/src/core/services.py index 3e46467..603c20b 100644 --- a/src/core/services.py +++ b/src/core/services.py @@ -13,7 +13,7 @@ class UserService: @staticmethod async def get_or_create_user(session: AsyncSession, telegram_id: int, username: str = None, first_name: str = None, - last_name: str = None) -> User: + last_name: str = None, nickname: str = None) -> User: """Получить или создать пользователя""" # Пробуем найти существующего пользователя result = await session.execute( @@ -26,6 +26,9 @@ class UserService: user.username = username user.first_name = first_name user.last_name = last_name + # Обновляем nickname только если он передан + if nickname is not None: + user.nickname = nickname await session.commit() return user @@ -34,7 +37,8 @@ class UserService: telegram_id=telegram_id, username=username, first_name=first_name, - last_name=last_name + last_name=last_name, + nickname=nickname ) session.add(user) await session.commit() diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index 60a4558..3a4c627 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -81,6 +81,12 @@ class AdminStates(StatesGroup): # Настройки отображения победителей lottery_display_type_select = State() lottery_display_type_set = State() + + # Массовая рассылка + broadcast_message = State() + + # Импорт/экспорт пользователей + import_users_json = State() admin_router = Router() @@ -98,6 +104,7 @@ def get_admin_main_keyboard() -> InlineKeyboardMarkup: [InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")], [InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")], [InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")], + [InlineKeyboardButton(text="📢 Рассылка", callback_data="admin_broadcast")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")], [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] ] @@ -2294,7 +2301,12 @@ async def process_winner_place(message: Message, state: FSMContext): text = f"👑 Установка победителя на {place} место\n" text += f"🎯 Розыгрыш: {lottery.title}\n\n" - text += f"Введите Telegram ID или username пользователя:" + text += ( + "Введите один из вариантов:\n" + "• Telegram ID (числовой ID)\n" + "• Username (с @ или без)\n" + "• Номер счета (формат: XX-XX-XX-XX-XX-XX-XX)" + ) await message.answer(text) await state.set_state(AdminStates.set_winner_user) @@ -2302,45 +2314,70 @@ async def process_winner_place(message: Message, state: FSMContext): @admin_router.message(StateFilter(AdminStates.set_winner_user)) async def process_winner_user(message: Message, state: FSMContext): - """Обработка пользователя-победителя""" + """Обработка пользователя-победителя (по ID, username или номеру счета)""" if not is_admin(message.from_user.id): await message.answer("❌ Недостаточно прав") return user_input = message.text.strip() - # Пробуем определить, это ID или username - if user_input.startswith('@'): - user_input = user_input[1:] # Убираем @ - is_username = True - elif user_input.isdigit(): - is_username = False - telegram_id = int(user_input) - else: - is_username = True + # Проверяем, это номер счета (формат XX-XX-XX-XX-XX-XX-XX) + is_account = '-' in user_input and len(user_input.split('-')) >= 5 - async with async_session_maker() as session: - if is_username: - # Поиск по username - from sqlalchemy import select - from ..core.models import User + if is_account: + # Обработка по номеру счета + from ..core.registration_services import AccountService + + async with async_session_maker() as session: + # Ищем владельца счета + owner = await AccountService.get_account_owner(session, user_input) - result = await session.execute( - select(User).where(User.username == user_input) - ) - user = result.scalar_one_or_none() - - if not user: - await message.answer("❌ Пользователь с таким username не найден") + if not owner: + await message.answer( + f"❌ Счет {user_input} не найден в системе.\n" + f"Проверьте правильность номера счета." + ) return - telegram_id = user.telegram_id + telegram_id = owner.telegram_id + display_name = owner.nickname if owner.nickname else (f"@{owner.username}" if owner.username else owner.first_name) + else: + # Обработка по ID или username + # Пробуем определить, это ID или username + if user_input.startswith('@'): + user_input = user_input[1:] # Убираем @ + is_username = True + elif user_input.isdigit(): + is_username = False + telegram_id = int(user_input) else: - user = await UserService.get_user_by_telegram_id(session, telegram_id) - - if not user: - await message.answer("❌ Пользователь с таким ID не найден") - return + is_username = True + + async with async_session_maker() as session: + if is_username: + # Поиск по username + from sqlalchemy import select + from ..core.models import User + + result = await session.execute( + select(User).where(User.username == user_input) + ) + user = result.scalar_one_or_none() + + if not user: + await message.answer("❌ Пользователь с таким username не найден") + return + + telegram_id = user.telegram_id + display_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) + else: + user = await UserService.get_user_by_telegram_id(session, telegram_id) + + if not user: + await message.answer("❌ Пользователь с таким ID не найден") + return + + display_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) data = await state.get_data() @@ -2355,13 +2392,13 @@ async def process_winner_user(message: Message, state: FSMContext): await state.clear() if success: - username = f"@{user.username}" if user.username else user.first_name await message.answer( f"✅ Предопределенный победитель установлен!\n\n" f"🏆 Место: {data['place']}\n" - f"👤 Пользователь: {username}\n" - f"🆔 ID: {telegram_id}\n\n" - f"При проведении розыгрыша этот пользователь автоматически займет {data['place']} место.", + f"👤 Пользователь: {display_name}\n" + f"🆔 ID: {telegram_id}\n" + + (f"💳 Счет: {user_input}\n" if is_account else "") + + f"\n⚡ При проведении розыгрыша этот пользователь автоматически займет {data['place']} место.", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👑 К управлению победителями", callback_data="admin_winners")] ]) @@ -2886,6 +2923,13 @@ async def conduct_lottery_draw(callback: CallbackQuery): except Exception as e: logger.error(f"Ошибка при отправке уведомлений: {e}") + # Отправляем результаты розыгрыша всем участникам (кроме победителей) + try: + await _notify_all_participants_about_results(callback.bot, session, lottery_id, winners_dict) + logger.info(f"Результаты розыгрыша разосланы всем участникам {lottery_id}") + except Exception as e: + logger.error(f"Ошибка при рассылке результатов: {e}") + # Получаем победителей из базы winners = await LotteryService.get_winners(session, lottery_id) text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n" @@ -3006,7 +3050,9 @@ async def show_admin_settings(callback: CallbackQuery): text += "Доступные действия:" buttons = [ - [InlineKeyboardButton(text="💾 Экспорт данных", callback_data="admin_export_data")], + [InlineKeyboardButton(text="� Экспорт пользователей (JSON)", callback_data="admin_export_users")], + [InlineKeyboardButton(text="📤 Импорт пользователей (JSON)", callback_data="admin_import_users")], + [InlineKeyboardButton(text="�💾 Экспорт данных", callback_data="admin_export_data")], [InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")], [InlineKeyboardButton(text="📋 Системная информация", callback_data="admin_system_info")], [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] @@ -3701,5 +3747,495 @@ async def show_user_messages(callback: CallbackQuery): ) +async def _notify_all_participants_about_results(bot, session: AsyncSession, lottery_id: int, winners_dict: dict): + """ + Рассылает результаты розыгрыша всем зарегистрированным пользователям (кроме победителей) + + Args: + bot: Экземпляр бота + session: Сессия БД + lottery_id: ID розыгрыша + winners_dict: Словарь с победителями {место: данные} + """ + import asyncio + + # Получаем розыгрыш + lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + return + + # Получаем всех зарегистрированных пользователей + all_users = await UserService.get_all_users(session) + registered_users = [u for u in all_users if u.is_registered] + + # Получаем telegram_id всех победителей + winners = await LotteryService.get_winners(session, lottery_id) + winner_telegram_ids = set() + for winner in winners: + if winner.user and winner.user.telegram_id: + winner_telegram_ids.add(winner.user.telegram_id) + elif winner.account_number: + # Ищем владельца счета + from ..core.registration_services import AccountService + owner = await AccountService.get_account_owner(session, winner.account_number) + if owner and owner.telegram_id: + winner_telegram_ids.add(owner.telegram_id) + + # Формируем сообщение с результатами + message = ( + f"📢 Результаты розыгрыша\n\n" + f"🎯 {lottery.title}\n\n" + f"🏆 Победители:\n" + ) + + for winner in winners: + nickname = None + display_name = None + + # Определяем отображаемое имя победителя + if winner.user: + nickname = winner.user.nickname + if not nickname: + display_name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + elif winner.account_number: + from ..core.registration_services import AccountService + owner = await AccountService.get_account_owner(session, winner.account_number) + if owner: + nickname = owner.nickname + if not nickname: + display_name = f"@{owner.username}" if owner.username else owner.first_name + + # Формируем строку победителя + winner_name = nickname if nickname else display_name if display_name else f"Счет {winner.account_number}" + message += f"{winner.place} место: {winner_name}\n" + + message += ( + f"\n🎁 Поздравляем победителей!\n" + f"📌 Победители получат уведомления с инструкциями для получения призов." + ) + + # Рассылаем всем кроме победителей + success_count = 0 + fail_count = 0 + + for user in registered_users: + # Пропускаем победителей + if user.telegram_id in winner_telegram_ids: + continue + + try: + await bot.send_message( + user.telegram_id, + message, + parse_mode="HTML" + ) + success_count += 1 + await asyncio.sleep(0.05) # Небольшая задержка между сообщениями + except Exception as e: + logger.warning(f"Не удалось отправить результаты пользователю {user.telegram_id}: {e}") + fail_count += 1 + + logger.info(f"Результаты розыгрыша разосланы: {success_count} успешно, {fail_count} ошибок") + + +# ============================================================================ +# ЭКСПОРТ И ИМПОРТ ПОЛЬЗОВАТЕЛЕЙ +# ============================================================================ + +@admin_router.callback_query(F.data == "admin_export_users") +async def admin_export_users(callback: CallbackQuery): + """Экспорт всех пользователей в JSON""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен", show_alert=True) + return + + await callback.answer("⏳ Формирую файл...", show_alert=False) + + async with async_session_maker() as session: + # Получаем всех пользователей + all_users = await UserService.get_all_users(session) + + # Формируем JSON + users_data = [] + for user in all_users: + user_dict = { + 'telegram_id': user.telegram_id, + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'nickname': user.nickname, + 'phone': user.phone, + 'club_card_number': user.club_card_number, + 'is_registered': user.is_registered, + 'is_admin': user.is_admin, + 'verification_code': user.verification_code, + 'created_at': user.created_at.isoformat() if user.created_at else None + } + users_data.append(user_dict) + + # Создаем JSON с метаданными + export_data = { + 'export_date': datetime.now().isoformat(), + 'total_users': len(users_data), + 'registered_users': len([u for u in users_data if u['is_registered']]), + 'version': '1.0', + 'users': users_data + } + + # Конвертируем в JSON + json_str = json.dumps(export_data, ensure_ascii=False, indent=2) + json_bytes = json_str.encode('utf-8') + + # Отправляем файл + from aiogram.types import BufferedInputFile + + filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + file = BufferedInputFile(json_bytes, filename=filename) + + await callback.message.answer_document( + document=file, + caption=( + f"📥 Экспорт пользователей\n\n" + f"📊 Всего пользователей: {len(users_data)}\n" + f"✅ Зарегистрировано: {export_data['registered_users']}\n" + f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}" + ), + parse_mode="HTML" + ) + + await callback.answer("✅ Файл отправлен", show_alert=False) + + +@admin_router.callback_query(F.data == "admin_import_users") +async def admin_import_users_start(callback: CallbackQuery, state: FSMContext): + """Начать импорт пользователей""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен", show_alert=True) + return + + text = ( + "📤 Импорт пользователей\n\n" + "Отправьте JSON файл с данными пользователей.\n\n" + "⚠️ Внимание!\n" + "• Будут обновлены существующие пользователи (по telegram_id)\n" + "• Новые пользователи будут добавлены\n" + "• Текущие данные не будут удалены\n\n" + "Отправьте /cancel для отмены" + ) + + buttons = [ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_settings")] + ] + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + await state.set_state(AdminStates.import_users_json) + + +@admin_router.message(StateFilter(AdminStates.import_users_json), F.document) +async def admin_import_users_process(message: Message, state: FSMContext): + """Обработка импорта пользователей из JSON""" + if not is_admin(message.from_user.id): + return + + # Проверяем формат файла + if not message.document.file_name.endswith('.json'): + await message.answer("❌ Неверный формат файла. Отправьте JSON файл.") + return + + status_msg = await message.answer("⏳ Загружаю файл...") + + try: + # Скачиваем файл + file = await message.bot.get_file(message.document.file_id) + file_content = await message.bot.download_file(file.file_path) + + # Парсим JSON + json_data = json.loads(file_content.read().decode('utf-8')) + + # Проверяем структуру + if 'users' not in json_data: + await status_msg.edit_text("❌ Неверная структура JSON. Не найден массив 'users'.") + await state.clear() + return + + users_data = json_data['users'] + + await status_msg.edit_text( + f"📊 Найдено пользователей в файле: {len(users_data)}\n" + f"⏳ Импортирую..." + ) + + # Импортируем пользователей + async with async_session_maker() as session: + added_count = 0 + updated_count = 0 + error_count = 0 + + for user_data in users_data: + try: + telegram_id = user_data.get('telegram_id') + if not telegram_id: + error_count += 1 + continue + + # Ищем существующего пользователя + existing_user = await UserService.get_user_by_telegram_id(session, telegram_id) + + if existing_user: + # Обновляем существующего + existing_user.username = user_data.get('username') + existing_user.first_name = user_data.get('first_name') + existing_user.last_name = user_data.get('last_name') + existing_user.nickname = user_data.get('nickname') + existing_user.phone = user_data.get('phone') + existing_user.club_card_number = user_data.get('club_card_number') + existing_user.is_registered = user_data.get('is_registered', False) + existing_user.verification_code = user_data.get('verification_code') + # is_admin не обновляем из соображений безопасности + + updated_count += 1 + else: + # Создаем нового + new_user = User( + telegram_id=telegram_id, + username=user_data.get('username'), + first_name=user_data.get('first_name'), + last_name=user_data.get('last_name'), + nickname=user_data.get('nickname'), + phone=user_data.get('phone'), + club_card_number=user_data.get('club_card_number'), + is_registered=user_data.get('is_registered', False), + is_admin=False, # Не импортируем админов из соображений безопасности + verification_code=user_data.get('verification_code') + ) + session.add(new_user) + added_count += 1 + + except Exception as e: + logger.error(f"Ошибка импорта пользователя {user_data.get('telegram_id')}: {e}") + error_count += 1 + + # Сохраняем изменения + await session.commit() + + # Итоговый отчет + await status_msg.edit_text( + f"✅ Импорт завершен!\n\n" + f"📊 Статистика:\n" + f"➕ Добавлено: {added_count}\n" + f"🔄 Обновлено: {updated_count}\n" + f"❌ Ошибок: {error_count}\n" + f"📝 Всего обработано: {added_count + updated_count}", + parse_mode="HTML" + ) + + await state.clear() + + except json.JSONDecodeError: + await status_msg.edit_text("❌ Ошибка чтения JSON. Проверьте формат файла.") + await state.clear() + except Exception as e: + logger.error(f"Ошибка импорта пользователей: {e}") + await status_msg.edit_text(f"❌ Ошибка импорта: {str(e)}") + await state.clear() + + +# ============================================================================ +# МАССОВАЯ РАССЫЛКА +# ============================================================================ + +@admin_router.callback_query(F.data == "admin_broadcast") +async def admin_broadcast_menu(callback: CallbackQuery, state: FSMContext): + """Меню массовой рассылки""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен", show_alert=True) + return + + async with async_session_maker() as session: + # Получаем статистику пользователей + all_users = await UserService.get_all_users(session) + registered_users = [u for u in all_users if u.is_registered] + + text = ( + "📢 Массовая рассылка\n\n" + f"👥 Всего пользователей: {len(all_users)}\n" + f"✅ Зарегистрировано: {len(registered_users)}\n\n" + "Нажмите кнопку ниже, чтобы отправить сообщение всем зарегистрированным пользователям." + ) + + buttons = [ + [InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] + ] + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + +@admin_router.callback_query(F.data == "admin_broadcast_start") +async def admin_broadcast_start(callback: CallbackQuery, state: FSMContext): + """Начать создание рассылки""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Доступ запрещен", show_alert=True) + return + + text = ( + "✉️ Создание рассылки\n\n" + "Отправьте сообщение для рассылки.\n" + "Вы можете отправить:\n" + "• Текст (поддерживается Markdown)\n" + "• Фото с подписью\n" + "• Видео с подписью\n" + "• Документ с подписью\n\n" + "Отправьте /cancel для отмены" + ) + + buttons = [ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_broadcast")] + ] + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + await state.set_state(AdminStates.broadcast_message) + + +@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document) +async def admin_broadcast_send(message: Message, state: FSMContext): + """Обработка и отправка рассылки""" + if not is_admin(message.from_user.id): + return + + # Отправляем уведомление о начале рассылки + status_msg = await message.answer( + "📤 Начинаю рассылку...\n\n" + "⏳ Подождите, это может занять некоторое время.", + parse_mode="HTML" + ) + + async with async_session_maker() as session: + # Получаем всех зарегистрированных пользователей + all_users = await UserService.get_all_users(session) + registered_users = [u for u in all_users if u.is_registered] + + success_count = 0 + fail_count = 0 + + # Рассылаем сообщение пакетами + from src.handlers.chat_handlers import BATCH_SIZE, BATCH_DELAY + import asyncio + + for i in range(0, len(registered_users), BATCH_SIZE): + batch = registered_users[i:i + BATCH_SIZE] + + # Отправляем пакет + tasks = [] + for user in batch: + tasks.append(_send_broadcast_to_user(message, user.telegram_id)) + + # Ждем завершения пакета + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Подсчитываем результаты + for result in results: + if isinstance(result, Exception): + fail_count += 1 + elif result: + success_count += 1 + else: + fail_count += 1 + + # Обновляем статус каждые 20 пользователей + if (i + BATCH_SIZE) % 60 == 0 or (i + BATCH_SIZE) >= len(registered_users): + progress = min(i + BATCH_SIZE, len(registered_users)) + try: + await status_msg.edit_text( + f"📤 Рассылка в процессе...\n\n" + f"📊 Прогресс: {progress}/{len(registered_users)}\n" + f"✅ Отправлено: {success_count}\n" + f"❌ Ошибок: {fail_count}", + parse_mode="HTML" + ) + except: + pass + + # Задержка между пакетами + if i + BATCH_SIZE < len(registered_users): + await asyncio.sleep(BATCH_DELAY) + + # Итоговый отчет + await status_msg.edit_text( + f"✅ Рассылка завершена!\n\n" + f"📊 Статистика:\n" + f"👥 Всего получателей: {len(registered_users)}\n" + f"✅ Доставлено: {success_count}\n" + f"❌ Не доставлено: {fail_count}\n\n" + f"📈 Процент доставки: {(success_count / len(registered_users) * 100):.1f}%", + parse_mode="HTML" + ) + + await state.clear() + + +async def _send_broadcast_to_user(message: Message, user_telegram_id: int) -> bool: + """ + Отправить сообщение рассылки конкретному пользователю + + Returns: + bool: True при успехе, False при ошибке + """ + try: + if message.text: + # Текстовое сообщение + await message.bot.send_message( + user_telegram_id, + message.text, + parse_mode="Markdown" + ) + elif message.photo: + # Фото с подписью + await message.bot.send_photo( + user_telegram_id, + photo=message.photo[-1].file_id, + caption=message.caption, + parse_mode="Markdown" + ) + elif message.video: + # Видео с подписью + await message.bot.send_video( + user_telegram_id, + video=message.video.file_id, + caption=message.caption, + parse_mode="Markdown" + ) + elif message.document: + # Документ с подписью + await message.bot.send_document( + user_telegram_id, + document=message.document.file_id, + caption=message.caption, + parse_mode="Markdown" + ) + else: + # Копируем сообщение как есть + await message.copy_to(user_telegram_id) + + return True + except Exception as e: + logger.warning(f"Не удалось отправить рассылку пользователю {user_telegram_id}: {e}") + return False + + # Экспорт роутера __all__ = ['admin_router'] \ No newline at end of file diff --git a/src/handlers/chat_handlers.py b/src/handlers/chat_handlers.py index bd06b72..1bd8b7d 100644 --- a/src/handlers/chat_handlers.py +++ b/src/handlers/chat_handlers.py @@ -446,7 +446,8 @@ async def handle_text_message(message: Message, state: FSMContext): # Формируем информацию об отправителе для админов (если это не админ) sender_info = None if not is_admin(message.from_user.id): - sender_name = f"@{user.username}" if user.username else user.first_name + # Используем nickname, если есть, иначе fallback на username или first_name + sender_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) if user.club_card_number: sender_name += f" (карта: {user.club_card_number})" sender_info = sender_name @@ -534,7 +535,8 @@ async def handle_photo_message(message: Message, state: FSMContext): # Формируем информацию об отправителе для админов (если это не админ) sender_info = None if not is_admin(message.from_user.id): - sender_name = f"@{user.username}" if user.username else user.first_name + # Используем nickname, если есть, иначе fallback на username или first_name + sender_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) if user.club_card_number: sender_name += f" (карта: {user.club_card_number})" sender_info = sender_name @@ -608,7 +610,8 @@ async def handle_video_message(message: Message, state: FSMContext): # Формируем информацию об отправителе для админов (если это не админ) sender_info = None if not is_admin(message.from_user.id): - sender_name = f"@{user.username}" if user.username else user.first_name + # Используем nickname, если есть, иначе fallback на username или first_name + sender_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) if user.club_card_number: sender_name += f" (карта: {user.club_card_number})" sender_info = sender_name @@ -681,7 +684,8 @@ async def handle_document_message(message: Message, state: FSMContext): # Формируем информацию об отправителе для админов (если это не админ) sender_info = None if not is_admin(message.from_user.id): - sender_name = f"@{user.username}" if user.username else user.first_name + # Используем nickname, если есть, иначе fallback на username или first_name + sender_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) if user.club_card_number: sender_name += f" (карта: {user.club_card_number})" sender_info = sender_name @@ -754,7 +758,8 @@ async def handle_animation_message(message: Message, state: FSMContext): # Формируем информацию об отправителе для админов (если это не админ) sender_info = None if not is_admin(message.from_user.id): - sender_name = f"@{user.username}" if user.username else user.first_name + # Используем nickname, если есть, иначе fallback на username или first_name + sender_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) if user.club_card_number: sender_name += f" (карта: {user.club_card_number})" sender_info = sender_name @@ -827,7 +832,8 @@ async def handle_sticker_message(message: Message, state: FSMContext): # Формируем информацию об отправителе для админов (если это не админ) sender_info = None if not is_admin(message.from_user.id): - sender_name = f"@{user.username}" if user.username else user.first_name + # Используем nickname, если есть, иначе fallback на username или first_name + sender_name = user.nickname if user.nickname else (f"@{user.username}" if user.username else user.first_name) if user.club_card_number: sender_name += f" (карта: {user.club_card_number})" sender_info = sender_name diff --git a/src/handlers/redraw_handlers.py b/src/handlers/redraw_handlers.py index 16d1156..7b05333 100644 --- a/src/handlers/redraw_handlers.py +++ b/src/handlers/redraw_handlers.py @@ -356,9 +356,29 @@ async def confirm_winner_callback(callback_query): winner.claimed_at = datetime.now(timezone.utc) await session.commit() - # Получаем данные о розыгрыше + # Получаем данные о розыгрыше и пользователе lottery = await LotteryService.get_lottery(session, winner.lottery_id) + # Получаем информацию о пользователе + owner = None + if winner.account_number: + owner = await AccountService.get_account_owner(session, winner.account_number) + elif winner.user_id: + user_result = await session.execute( + select(User).where(User.id == winner.user_id) + ) + owner = user_result.scalar_one_or_none() + + # Формируем отображаемое имя + display_name = "Пользователь" + if owner: + if owner.nickname: + display_name = owner.nickname + elif owner.username: + display_name = f"@{owner.username}" + elif owner.first_name: + display_name = owner.first_name + # Отправляем подтверждение пользователю confirmation_text = ( f"✅ **Выигрыш подтвержден!**\n\n" @@ -375,13 +395,17 @@ async def confirm_winner_callback(callback_query): parse_mode="Markdown" ) - # Уведомляем админов + # Уведомляем админов с nickname и клубной картой for admin_id in ADMIN_IDS: try: + # Формируем информацию для админа + user_info = display_name + if owner and owner.club_card_number: + user_info = f"{display_name} (карта: {owner.club_card_number})" + admin_text = ( f"✅ **Подтверждение выигрыша**\n\n" - f"👤 Пользователь: {callback_query.from_user.full_name} " - f"(@{callback_query.from_user.username or 'нет username'})\n" + f"👤 Пользователь: {user_info}\n" f"🎯 Розыгрыш: {lottery.title}\n" f"🏆 Место: {winner.place}\n" f"🎁 Приз: {winner.prize}\n" diff --git a/src/handlers/registration_handlers.py b/src/handlers/registration_handlers.py index 48dc66b..e61df0e 100644 --- a/src/handlers/registration_handlers.py +++ b/src/handlers/registration_handlers.py @@ -14,8 +14,49 @@ logger = logging.getLogger(__name__) router = Router() +# Служебные слова, которые нельзя использовать как никнейм +FORBIDDEN_NICKNAMES = [ + 'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро', + 'спасибо', 'пожалуйста', 'извините', 'до свидания', 'пока', + 'admin', 'administrator', 'moderator', 'bot', 'system', + 'hello', 'hi', 'thanks', 'please', 'sorry', 'goodbye', 'bye' +] + + +def validate_nickname(nickname: str) -> tuple[bool, str]: + """ + Валидация никнейма + + Returns: + (valid, error_message) + """ + nickname = nickname.strip() + + # Проверка длины + if len(nickname) < 2: + return False, "❌ Никнейм слишком короткий (минимум 2 символа)" + + if len(nickname) > 20: + return False, "❌ Никнейм слишком длинный (максимум 20 символов)" + + # Проверка на служебные слова + nickname_lower = nickname.lower() + for forbidden in FORBIDDEN_NICKNAMES: + if forbidden in nickname_lower: + import random + suggestion = f"{nickname[:3]}{random.randint(10, 99)}" + return False, f"❌ Это похоже на приветствие или служебное слово.\n\nПридумайте уникальный никнейм (например: {suggestion})" + + # Проверка на команды + if nickname.startswith('/'): + return False, "❌ Никнейм не может начинаться с '/'" + + return True, "" + + class RegistrationStates(StatesGroup): """Состояния для процесса регистрации""" + waiting_for_nickname = State() waiting_for_club_card = State() waiting_for_phone = State() @@ -28,7 +69,11 @@ async def start_registration(callback: CallbackQuery, state: FSMContext): text = ( "📝 Регистрация в системе\n\n" "Для участия в розыгрышах необходимо зарегистрироваться.\n\n" - "Введите номер вашей клубной карты:" + "Шаг 1 из 3: Придумайте никнейм\n\n" + "🎭 Введите ваш никнейм для чата:\n" + "• От 2 до 20 символов\n" + "• Может содержать буквы, цифры, пробелы\n" + "• Это имя будут видеть другие участники" ) await callback.message.edit_text( @@ -37,6 +82,32 @@ async def start_registration(callback: CallbackQuery, state: FSMContext): [InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")] ]) ) + await state.set_state(RegistrationStates.waiting_for_nickname) + + +@router.message(StateFilter(RegistrationStates.waiting_for_nickname)) +async def process_nickname(message: Message, state: FSMContext): + """Обработка никнейма""" + nickname = message.text.strip() + + # Валидация никнейма + valid, error_msg = validate_nickname(nickname) + + if not valid: + await message.answer( + f"{error_msg}\n\n" + "Попробуйте другой вариант:" + ) + return + + # Сохраняем никнейм + await state.update_data(nickname=nickname) + + await message.answer( + f"✅ Отлично! Ваш никнейм: {nickname}\n\n" + "Шаг 2 из 3: Клубная карта\n\n" + "📝 Введите номер вашей клубной карты:" + ) await state.set_state(RegistrationStates.waiting_for_club_card) @@ -60,7 +131,8 @@ async def process_club_card(message: Message, state: FSMContext): await state.update_data(club_card_number=club_card_number) await message.answer( - "📱 Теперь введите ваш номер телефона\n" + "Шаг 3 из 3: Телефон\n\n" + "📱 Введите ваш номер телефона\n" "(или отправьте '-' чтобы пропустить):" ) await state.set_state(RegistrationStates.waiting_for_phone) @@ -73,6 +145,7 @@ async def process_phone(message: Message, state: FSMContext): data = await state.get_data() club_card_number = data['club_card_number'] + nickname = data.get('nickname') try: async with async_session_maker() as session: @@ -82,9 +155,16 @@ async def process_phone(message: Message, state: FSMContext): club_card_number=club_card_number, phone=phone ) + + # Обновляем никнейм пользователя + if nickname: + user.nickname = nickname + await session.commit() + await session.refresh(user) text = ( "✅ Регистрация завершена!\n\n" + f"🎭 Никнейм: {user.nickname}\n" f"🎫 Клубная карта: {user.club_card_number}\n" f"🔑 Ваш код верификации: **{user.verification_code}**\n\n" "⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"