Compare commits
3 Commits
4e2c8981c2
...
4b06cd2f9e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b06cd2f9e | |||
| ca0c63a89c | |||
| c0407fdb11 |
@@ -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')
|
||||||
@@ -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) # Прошел ли полную регистрацию
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -82,6 +82,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
|
if is_account:
|
||||||
elif user_input.isdigit():
|
# Обработка по номеру счета
|
||||||
is_username = False
|
from ..core.registration_services import AccountService
|
||||||
telegram_id = int(user_input)
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Ищем владельца счета
|
||||||
|
owner = await AccountService.get_account_owner(session, user_input)
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Счет {user_input} не найден в системе.\n"
|
||||||
|
f"Проверьте правильность номера счета."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
telegram_id = owner.telegram_id
|
||||||
|
display_name = owner.nickname if owner.nickname else (f"@{owner.username}" if owner.username else owner.first_name)
|
||||||
else:
|
else:
|
||||||
is_username = True
|
# Обработка по ID или username
|
||||||
|
# Пробуем определить, это ID или username
|
||||||
async with async_session_maker() as session:
|
if user_input.startswith('@'):
|
||||||
if is_username:
|
user_input = user_input[1:] # Убираем @
|
||||||
# Поиск по username
|
is_username = True
|
||||||
from sqlalchemy import select
|
elif user_input.isdigit():
|
||||||
from ..core.models import User
|
is_username = False
|
||||||
|
telegram_id = int(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 не найден")
|
|
||||||
return
|
|
||||||
|
|
||||||
telegram_id = user.telegram_id
|
|
||||||
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']
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -83,8 +156,15 @@ async def process_phone(message: Message, state: FSMContext):
|
|||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user