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..989de0c 100644
--- a/src/handlers/chat_handlers.py
+++ b/src/handlers/chat_handlers.py
@@ -6,7 +6,7 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter, Command
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
-from typing import List, Dict, Optional, Set
+from typing import List, Dict, Optional, Set, Any
from collections import deque
import time
@@ -130,18 +130,21 @@ async def get_all_active_users(session: AsyncSession) -> List:
async def broadcast_message_with_scheduler(
message: Message,
+ sender_user: Any, # User model object
exclude_user_id: Optional[int] = None,
- admin_only: bool = False,
- sender_info: Optional[str] = None
+ admin_only: bool = False
) -> tuple[Dict[str, int], int, int]:
"""
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
+ Подписи формируются динамически в зависимости от получателя:
+ - Админы видят: nickname (карта: XXXX)
+ - Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа)
Args:
message: Сообщение для рассылки
+ sender_user: Объект User отправителя
exclude_user_id: ID пользователя для исключения
admin_only: Рассылать только админам
- sender_info: Информация об отправителе (для показа админам)
Возвращает: (forwarded_ids, success_count, fail_count)
"""
@@ -165,12 +168,29 @@ async def broadcast_message_with_scheduler(
# Отправляем пакет
tasks = []
- for user in batch:
- # Для админов добавляем информацию об отправителе, если сообщение от обычного пользователя
- if sender_info and user.telegram_id in ADMIN_IDS:
- tasks.append(_send_message_to_admin_with_sender(message, user.telegram_id, sender_info))
+ for recipient_user in batch:
+ # Формируем подпись в зависимости от получателя
+ if recipient_user.telegram_id in ADMIN_IDS:
+ # Админы видят полную информацию: nickname (карта: XXXX)
+ sender_name = sender_user.nickname if sender_user.nickname else (
+ f"@{sender_user.username}" if sender_user.username else sender_user.first_name
+ )
+ if sender_user.club_card_number:
+ sender_name += f" (карта: {sender_user.club_card_number})"
+ sender_info = sender_name
+ tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info))
else:
- tasks.append(_send_message_to_user(message, user.telegram_id))
+ # Обычные пользователи видят:
+ # - "Админ" если отправитель - админ
+ # - nickname если отправитель - обычный пользователь
+ if sender_user.telegram_id in ADMIN_IDS:
+ sender_info = "Админ"
+ tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
+ else:
+ sender_info = sender_user.nickname if sender_user.nickname else (
+ f"@{sender_user.username}" if sender_user.username else sender_user.first_name
+ )
+ tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -205,6 +225,85 @@ async def _send_message_to_user(message: Message, user_telegram_id: int) -> Opti
return None
+async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
+ """
+ Отправить сообщение обычному пользователю с информацией об отправителе.
+ Возвращает message_id при успехе или None при ошибке.
+ """
+ try:
+ # Формируем текст с информацией об отправителе
+ header = f"📨 {sender_info}:\n\n"
+
+ if message.text:
+ # Текстовое сообщение
+ sent_msg = await message.bot.send_message(
+ user_telegram_id,
+ header + message.text,
+ parse_mode="HTML"
+ )
+ elif message.photo:
+ # Фото
+ caption = header + (message.caption or "")
+ sent_msg = await message.bot.send_photo(
+ user_telegram_id,
+ photo=message.photo[-1].file_id,
+ caption=caption,
+ parse_mode="HTML"
+ )
+ elif message.video:
+ # Видео
+ caption = header + (message.caption or "")
+ sent_msg = await message.bot.send_video(
+ user_telegram_id,
+ video=message.video.file_id,
+ caption=caption,
+ parse_mode="HTML"
+ )
+ elif message.document:
+ # Документ
+ caption = header + (message.caption or "")
+ sent_msg = await message.bot.send_document(
+ user_telegram_id,
+ document=message.document.file_id,
+ caption=caption,
+ parse_mode="HTML"
+ )
+ elif message.animation:
+ # GIF
+ caption = header + (message.caption or "")
+ sent_msg = await message.bot.send_animation(
+ user_telegram_id,
+ animation=message.animation.file_id,
+ caption=caption,
+ parse_mode="HTML"
+ )
+ elif message.sticker:
+ # Стикер - сначала отправляем заголовок, потом стикер
+ await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
+ sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
+ elif message.voice:
+ # Голосовое сообщение
+ sent_msg = await message.bot.send_voice(
+ user_telegram_id,
+ voice=message.voice.file_id,
+ caption=header,
+ parse_mode="HTML"
+ )
+ elif message.video_note:
+ # Видео-кружок
+ await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
+ sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
+ else:
+ # Неизвестный тип - просто копируем
+ await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
+ sent_msg = await message.copy_to(user_telegram_id)
+
+ return sent_msg.message_id
+ except Exception as e:
+ print(f"Failed to send message to {user_telegram_id}: {e}")
+ return None
+
+
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
"""
Отправить сообщение админу с информацией об отправителе.
@@ -443,19 +542,12 @@ async def handle_text_message(message: Message, state: FSMContext):
# Обрабатываем в зависимости от режима
if settings.mode == 'broadcast':
# Режим рассылки с планировщиком
- # Формируем информацию об отправителе для админов (если это не админ)
- sender_info = None
- if not is_admin(message.from_user.id):
- sender_name = 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
-
+ # Передаем объект user для динамического формирования подписей
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
- message,
- exclude_user_id=message.from_user.id,
- sender_info=sender_info
+ message,
+ sender_user=user,
+ exclude_user_id=message.from_user.id
)
# Сохраняем сообщение в историю
@@ -531,19 +623,11 @@ async def handle_photo_message(message: Message, state: FSMContext):
photo = message.photo[-1]
if settings.mode == 'broadcast':
- # Формируем информацию об отправителе для админов (если это не админ)
- sender_info = None
- if not is_admin(message.from_user.id):
- sender_name = 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
-
# Рассылаем фото - ВСЕГДА исключаем отправителя
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
- exclude_user_id=message.from_user.id,
- sender_info=sender_info
+ sender_user=user,
+ exclude_user_id=message.from_user.id
)
await ChatMessageService.save_message(
@@ -605,18 +689,11 @@ async def handle_video_message(message: Message, state: FSMContext):
)
if settings.mode == 'broadcast':
- # Формируем информацию об отправителе для админов (если это не админ)
- sender_info = None
- if not is_admin(message.from_user.id):
- sender_name = 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
-
+ # Рассылаем видео
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
- exclude_user_id=message.from_user.id,
- sender_info=sender_info
+ sender_user=user,
+ exclude_user_id=message.from_user.id
)
await ChatMessageService.save_message(
@@ -678,18 +755,11 @@ async def handle_document_message(message: Message, state: FSMContext):
)
if settings.mode == 'broadcast':
- # Формируем информацию об отправителе для админов (если это не админ)
- sender_info = None
- if not is_admin(message.from_user.id):
- sender_name = 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
-
+ # Рассылаем документ
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
- exclude_user_id=message.from_user.id,
- sender_info=sender_info
+ sender_user=user,
+ exclude_user_id=message.from_user.id
)
await ChatMessageService.save_message(
@@ -751,18 +821,11 @@ async def handle_animation_message(message: Message, state: FSMContext):
)
if settings.mode == 'broadcast':
- # Формируем информацию об отправителе для админов (если это не админ)
- sender_info = None
- if not is_admin(message.from_user.id):
- sender_name = 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
-
+ # Рассылаем анимацию
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
- exclude_user_id=message.from_user.id,
- sender_info=sender_info
+ sender_user=user,
+ exclude_user_id=message.from_user.id
)
await ChatMessageService.save_message(
@@ -824,18 +887,11 @@ async def handle_sticker_message(message: Message, state: FSMContext):
)
if settings.mode == 'broadcast':
- # Формируем информацию об отправителе для админов (если это не админ)
- sender_info = None
- if not is_admin(message.from_user.id):
- sender_name = 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
-
+ # Рассылаем стикер
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
- exclude_user_id=message.from_user.id,
- sender_info=sender_info
+ sender_user=user,
+ exclude_user_id=message.from_user.id
)
await ChatMessageService.save_message(
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"