Merge pull request 'v2_functions' (#3) from v2_functions into master
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #3
This commit is contained in:
2026-02-11 09:41:25 +00:00
7 changed files with 839 additions and 112 deletions

View File

@@ -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')

View File

@@ -14,6 +14,7 @@ class User(Base):
username = Column(String(255)) username = Column(String(255))
first_name = Column(String(255)) first_name = Column(String(255))
last_name = Column(String(255)) last_name = Column(String(255))
nickname = Column(String(100), nullable=True) # Никнейм пользователя для чата
phone = Column(String(20), nullable=True) # Телефон для верификации phone = Column(String(20), nullable=True) # Телефон для верификации
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию

View File

@@ -13,7 +13,7 @@ class UserService:
@staticmethod @staticmethod
async def get_or_create_user(session: AsyncSession, telegram_id: int, async def get_or_create_user(session: AsyncSession, telegram_id: int,
username: str = None, first_name: str = None, username: str = None, first_name: str = None,
last_name: str = None) -> User: last_name: str = None, nickname: str = None) -> User:
"""Получить или создать пользователя""" """Получить или создать пользователя"""
# Пробуем найти существующего пользователя # Пробуем найти существующего пользователя
result = await session.execute( result = await session.execute(
@@ -26,6 +26,9 @@ class UserService:
user.username = username user.username = username
user.first_name = first_name user.first_name = first_name
user.last_name = last_name user.last_name = last_name
# Обновляем nickname только если он передан
if nickname is not None:
user.nickname = nickname
await session.commit() await session.commit()
return user return user
@@ -34,7 +37,8 @@ class UserService:
telegram_id=telegram_id, telegram_id=telegram_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
last_name=last_name last_name=last_name,
nickname=nickname
) )
session.add(user) session.add(user)
await session.commit() await session.commit()

View File

@@ -81,6 +81,12 @@ class AdminStates(StatesGroup):
# Настройки отображения победителей # Настройки отображения победителей
lottery_display_type_select = State() lottery_display_type_select = State()
lottery_display_type_set = State() lottery_display_type_set = State()
# Массовая рассылка
broadcast_message = State()
# Импорт/экспорт пользователей
import_users_json = State()
admin_router = Router() admin_router = Router()
@@ -98,6 +104,7 @@ def get_admin_main_keyboard() -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")], [InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")],
[InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")], [InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")],
[InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")], [InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
[InlineKeyboardButton(text="📢 Рассылка", callback_data="admin_broadcast")],
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] [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"👑 Установка победителя на {place} место\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\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 message.answer(text)
await state.set_state(AdminStates.set_winner_user) 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)) @admin_router.message(StateFilter(AdminStates.set_winner_user))
async def process_winner_user(message: Message, state: FSMContext): async def process_winner_user(message: Message, state: FSMContext):
"""Обработка пользователя-победителя""" """Обработка пользователя-победителя (по ID, username или номеру счета)"""
if not is_admin(message.from_user.id): if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав") await message.answer("❌ Недостаточно прав")
return return
user_input = message.text.strip() user_input = message.text.strip()
# Пробуем определить, это ID или username # Проверяем, это номер счета (формат XX-XX-XX-XX-XX-XX-XX)
if user_input.startswith('@'): is_account = '-' in user_input and len(user_input.split('-')) >= 5
user_input = user_input[1:] # Убираем @
is_username = True
elif user_input.isdigit():
is_username = False
telegram_id = int(user_input)
else:
is_username = True
async with async_session_maker() as session: if is_account:
if is_username: # Обработка по номеру счета
# Поиск по username from ..core.registration_services import AccountService
from sqlalchemy import select
from ..core.models import User async with async_session_maker() as session:
# Ищем владельца счета
owner = await AccountService.get_account_owner(session, user_input)
result = await session.execute( if not owner:
select(User).where(User.username == user_input) await message.answer(
) f"❌ Счет {user_input} не найден в системе.\n"
user = result.scalar_one_or_none() f"Проверьте правильность номера счета."
)
if not user:
await message.answer("❌ Пользователь с таким username не найден")
return 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: else:
user = await UserService.get_user_by_telegram_id(session, telegram_id) is_username = True
if not user: async with async_session_maker() as session:
await message.answer("❌ Пользователь с таким ID не найден") if is_username:
return # Поиск по 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() data = await state.get_data()
@@ -2355,13 +2392,13 @@ async def process_winner_user(message: Message, state: FSMContext):
await state.clear() await state.clear()
if success: if success:
username = f"@{user.username}" if user.username else user.first_name
await message.answer( await message.answer(
f"✅ Предопределенный победитель установлен!\n\n" f"✅ Предопределенный победитель установлен!\n\n"
f"🏆 Место: {data['place']}\n" f"🏆 Место: {data['place']}\n"
f"👤 Пользователь: {username}\n" f"👤 Пользователь: {display_name}\n"
f"🆔 ID: {telegram_id}\n\n" f"🆔 ID: {telegram_id}\n"
f"При проведении розыгрыша этот пользователь автоматически займет {data['place']} место.", + (f"💳 Счет: {user_input}\n" if is_account else "") +
f"\n⚡ При проведении розыгрыша этот пользователь автоматически займет {data['place']} место.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👑 К управлению победителями", callback_data="admin_winners")] [InlineKeyboardButton(text="👑 К управлению победителями", callback_data="admin_winners")]
]) ])
@@ -2886,6 +2923,13 @@ async def conduct_lottery_draw(callback: CallbackQuery):
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {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) winners = await LotteryService.get_winners(session, lottery_id)
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n" text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
@@ -3006,7 +3050,9 @@ async def show_admin_settings(callback: CallbackQuery):
text += "Доступные действия:" text += "Доступные действия:"
buttons = [ buttons = [
[InlineKeyboardButton(text="💾 Экспорт данных", callback_data="admin_export_data")], [InlineKeyboardButton(text="<EFBFBD> Экспорт пользователей (JSON)", callback_data="admin_export_users")],
[InlineKeyboardButton(text="📤 Импорт пользователей (JSON)", callback_data="admin_import_users")],
[InlineKeyboardButton(text="<EFBFBD>💾 Экспорт данных", callback_data="admin_export_data")],
[InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")], [InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")],
[InlineKeyboardButton(text="📋 Системная информация", callback_data="admin_system_info")], [InlineKeyboardButton(text="📋 Системная информация", callback_data="admin_system_info")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] [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"📢 <b>Результаты розыгрыша</b>\n\n"
f"🎯 <b>{lottery.title}</b>\n\n"
f"🏆 <b>Победители:</b>\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"📥 <b>Экспорт пользователей</b>\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 = (
"📤 <b>Импорт пользователей</b>\n\n"
"Отправьте JSON файл с данными пользователей.\n\n"
"⚠️ <b>Внимание!</b>\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"✅ <b>Импорт завершен!</b>\n\n"
f"📊 <b>Статистика:</b>\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 = (
"📢 <b>Массовая рассылка</b>\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 = (
"✉️ <b>Создание рассылки</b>\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(
"📤 <b>Начинаю рассылку...</b>\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"📤 <b>Рассылка в процессе...</b>\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"✅ <b>Рассылка завершена!</b>\n\n"
f"📊 <b>Статистика:</b>\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'] __all__ = ['admin_router']

View File

@@ -6,7 +6,7 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter, Command from aiogram.filters import StateFilter, Command
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import asyncio import asyncio
from typing import List, Dict, Optional, Set from typing import List, Dict, Optional, Set, Any
from collections import deque from collections import deque
import time import time
@@ -130,18 +130,21 @@ async def get_all_active_users(session: AsyncSession) -> List:
async def broadcast_message_with_scheduler( async def broadcast_message_with_scheduler(
message: Message, message: Message,
sender_user: Any, # User model object
exclude_user_id: Optional[int] = None, exclude_user_id: Optional[int] = None,
admin_only: bool = False, admin_only: bool = False
sender_info: Optional[str] = None
) -> tuple[Dict[str, int], int, int]: ) -> tuple[Dict[str, int], int, int]:
""" """
Разослать сообщение всем пользователям с планировщиком (пакетная отправка). Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
Подписи формируются динамически в зависимости от получателя:
- Админы видят: nickname (карта: XXXX)
- Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа)
Args: Args:
message: Сообщение для рассылки message: Сообщение для рассылки
sender_user: Объект User отправителя
exclude_user_id: ID пользователя для исключения exclude_user_id: ID пользователя для исключения
admin_only: Рассылать только админам admin_only: Рассылать только админам
sender_info: Информация об отправителе (для показа админам)
Возвращает: (forwarded_ids, success_count, fail_count) Возвращает: (forwarded_ids, success_count, fail_count)
""" """
@@ -165,12 +168,29 @@ async def broadcast_message_with_scheduler(
# Отправляем пакет # Отправляем пакет
tasks = [] tasks = []
for user in batch: for recipient_user in batch:
# Для админов добавляем информацию об отправителе, если сообщение от обычного пользователя # Формируем подпись в зависимости от получателя
if sender_info and user.telegram_id in ADMIN_IDS: if recipient_user.telegram_id in ADMIN_IDS:
tasks.append(_send_message_to_admin_with_sender(message, user.telegram_id, sender_info)) # Админы видят полную информацию: 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: 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) 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 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"📨 <b>{sender_info}:</b>\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]: 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': if settings.mode == 'broadcast':
# Режим рассылки с планировщиком # Режим рассылки с планировщиком
# Формируем информацию об отправителе для админов (если это не админ) # Передаем объект user для динамического формирования подписей
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( forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message, message,
exclude_user_id=message.from_user.id, sender_user=user,
sender_info=sender_info exclude_user_id=message.from_user.id
) )
# Сохраняем сообщение в историю # Сохраняем сообщение в историю
@@ -531,19 +623,11 @@ async def handle_photo_message(message: Message, state: FSMContext):
photo = message.photo[-1] photo = message.photo[-1]
if settings.mode == 'broadcast': 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( forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message, message,
exclude_user_id=message.from_user.id, sender_user=user,
sender_info=sender_info exclude_user_id=message.from_user.id
) )
await ChatMessageService.save_message( await ChatMessageService.save_message(
@@ -605,18 +689,11 @@ async def handle_video_message(message: Message, state: FSMContext):
) )
if settings.mode == 'broadcast': 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( forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message, message,
exclude_user_id=message.from_user.id, sender_user=user,
sender_info=sender_info exclude_user_id=message.from_user.id
) )
await ChatMessageService.save_message( await ChatMessageService.save_message(
@@ -678,18 +755,11 @@ async def handle_document_message(message: Message, state: FSMContext):
) )
if settings.mode == 'broadcast': 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( forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message, message,
exclude_user_id=message.from_user.id, sender_user=user,
sender_info=sender_info exclude_user_id=message.from_user.id
) )
await ChatMessageService.save_message( await ChatMessageService.save_message(
@@ -751,18 +821,11 @@ async def handle_animation_message(message: Message, state: FSMContext):
) )
if settings.mode == 'broadcast': 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( forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message, message,
exclude_user_id=message.from_user.id, sender_user=user,
sender_info=sender_info exclude_user_id=message.from_user.id
) )
await ChatMessageService.save_message( await ChatMessageService.save_message(
@@ -824,18 +887,11 @@ async def handle_sticker_message(message: Message, state: FSMContext):
) )
if settings.mode == 'broadcast': 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( forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message, message,
exclude_user_id=message.from_user.id, sender_user=user,
sender_info=sender_info exclude_user_id=message.from_user.id
) )
await ChatMessageService.save_message( await ChatMessageService.save_message(

View File

@@ -356,9 +356,29 @@ async def confirm_winner_callback(callback_query):
winner.claimed_at = datetime.now(timezone.utc) winner.claimed_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
# Получаем данные о розыгрыше # Получаем данные о розыгрыше и пользователе
lottery = await LotteryService.get_lottery(session, winner.lottery_id) 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 = ( confirmation_text = (
f"✅ **Выигрыш подтвержден!**\n\n" f"✅ **Выигрыш подтвержден!**\n\n"
@@ -375,13 +395,17 @@ async def confirm_winner_callback(callback_query):
parse_mode="Markdown" parse_mode="Markdown"
) )
# Уведомляем админов # Уведомляем админов с nickname и клубной картой
for admin_id in ADMIN_IDS: for admin_id in ADMIN_IDS:
try: try:
# Формируем информацию для админа
user_info = display_name
if owner and owner.club_card_number:
user_info = f"{display_name} (карта: {owner.club_card_number})"
admin_text = ( admin_text = (
f"✅ **Подтверждение выигрыша**\n\n" f"✅ **Подтверждение выигрыша**\n\n"
f"👤 Пользователь: {callback_query.from_user.full_name} " f"👤 Пользователь: {user_info}\n"
f"(@{callback_query.from_user.username or 'нет username'})\n"
f"🎯 Розыгрыш: {lottery.title}\n" f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n" f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n" f"🎁 Приз: {winner.prize}\n"

View File

@@ -14,8 +14,49 @@ logger = logging.getLogger(__name__)
router = Router() 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): class RegistrationStates(StatesGroup):
"""Состояния для процесса регистрации""" """Состояния для процесса регистрации"""
waiting_for_nickname = State()
waiting_for_club_card = State() waiting_for_club_card = State()
waiting_for_phone = State() waiting_for_phone = State()
@@ -28,7 +69,11 @@ async def start_registration(callback: CallbackQuery, state: FSMContext):
text = ( text = (
"📝 Регистрация в системе\n\n" "📝 Регистрация в системе\n\n"
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n" "Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
"Введите номер вашей клубной карты:" "Шаг 1 из 3: Придумайте никнейм\n\n"
"🎭 Введите ваш никнейм для чата:\n"
"• От 2 до 20 символов\n"
"• Может содержать буквы, цифры, пробелы\n"
"• Это имя будут видеть другие участники"
) )
await callback.message.edit_text( await callback.message.edit_text(
@@ -37,6 +82,32 @@ async def start_registration(callback: CallbackQuery, state: FSMContext):
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")] [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) 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 state.update_data(club_card_number=club_card_number)
await message.answer( await message.answer(
"📱 Теперь введите ваш номер телефона\n" "Шаг 3 из 3: Телефон\n\n"
"📱 Введите ваш номер телефона\n"
"(или отправьте '-' чтобы пропустить):" "(или отправьте '-' чтобы пропустить):"
) )
await state.set_state(RegistrationStates.waiting_for_phone) 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() data = await state.get_data()
club_card_number = data['club_card_number'] club_card_number = data['club_card_number']
nickname = data.get('nickname')
try: try:
async with async_session_maker() as session: 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, club_card_number=club_card_number,
phone=phone phone=phone
) )
# Обновляем никнейм пользователя
if nickname:
user.nickname = nickname
await session.commit()
await session.refresh(user)
text = ( text = (
"✅ Регистрация завершена!\n\n" "✅ Регистрация завершена!\n\n"
f"🎭 Никнейм: {user.nickname}\n"
f"🎫 Клубная карта: {user.club_card_number}\n" f"🎫 Клубная карта: {user.club_card_number}\n"
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n" f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n" "⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"