Compare commits

...

3 Commits

Author SHA1 Message Date
4b06cd2f9e 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
2026-02-11 09:41:25 +00:00
ca0c63a89c chat+lottery refactor
All checks were successful
continuous-integration/drone/pr Build is passing
2026-02-11 18:40:37 +09:00
c0407fdb11 Реализованы все улучшения функционала бота
Блок 1: Система никнеймов
-  Добавлено поле nickname в модель User
-  Создана миграция для nickname
-  Обновлена регистрация (3 шага: nickname → карта → телефон)
-  Валидация nickname (длина 2-20, проверка служебных слов)
-  Подписи в чате используют nickname

Блок 2: Админские функции
-  Массовая рассылка (кнопка в админке, поддержка текста/фото/видео/документов)
-  Экспорт пользователей в JSON (бэкап с метаданными)
-  Импорт пользователей из JSON (восстановление с обновлением)

Блок 3: Улучшения розыгрышей
-  Рассылка результатов розыгрыша всем участникам (кроме победителей)
-  Сообщения подтверждения показывают nickname + клубную карту
-  Ручное назначение победителя по номеру счета/telegram ID/username
2026-02-09 20:22:32 +09: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))
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) # Прошел ли полную регистрацию

View File

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

View File

@@ -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="<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_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"📢 <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']

View File

@@ -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"📨 <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]:
"""
Отправить сообщение админу с информацией об отправителе.
@@ -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(

View File

@@ -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"

View File

@@ -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"