Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-09-13 19:48:41 +09:00
parent 733298bf06
commit cb35bb12e3
86 changed files with 2993 additions and 2455 deletions

View File

@@ -1,6 +1,7 @@
"""
Обработчики для работы со счетами в розыгрышах
"""
from src.core.access import is_admin
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import StateFilter
@@ -28,9 +29,6 @@ class AccountStates(StatesGroup):
account_router = Router()
def is_admin(user_id: int) -> bool:
"""Проверка прав администратора"""
return user_id in ADMIN_IDS
@account_router.message(
@@ -44,7 +42,7 @@ async def detect_account_input(message: Message, state: FSMContext):
Активируется только для администраторов
Извлекает номер клубной карты и определяет владельца
"""
if not is_admin(message.from_user.id):
if not await is_admin(message.from_user.id):
return
# Парсим счета из сообщения
@@ -156,7 +154,7 @@ async def cancel_account_action(callback: CallbackQuery, state: FSMContext):
@account_router.callback_query(F.data == "account_action:add_to_lottery")
async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext):
"""Выбор розыгрыша для добавления счетов"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
return
@@ -200,7 +198,7 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext
@account_router.callback_query(F.data.startswith("add_accounts_to:"))
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавление счетов в выбранный розыгрыш"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
return
@@ -258,7 +256,7 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
@account_router.callback_query(F.data == "account_action:set_as_winner")
async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
"""Выбор розыгрыша для установки победителя"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
return
@@ -319,7 +317,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
@account_router.callback_query(F.data.startswith("winner_lottery:"))
async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
"""Выбор места для победителя"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
return
@@ -382,7 +380,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
@account_router.callback_query(F.data.startswith("winner_place:"))
async def set_account_winner(callback: CallbackQuery, state: FSMContext):
"""Установка счета как победителя"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
return

View File

@@ -6,6 +6,7 @@ from sqlalchemy import select, delete, func
from ..core.models import Lottery, Participation, Winner
from ..utils.account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern
from typing import List, Optional, Dict, Any
from ..core.transactions import lock_open_lottery
class AccountParticipationService:
@@ -49,6 +50,9 @@ class AccountParticipationService:
"account_number": account_number
}
if not await lock_open_lottery(session, lottery_id):
await session.rollback()
return {"success": False, "message": "Розыгрыш закрыт или не найден", "account_number": formatted_account}
# Проверяем существование розыгрыша
lottery = await session.get(Lottery, lottery_id)
if not lottery:
@@ -66,6 +70,7 @@ class AccountParticipationService:
)
)
if existing.scalar_one_or_none():
await session.rollback()
card_info = f" (карта: {card_number})" if card_number else ""
return {
"success": False,
@@ -82,6 +87,12 @@ class AccountParticipationService:
select(Account).where(Account.account_number == formatted_account)
)
account_record = account_record.scalar_one_or_none()
if account_record and not account_record.is_active:
await session.rollback()
return {"success": False, "message": "Счет неактивен", "account_number": formatted_account}
if card_number and (not user or user.club_card_number != card_number):
await session.rollback()
return {"success": False, "message": "Карта не соответствует владельцу счета", "account_number": formatted_account}
# Добавляем участие с полными данными
participation = Participation(
@@ -149,6 +160,9 @@ class AccountParticipationService:
"message": f"Неверный формат счета: {account_number}"
}
if not await lock_open_lottery(session, lottery_id):
await session.rollback()
return {"success": False, "message": "Розыгрыш закрыт или не найден"}
participation = await session.execute(
select(Participation).where(
Participation.lottery_id == lottery_id,
@@ -158,6 +172,7 @@ class AccountParticipationService:
participation = participation.scalar_one_or_none()
if not participation:
await session.rollback()
return {
"success": False,
"message": f"Счет {formatted_account} не участвует в розыгрыше"
@@ -236,6 +251,13 @@ class AccountParticipationService:
Устанавливает счет как победителя в розыгрыше.
Поддерживает формат: "КАРТА СЧЕТ" или просто "СЧЕТ"
"""
if not await lock_open_lottery(session, lottery_id):
await session.rollback()
return {"success": False, "message": "Розыгрыш закрыт или не найден"}
lottery = await session.get(Lottery, lottery_id)
if place < 1 or place > len(lottery.prizes or [None]):
await session.rollback()
return {"success": False, "message": "Некорректное призовое место"}
# Разделяем номер карты и счета, если они указаны вместе
card_number = None
parts = account_number.split()

View File

@@ -1,4 +1,5 @@
"""Админские обработчики для управления счетами и верификации"""
from src.utils.errors import public_error
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command
@@ -12,7 +13,7 @@ from src.core.registration_services import AccountService, WinnerNotificationSer
from src.core.services import UserService, LotteryService, ParticipationService
from src.core.models import User, Winner, Account, Participation
from src.core.config import ADMIN_IDS
from src.core.permissions import admin_only
from src.core.access import staff_only
router = Router()
@@ -24,7 +25,7 @@ class AddAccountStates(StatesGroup):
@router.message(CaseInsensitiveCommand("cancel"))
@admin_only
@staff_only
async def cancel_command(message: Message, state: FSMContext):
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
await state.clear()
@@ -32,7 +33,7 @@ async def cancel_command(message: Message, state: FSMContext):
@router.message(CaseInsensitiveCommand("add_account"))
@admin_only
@staff_only
async def add_account_command(message: Message, state: FSMContext):
"""
Добавить счет пользователю по клубной карте (регистронезависимо)
@@ -107,7 +108,7 @@ async def process_single_account(message: Message, club_card: str, account_numbe
)
text += "📨 Владельцу отправлено уведомление\n\n"
except Exception as e:
text += f"⚠️ Не удалось отправить уведомление: {str(e)}\n\n"
text += f"⚠️ Не удалось отправить уведомление: {public_error(e)}\n\n"
# Предлагаем добавить в розыгрыш
await show_lottery_selection(message, text, state)
@@ -116,11 +117,11 @@ async def process_single_account(message: Message, club_card: str, account_numbe
await message.answer(f"❌ Ошибка: {str(e)}")
await state.clear()
except Exception as e:
await message.answer(f"❌ Произошла ошибка: {str(e)}")
await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
await state.clear()
@router.message(AddAccountStates.waiting_for_data)
@router.message(AddAccountStates.waiting_for_data, F.text)
async def process_accounts_data(message: Message, state: FSMContext):
"""Обработка данных счетов (один или несколько)"""
if message.text.strip().lower() == '/cancel':
@@ -220,7 +221,7 @@ async def process_accounts_data(message: Message, state: FSMContext):
except ValueError as e:
errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}")
except Exception as e:
errors.append(f"Счет {account_number}: {str(e)}")
errors.append(f"Счет {account_number}: {public_error(e)}")
i += 1
@@ -383,36 +384,10 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
await state.clear()
return
for acc in accounts:
try:
# Добавляем участие через account_id
# Проверяем, не участвует ли уже
existing = await session.execute(
select(Participation).where(
and_(
Participation.lottery_id == lottery_id,
Participation.account_id == acc['account_id']
)
)
)
if existing.scalar_one_or_none():
errors.append(f"{acc['account_number']}: уже участвует")
continue
# Создаем участие
participation = Participation(
lottery_id=lottery_id,
account_id=acc['account_id'],
account_number=acc['account_number']
)
session.add(participation)
success_count += 1
except Exception as e:
errors.append(f"{acc['account_number']}: {str(e)}")
await session.commit()
from .account_services import AccountParticipationService
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts])
success_count = result['added']
errors = result['errors']
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
@@ -436,7 +411,7 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
@router.message(CaseInsensitiveCommand("remove_account"))
@admin_only
@staff_only
async def remove_account_command(message: Message):
"""
Деактивировать счет(а) (регистронезависимо)
@@ -502,11 +477,11 @@ async def remove_account_command(message: Message):
await message.answer("\n\n".join(response_parts), parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Критическая ошибка: {str(e)}")
await message.answer(f"❌ Критическая ошибка: {public_error(e)}")
@router.message(CaseInsensitiveCommand("verify_winner"))
@admin_only
@staff_only
async def verify_winner_command(message: Message):
"""
Подтвердить выигрыш по коду верификации (регистронезависимо)
@@ -585,7 +560,7 @@ async def verify_winner_command(message: Message):
)
text += "\n📨 Победителю отправлено уведомление"
except Exception as e:
text += f"\n⚠️ Не удалось отправить уведомление: {str(e)}"
text += f"\n⚠️ Не удалось отправить уведомление: {public_error(e)}"
if winner.account_number:
text += f"💳 Счет: {winner.account_number}\n"
@@ -593,11 +568,11 @@ async def verify_winner_command(message: Message):
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
await message.answer(f"❌ Ошибка: {public_error(e)}")
@router.message(CaseInsensitiveCommand("winner_status"))
@admin_only
@staff_only
async def winner_status_command(message: Message):
"""
Показать статус всех победителей розыгрыша (регистронезависимо)
@@ -666,11 +641,11 @@ async def winner_status_command(message: Message):
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
await message.answer(f"❌ Ошибка: {public_error(e)}")
@router.message(CaseInsensitiveCommand("user_info"))
@admin_only
@staff_only
async def user_info_command(message: Message):
"""
Показать информацию о пользователе (регистронезависимо)
@@ -741,7 +716,7 @@ async def user_info_command(message: Message):
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
await message.answer(f"❌ Ошибка: {public_error(e)}")
@router.callback_query(F.data == "view_my_accounts")
@@ -807,6 +782,6 @@ async def view_my_accounts_callback(callback: CallbackQuery):
except Exception as e:
# Не используем callback.answer в except - может быть timeout
try:
await callback.message.answer(f"❌ Ошибка: {str(e)}")
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
except:
pass # Игнорируем если не получилось отправить

View File

@@ -1,4 +1,5 @@
"""Админские обработчики для управления чатом"""
from src.utils.errors import public_error
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
@@ -290,12 +291,9 @@ async def cmd_delete_message(message: Message):
from sqlalchemy import select
from src.core.models import ChatMessage
result = await session.execute(
select(ChatMessage).where(
ChatMessage.telegram_message_id == message.reply_to_message.message_id
)
chat_message = await ChatMessageService.get_message_by_telegram_id(
session, message.reply_to_message.message_id, chat_id=message.chat.id,
)
chat_message = result.scalar_one_or_none()
if not chat_message:
await message.answer("❌ Сообщение не найдено в базе данных")
@@ -317,7 +315,7 @@ async def cmd_delete_message(message: Message):
await message.bot.delete_message(int(user_telegram_id), msg_id)
deleted_count += 1
except Exception as e:
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {e}")
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {public_error(e)}")
await message.answer(
f"✅ <b>Сообщение удалено</b>\n\n"

View File

@@ -2,7 +2,9 @@
Хендлеры для управления кастомными эмодзи админом
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
"""
from src.utils.errors import public_error
import logging
from html import escape
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command, StateFilter
@@ -11,6 +13,7 @@ from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import async_session_maker
from ..core.access import is_admin
from ..core.config import ADMIN_IDS
from ..core.emoji_mapping_service import EmojiMappingService
@@ -26,73 +29,36 @@ class EmojiStates(StatesGroup):
@router.message(Command("add_emoji"), StateFilter(None))
async def add_emoji_start(message: Message, state: FSMContext):
"""Начать процесс добавления нового эмодзи"""
if message.from_user.id not in ADMIN_IDS:
if not await is_admin(message.from_user.id):
await message.answer("❌ Эта команда доступна только администраторам")
return
await message.answer(
"🎨 Отправьте эмодзи, который хотите зарегистрировать.\n\n"
"Бот получит его <code>emoji_id</code> и будет использовать этот ID "
"при отправке сообщений в чаты, чтобы эмодзи выглядел точно так же.",
"🎨 Отправьте один премиум-эмодзи из панели Telegram.\n\n"
"Бот сохранит его в каталоге для использования в шаблонах. "
"В чатах и рассылках оформление сохраняется прямо из отправленного сообщения.",
parse_mode="HTML"
)
await state.set_state(EmojiStates.waiting_for_emoji)
@router.message(EmojiStates.waiting_for_emoji)
@router.message(EmojiStates.waiting_for_emoji, F.text)
async def receive_emoji(message: Message, state: FSMContext):
"""Получить эмодзи от админа и сохранить его emoji_id"""
# Проверяем что это именно тект сообщение с эмодзи
if not message.text or len(message.text) > 10:
await message.answer(
"❌ Пожалуйста, отправьте просто эмодзи или маленький текст с эмодзи"
)
entities = [entity for entity in message.entities or () if entity.type == "custom_emoji"]
if len(entities) != 1:
await message.answer("Отправьте один премиум-эмодзи из панели эмодзи Telegram. Обычный символ не содержит его ID.")
return
emoji_text = message.text.strip()
# Проверяем что хотя бы один символ это эмодзи
has_emoji = any(ord(c) > 127 for c in emoji_text)
if not has_emoji:
await message.answer(
"❌ Текст не содержит эмодзи. Пожалуйста, отправьте эмодзи"
)
entity = entities[0]
emoji_text = entity.extract_from(message.text)
emoji_id = entity.custom_emoji_id
if message.text.strip() != emoji_text or len(emoji_text) > 10 or not emoji_id or not emoji_id.isascii() or not emoji_id.isdigit():
await message.answer("Отправьте только один премиум-эмодзи, без дополнительного текста.")
return
# Извлекаем emoji_id из entities если это есть
emoji_id = None
# Проверяем есть ли entities в сообщении (custom emoji имеют свой entitytype)
if message.entities:
for entity in message.entities:
if entity.type == "custom_emoji":
# Получаем text с этим entity
emoji_id = entity.custom_emoji_id
break
# Если нет custom_emoji entity, пробуем другой способ
if not emoji_id:
# Используем встроенный способ Telegram - отправляем тестовое сообщение с этим эмодзи
# и смотрим entities
try:
# Отправляем сообщение с эмодзи обратно
test_msg = await message.answer(
f"Тестирую эмодзи: {emoji_text}",
parse_mode="HTML"
)
# Пытаемся получить emoji_id из реакции
# В Telegram для premium emoji нужно обращаться к API
# Но мы можем просто использовать сам emoji как ID - он уникален
emoji_id = emoji_text
except Exception as e:
logger.error(f"Error testing emoji: {e}")
emoji_id = emoji_text
# Сохраняем в состояние
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id if emoji_id else emoji_text)
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id)
await message.answer(
f"✅ Получил эмодзи: <code>{emoji_text}</code>\n\n"
f"✅ Получил эмодзи: <code>{escape(emoji_text or '')}</code>\n\n"
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
parse_mode="HTML"
@@ -100,7 +66,7 @@ async def receive_emoji(message: Message, state: FSMContext):
await state.set_state(EmojiStates.waiting_for_description)
@router.message(EmojiStates.waiting_for_description)
@router.message(EmojiStates.waiting_for_description, F.text)
async def receive_emoji_description(message: Message, state: FSMContext):
"""Получить описание эмодзи и сохранить в БД"""
if not message.text:
@@ -108,10 +74,18 @@ async def receive_emoji_description(message: Message, state: FSMContext):
return
description = message.text.strip()
if not description or len(description) > 255:
await message.answer("Введите описание длиной от 1 до 255 символов.")
return
data = await state.get_data()
emoji_text = data.get("emoji_text")
emoji_id = data.get("emoji_id")
if not emoji_text or not emoji_id:
await state.clear()
await message.answer("Начните добавление заново: /add_emoji")
return
# Сохраняем в БД
async with async_session_maker() as session:
emoji_service = EmojiMappingService(session)
@@ -120,8 +94,8 @@ async def receive_emoji_description(message: Message, state: FSMContext):
existing = await emoji_service.get_emoji_by_text(emoji_text, message.from_user.id)
if existing:
await message.answer(
f"⚠️ Вы уже зарегистрировали этот эмодзи: {emoji_text}\n"
f"Описание: <code>{existing.description}</code>",
f"⚠️ Вы уже зарегистрировали этот эмодзи: {escape(emoji_text or '')}\n"
f"Описание: <code>{escape(existing.description or '')}</code>",
parse_mode="HTML"
)
await state.clear()
@@ -137,16 +111,18 @@ async def receive_emoji_description(message: Message, state: FSMContext):
await message.answer(
f"✅ <b>Эмодзи успешно зарегистрировано!</b>\n\n"
f"Эмодзи: <code>{emoji_text}</code>\n"
f"Описание: <code>{description}</code>\n"
f"Эмодзи: <code>{escape(emoji_text or '')}</code>\n"
f"Описание: <code>{escape(description or '')}</code>\n"
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
f"Теперь это эмодзи будет автоматически использоваться в сообщениях бота.",
"Эмодзи сохранён в каталоге. В чатах и рассылках отправляйте его из панели Telegram.",
parse_mode="HTML"
)
except ValueError as error:
await message.answer(str(error), parse_mode=None)
except Exception as e:
logger.error(f"Error registering emoji: {e}")
await message.answer(
f"❌ Ошибка при сохранении эмодзи: {str(e)}",
f"❌ Ошибка при сохранении эмодзи: {public_error(e)}",
parse_mode="HTML"
)
@@ -156,7 +132,7 @@ async def receive_emoji_description(message: Message, state: FSMContext):
@router.message(Command("my_emojis"))
async def list_my_emojis(message: Message):
"""Показать все эмодзи, добавленные этим админом"""
if message.from_user.id not in ADMIN_IDS:
if not await is_admin(message.from_user.id):
await message.answer("❌ Эта команда доступна только администраторам")
return
@@ -174,7 +150,7 @@ async def list_my_emojis(message: Message):
text = "🎨 <b>Ваши зарегистрированные эмодзи:</b>\n\n"
for emoji in emojis:
text += (
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
f"<code>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
f" ID: <code>{emoji.emoji_id[:30]}</code>...\n"
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
)
@@ -185,7 +161,7 @@ async def list_my_emojis(message: Message):
@router.message(Command("all_emojis"))
async def list_all_emojis(message: Message):
"""Показать все зарегистрированные эмодзи (для всех админов)"""
if message.from_user.id not in ADMIN_IDS:
if not await is_admin(message.from_user.id):
await message.answer("❌ Эта команда доступна только администраторам")
return
@@ -202,8 +178,8 @@ async def list_all_emojis(message: Message):
text = "🎨 <b>Все зарегистрированные эмодзи в системе:</b>\n\n"
for emoji in emojis:
text += (
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
f" Админ: <code>{emoji.admin.first_name or 'Unknown'}</code> "
f"<code>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
f" Админ: <code>{escape(emoji.admin.first_name or 'Администратор') if emoji.admin else 'Администратор'}</code> "
f"(ID: {emoji.admin_id})\n"
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
)
@@ -214,7 +190,7 @@ async def list_all_emojis(message: Message):
@router.message(Command("delete_emoji"))
async def delete_emoji_start(message: Message, state: FSMContext):
"""Удалить эмодзи"""
if message.from_user.id not in ADMIN_IDS:
if not await is_admin(message.from_user.id):
await message.answer("❌ Эта команда доступна только администраторам")
return
@@ -233,7 +209,7 @@ async def delete_emoji_start(message: Message, state: FSMContext):
for emoji in emojis:
buttons.append([
InlineKeyboardButton(
text=f"{emoji.emoji_text} ({emoji.description})",
text=f"{emoji.emoji_text} ({emoji.description or ''})",
callback_data=f"delete_emoji_{emoji.emoji_id}"
)
])
@@ -258,14 +234,14 @@ async def delete_emoji_confirm(callback: CallbackQuery):
await callback.answer("❌ Эмодзи не найден", show_alert=True)
return
if emoji.admin_id != callback.from_user.id and callback.from_user.id not in ADMIN_IDS:
if (not emoji.admin or emoji.admin.telegram_id != callback.from_user.id) and callback.from_user.id not in ADMIN_IDS:
await callback.answer("❌ Вы не можете удалить эмодзи другого админа", show_alert=True)
return
success = await emoji_service.delete_emoji(emoji_id)
if success:
await callback.answer(
f"✅ Эмодзи <code>{emoji.emoji_text}</code> удалено",
f"✅ Эмодзи {emoji.emoji_text} удалено",
show_alert=True
)
await callback.message.delete()

View File

@@ -1,6 +1,11 @@
"""
Расширенная админ-панель для управления розыгрышами
"""
from src.utils.errors import public_error
from src.core.access import is_admin
from src.core.access import is_admin as check_admin_access
from sqlalchemy.orm import selectinload
from datetime import timezone
import logging
from aiogram import Router, F
from aiogram.types import (
@@ -112,9 +117,6 @@ class AdminStates(StatesGroup):
admin_router = Router()
def is_admin(user_id: int) -> bool:
"""Проверка прав администратора (быстрая проверка только .env)"""
return user_id in ADMIN_IDS
def is_super_admin(user_id: int) -> bool:
@@ -122,23 +124,6 @@ def is_super_admin(user_id: int) -> bool:
return user_id in ADMIN_IDS
async def check_admin_access(user_id: int) -> bool:
"""
Асинхронная проверка доступа администратора.
Проверяет как главных администраторов (.env), так и назначенных (БД)
"""
# Сначала проверяем главных администраторов
if user_id in ADMIN_IDS:
return True
# Затем проверяем назначенных администраторов в БД
async with async_session_maker() as session:
from sqlalchemy import select
result = await session.execute(
select(User).where(User.telegram_id == user_id, User.is_admin == True)
)
user = result.scalar_one_or_none()
return user is not None
def get_admin_main_keyboard() -> InlineKeyboardMarkup:
@@ -233,7 +218,7 @@ async def show_admin_panel(callback: CallbackQuery):
# УПРАВЛЕНИЕ РОЗЫГРЫШАМИ
# ======================
@admin_router.callback_query(F.data == "admin_lotteries")
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
async def show_lottery_management(callback: CallbackQuery):
"""Управление розыгрышами"""
if not await check_admin_access(callback.from_user.id):
@@ -277,7 +262,7 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
logging.info(f"✅ Состояние установлено: AdminStates.lottery_title")
except Exception as e:
logging.error(f"❌ Ошибка при создании розыгрыша: {e}")
await callback.message.answer(f"❌ Ошибка: {str(e)}")
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
@admin_router.message(StateFilter(AdminStates.lottery_title))
@@ -784,7 +769,7 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.add_participant_user)
@admin_router.message(StateFilter(AdminStates.add_participant_user))
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text)
async def process_add_participant(message: Message, state: FSMContext):
"""Обработка добавления участника"""
if not await check_admin_access(message.from_user.id):
@@ -905,7 +890,7 @@ async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMC
)
@admin_router.message(StateFilter(AdminStates.remove_participant_user))
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text)
async def process_remove_participant(message: Message, state: FSMContext):
"""Обработка удаления участника"""
if not await check_admin_access(message.from_user.id):
@@ -1180,7 +1165,7 @@ async def start_search_participants(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.participant_search)
@admin_router.message(StateFilter(AdminStates.participant_search))
@admin_router.message(StateFilter(AdminStates.participant_search), F.text)
async def process_search_participants(message: Message, state: FSMContext):
"""Обработка поиска участников"""
if not await check_admin_access(message.from_user.id):
@@ -1490,7 +1475,7 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавление участника в конкретный розыгрыш"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -1520,10 +1505,10 @@ async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext)
await state.set_state(AdminStates.add_to_lottery_user)
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user))
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text)
async def process_add_to_lottery(message: Message, state: FSMContext):
"""Обработка добавления участника в конкретный розыгрыш"""
if not is_admin(message.from_user.id):
if not await is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
@@ -1624,7 +1609,7 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
"""Удаление участника из конкретного розыгрыша"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -1656,10 +1641,10 @@ async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMCon
await state.set_state(AdminStates.remove_from_lottery_user)
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user))
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text)
async def process_remove_from_lottery(message: Message, state: FSMContext):
"""Обработка удаления участника из конкретного розыгрыша"""
if not is_admin(message.from_user.id):
if not await is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
@@ -1741,7 +1726,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
async def check_winners(callback: CallbackQuery):
"""Проверка подтверждения победителей"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -1810,7 +1795,7 @@ async def check_winners(callback: CallbackQuery):
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
async def redraw_lottery(callback: CallbackQuery):
"""Повторный розыгрыш для неподтверждённых призов"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -1860,123 +1845,23 @@ async def redraw_lottery(callback: CallbackQuery):
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
async def confirm_redraw(callback: CallbackQuery):
"""Подтверждение и выполнение повторного розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
if not await check_admin_access(callback.from_user.id):
await callback.answer("Недостаточно прав", show_alert=True)
return
from src.core.redraw_services import redraw_unclaimed
from src.utils.notifications import notify_winners_async
lottery_id = int(callback.data.split("_")[-1])
await callback.answer("⏳ Проводится повторный розыгрыш...", show_alert=True)
await callback.answer("Проверяю неподтверждённые призы")
async with async_session_maker() as session:
from sqlalchemy import select, delete
from ..core.models import Winner, Participation
import random
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.message.edit_text("❌ Розыгрыш не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
# Собираем подтверждённые и неподтверждённые
confirmed_winners = [w for w in winners if w.is_claimed]
unconfirmed_winners = [w for w in winners if not w.is_claimed]
if not unconfirmed_winners:
await callback.message.edit_text(
"Все победители уже подтверждены!",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
# Собираем исключённые счета (подтверждённые победители + бывшие неподтверждённые)
excluded_accounts = set()
for w in winners:
if w.account_number:
excluded_accounts.add(w.account_number)
# Получаем всех участников, исключая уже выигравших
result = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id)
)
all_participations = result.scalars().all()
# Фильтруем участников
available_participations = [
p for p in all_participations
if p.account_number not in excluded_accounts
]
if not available_participations:
await callback.message.edit_text(
"❌ Нет доступных участников для переигровки.\n"
"Все участники уже выиграли или были исключены.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
return
# Удаляем неподтверждённых победителей
for winner in unconfirmed_winners:
await session.delete(winner)
# Проводим розыгрыш для неподтверждённых мест
new_winners_text = ""
random.shuffle(available_participations)
for i, old_winner in enumerate(unconfirmed_winners):
if i >= len(available_participations):
break
new_participation = available_participations[i]
# Создаём нового победителя
new_winner = Winner(
lottery_id=lottery_id,
user_id=new_participation.user_id,
account_number=new_participation.account_number,
place=old_winner.place,
is_manual=False,
is_claimed=False
)
session.add(new_winner)
# Исключаем из следующих итераций
if new_participation.account_number:
excluded_accounts.add(new_participation.account_number)
prize = lottery.prizes[old_winner.place - 1] if lottery.prizes and len(lottery.prizes) >= old_winner.place else "Приз"
name = new_participation.account_number or f"ID: {new_participation.user_id}"
new_winners_text += f"🏆 {old_winner.place} место: {name}{prize}\n"
await session.commit()
# Отправляем уведомления новым победителям
from ..utils.notifications import notify_winners_async
try:
winners = await redraw_unclaimed(session, lottery_id)
if winners:
await notify_winners_async(callback.bot, session, lottery_id)
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
text = "Повторный розыгрыш завершён.\n" if winners else "Нет просроченных неподтверждённых призов или новых участников."
for winner in winners:
text += f"{winner.place} место: {winner.account_number or winner.user_id}{winner.prize}\n"
await callback.message.answer(text)
text = f"🎉 Повторный розыгрыш завершён!\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
text += f"Новые победители:\n{new_winners_text}\n"
text += "✅ Уведомления отправлены новым победителям"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Проверить победителей", callback_data=f"admin_check_winners_{lottery_id}")],
[InlineKeyboardButton(text="🔙 К розыгрышу", callback_data=f"admin_lottery_detail_{lottery_id}")]
])
)
# ======================
@@ -1986,7 +1871,7 @@ async def confirm_redraw(callback: CallbackQuery):
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
async def delete_lottery_confirm(callback: CallbackQuery):
"""Подтверждение удаления розыгрыша"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -2020,7 +1905,7 @@ async def delete_lottery_confirm(callback: CallbackQuery):
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
async def delete_lottery_execute(callback: CallbackQuery):
"""Выполнение удаления розыгрыша"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -2037,15 +1922,7 @@ async def delete_lottery_execute(callback: CallbackQuery):
lottery_title = lottery.title
# Удаляем победителей
await session.execute(sql_delete(Winner).where(Winner.lottery_id == lottery_id))
# Удаляем участников
await session.execute(sql_delete(Participation).where(Participation.lottery_id == lottery_id))
# Удаляем розыгрыш
await session.delete(lottery)
await session.commit()
await LotteryService.delete_lottery(session, lottery_id)
await callback.message.edit_text(
f"✅ Розыгрыш удалён\n\n"
@@ -2384,7 +2261,7 @@ async def show_participants_report(callback: CallbackQuery):
select(
User.first_name,
User.username,
User.account_number,
func.min(Participation.account_number),
func.count(Participation.id).label('participations')
)
.join(Participation)
@@ -2396,11 +2273,11 @@ async def show_participants_report(callback: CallbackQuery):
# Участники с аккаунтами vs без
users_with_accounts = await session.scalar(
select(func.count(User.id)).where(User.account_number.isnot(None))
select(func.count(User.id)).where(User.accounts.any())
)
users_without_accounts = await session.scalar(
select(func.count(User.id)).where(User.account_number.is_(None))
select(func.count(User.id)).where(~User.accounts.any())
)
text = "📈 Отчет по участникам\n\n"
@@ -2951,7 +2828,7 @@ async def process_winner_place(message: Message, state: FSMContext):
await state.set_state(AdminStates.set_winner_user)
@admin_router.message(StateFilter(AdminStates.set_winner_user))
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text)
async def process_winner_user(message: Message, state: FSMContext):
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
if not await check_admin_access(message.from_user.id):
@@ -3391,6 +3268,8 @@ async def do_remove_winner(callback: CallbackQuery):
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
# Удаляем победителя
from src.core.models import WinnerVerification
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id == winner_id))
await session.execute(delete(Winner).where(Winner.id == winner_id))
await session.commit()
@@ -3443,7 +3322,7 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
)
])
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_draws")])
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@@ -3546,7 +3425,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
except Exception as e:
logger.error(f"Ошибка при проведении розыгрыша {lottery_id}: {e}", exc_info=True)
await session.rollback()
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
await callback.answer(f"❌ Ошибка: {public_error(e)}", show_alert=True)
return
if winners_dict:
@@ -3587,7 +3466,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_draws")]
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_lotteries")]
])
)
else:
@@ -4095,7 +3974,7 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
except Exception as e:
logger.error(f"💥 Исключение при смене типа отображения: {e}")
text = f"❌ Ошибка: {str(e)}"
text = f"❌ Ошибка: {public_error(e)}"
await callback.answer("❌ Ошибка при сохранении!", show_alert=True)
return
await callback.answer("❌ Ошибка сохранения", show_alert=True)
@@ -4402,6 +4281,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
winners_dict: Словарь с победителями {место: данные}
"""
import asyncio
from html import escape
# Получаем розыгрыш
lottery = await LotteryService.get_lottery(session, lottery_id)
@@ -4428,7 +4308,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
# Формируем сообщение с результатами
message = (
f"📢 <b>Результаты розыгрыша</b>\n\n"
f"🎯 <b>{lottery.title}</b>\n\n"
f"🎯 <b>{escape(lottery.title)}</b>\n\n"
f"🏆 <b>Победители:</b>\n"
)
@@ -4451,7 +4331,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
# Формируем строку победителя
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"{winner.place} место: {escape(winner_name)}\n"
message += (
f"\n🎁 Поздравляем победителей!\n"
@@ -4459,6 +4339,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
)
# Рассылаем всем кроме победителей
await session.commit()
from src.utils.delivery import send_background
success_count = 0
fail_count = 0
@@ -4468,9 +4350,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
continue
try:
await bot.send_message(
user.telegram_id,
message,
await send_background(bot, chat_id=user.telegram_id,
text=message,
parse_mode="HTML"
)
success_count += 1
@@ -4488,100 +4369,19 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
@admin_router.callback_query(F.data == "admin_export_users")
async def admin_export_users(callback: CallbackQuery):
"""Экспорт всех пользователей в XLSX"""
if not await check_admin_access(callback.from_user.id):
await callback.answer("Доступ запрещен", show_alert=True)
await callback.answer("Недостаточно прав", show_alert=True)
return
await callback.answer("Готовлю файл")
import asyncio
from aiogram.types import BufferedInputFile
from src.utils.spreadsheets import export_users
async with async_session_maker() as session:
users = await UserService.get_all_users(session)
content = await asyncio.to_thread(export_users, users)
await callback.message.answer_document(BufferedInputFile(content, filename="users.xlsx"),
caption=f"Пользователей: {len(users)}")
await callback.answer("⏳ Формирую файл...", show_alert=False)
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from aiogram.types import BufferedInputFile
async with async_session_maker() as session:
# Получаем всех пользователей
all_users = await UserService.get_all_users(session)
# Создаем Excel файл
wb = Workbook()
ws = wb.active
ws.title = "Пользователи"
# Заголовки
headers = [
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
]
# Стиль для заголовков
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
# Данные пользователей
for row_num, user in enumerate(all_users, 2):
ws.cell(row=row_num, column=1, value=user.telegram_id)
ws.cell(row=row_num, column=2, value=user.username or '')
ws.cell(row=row_num, column=3, value=user.first_name or '')
ws.cell(row=row_num, column=4, value=user.last_name or '')
ws.cell(row=row_num, column=5, value=user.nickname or '')
ws.cell(row=row_num, column=6, value=user.phone or '')
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
ws.cell(row=row_num, column=10, value=user.verification_code or '')
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
# Автоподбор ширины колонок
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Сохраняем в BytesIO
excel_file = BytesIO()
wb.save(excel_file)
excel_file.seek(0)
# Отправляем файл
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
file = BufferedInputFile(excel_file.read(), filename=filename)
registered_count = len([u for u in all_users if u.is_registered])
await callback.message.answer_document(
document=file,
caption=(
f"📥 <b>Экспорт пользователей</b>\n\n"
f"📊 Всего пользователей: {len(all_users)}\n"
f"✅ Зарегистрировано: {registered_count}\n"
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
),
parse_mode="HTML"
)
await callback.answer("✅ Файл отправлен", show_alert=False)
except Exception as e:
logger.error(f"Ошибка экспорта пользователей: {e}")
await callback.answer("❌ Ошибка при создании файла", show_alert=True)
@admin_router.callback_query(F.data == "admin_import_users")
@@ -4619,172 +4419,32 @@ async def admin_import_users_start(callback: CallbackQuery, state: FSMContext):
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
async def admin_import_users_process(message: Message, state: FSMContext):
"""Обработка импорта пользователей из XLSX"""
if not await check_admin_access(message.from_user.id):
return
# Проверяем формат файла
if not message.document.file_name.endswith('.xlsx'):
await message.answer("❌ Неверный формат файла. Отправьте XLSX файл.")
import asyncio
from src.utils.spreadsheets import read_users_xlsx, MAX_FILE_BYTES
from src.core.import_services import import_users
if not (message.document.file_name or "").lower().endswith(".xlsx"):
await message.answer("Отправьте XLSX файл.")
return
status_msg = await message.answer("⏳ Загружаю файл...")
if (message.document.file_size or 0) > MAX_FILE_BYTES:
await message.answer("Файл должен быть не больше 5 МБ.")
return
status = await message.answer("Импортирую пользователей…")
try:
from openpyxl import load_workbook
from io import BytesIO
# Скачиваем файл
file = await message.bot.get_file(message.document.file_id)
file_content = await message.bot.download_file(file.file_path)
# Читаем Excel файл
excel_file = BytesIO(file_content.read())
wb = load_workbook(excel_file, read_only=True)
ws = wb.active
# Читаем данные
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
await state.clear()
return
# Первая строка - заголовки
headers = [h if h else '' for h in rows[0]]
# Находим индекс колонки Telegram ID
try:
telegram_id_idx = headers.index('Telegram ID')
except ValueError:
await status_msg.edit_text("Не найдена обязательная колонка 'Telegram ID'.")
await state.clear()
return
# Создаем маппинг индексов для других полей
field_mapping = {
'Username': 'username',
'Имя': 'first_name',
'Фамилия': 'last_name',
'Никнейм': 'nickname',
'Телефон': 'phone',
'Клубная карта': 'club_card_number',
'Зарегистрирован': 'is_registered',
'Код верификации': 'verification_code'
}
users_data = []
for row in rows[1:]: # Пропускаем заголовки
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
continue
user_dict = {'telegram_id': row[telegram_id_idx]}
for header_name, field_name in field_mapping.items():
try:
idx = headers.index(header_name)
if idx < len(row):
value = row[idx]
if field_name == 'is_registered':
user_dict[field_name] = value in ['Да', 'Yes', 'True', True, 1]
else:
user_dict[field_name] = value if value else None
except (ValueError, IndexError):
user_dict[field_name] = None
users_data.append(user_dict)
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
# Преобразуем telegram_id в int если это строка
try:
telegram_id = int(telegram_id)
except (ValueError, TypeError):
error_count += 1
continue
# Ищем существующего пользователя
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
if existing_user:
# Обновляем существующего
if user_data.get('username') is not None:
existing_user.username = user_data.get('username')
if user_data.get('first_name') is not None:
existing_user.first_name = user_data.get('first_name')
if user_data.get('last_name') is not None:
existing_user.last_name = user_data.get('last_name')
if user_data.get('nickname') is not None:
existing_user.nickname = user_data.get('nickname')
if user_data.get('phone') is not None:
existing_user.phone = user_data.get('phone')
if user_data.get('club_card_number') is not None:
existing_user.club_card_number = user_data.get('club_card_number')
if user_data.get('is_registered') is not None:
existing_user.is_registered = user_data.get('is_registered', False)
if user_data.get('verification_code') is not None:
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"
)
content = await message.bot.download_file(file.file_path)
records = await asyncio.to_thread(read_users_xlsx, content.read())
added, updated, errors = await import_users(records)
await status.edit_text(f"Добавлено: {added}. Обновлено: {updated}. Ошибок: {errors}.")
except ValueError as error:
await status.edit_text(str(error))
except Exception:
logger.exception("Не удалось импортировать XLSX")
await status.edit_text("Не удалось обработать файл. Проверьте формат данных.")
finally:
await state.clear()
except Exception as e:
logger.error(f"Ошибка импорта пользователей: {e}")
await status_msg.edit_text(f"❌ Ошибка импорта: {str(e)}\n\nПроверьте формат файла.")
await state.clear()
# ============================================================================
@@ -5176,7 +4836,7 @@ async def admin_broadcast_channels_menu(callback: CallbackQuery, state: FSMConte
buttons = [
[InlineKeyboardButton(text="✨ Добавить канал/группу", callback_data="admin_broadcast_add_channel")],
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_list_channels")],
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_channels")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_broadcast")]
]
@@ -5278,7 +4938,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
f"• Бот не добавлен в канал/группу\n"
f"• Неверный ID\n"
f"• Бот не имеет прав администратора\n\n"
f"Детали: {str(e)}",
f"Детали: {public_error(e)}",
parse_mode="HTML"
)
await state.clear()
@@ -5551,7 +5211,7 @@ async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.user_management_search)
@admin_router.message(AdminStates.user_management_search)
@admin_router.message(AdminStates.user_management_search, F.text)
async def admin_users_search_process(message: Message, state: FSMContext):
"""Обработка поискового запроса"""
if not await check_admin_access(message.from_user.id):
@@ -5932,7 +5592,7 @@ async def add_admin_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.admin_add_search)
@admin_router.message(StateFilter(AdminStates.admin_add_search))
@admin_router.message(StateFilter(AdminStates.admin_add_search), F.text)
async def search_user_for_admin(message: Message, state: FSMContext):
"""Поиск пользователя для назначения админом"""
if not is_super_admin(message.from_user.id):
@@ -6085,4 +5745,4 @@ async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
# Экспорт роутера
__all__ = ['admin_router']
__all__ = ['admin_router']

View File

@@ -0,0 +1,100 @@
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy import select, update
from src.core.access import staff_only
from src.core.config import ADMIN_IDS, CASHIER_IDS
from src.core.database import async_session_maker
from src.core.models import User
from src.core.services import LotteryService
from src.handlers.account_services import AccountParticipationService
from src.middlewares.access import AccessMiddleware
from src.utils.account_utils import parse_accounts_from_message
cashier_router = Router(name="cashier")
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
cashier_router.callback_query.middleware(AccessMiddleware(allow_cashier=True))
class CashierStates(StatesGroup):
accounts = State()
@cashier_router.message(Command("cashier"))
@staff_only
async def cashier_menu(message: Message, state: FSMContext):
await state.clear()
async with async_session_maker() as session:
lotteries = await LotteryService.get_active_lotteries(session, limit=30)
buttons = [[InlineKeyboardButton(text=lottery.title[:60], callback_data=f"cash_add:{lottery.id}")]
for lottery in lotteries]
await message.answer(
"💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n"
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
"/remove_account СЧЕТ — деактивировать счет\n"
"/verify_winner КОД ID_РОЗЫГРЫША — подтвердить выдачу приза\n"
"/winner_status ID_РОЗЫГРЫША — проверить выдачу\n"
"/cancel — отменить ввод",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None,
)
@cashier_router.callback_query(F.data == "cashier_panel")
async def cashier_menu_callback(callback: CallbackQuery, state: FSMContext):
await callback.answer()
# Do not use the bot-authored message as the identity for authorization.
await cashier_menu.__wrapped__(callback.message, state)
@cashier_router.callback_query(F.data.startswith("cash_add:"))
async def choose_draw(callback: CallbackQuery, state: FSMContext):
lottery_id = int(callback.data.split(":")[1])
await state.clear()
await state.update_data(lottery_id=lottery_id)
await state.set_state(CashierStates.accounts)
await callback.answer()
await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.")
@cashier_router.message(CashierStates.accounts, F.text)
async def add_accounts(message: Message, state: FSMContext):
accounts = parse_accounts_from_message(message.text)
if not accounts or len(accounts) > 1000:
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
return
data = await state.get_data()
async with async_session_maker() as session:
result = await AccountParticipationService.add_accounts_bulk(session, data["lottery_id"], accounts)
await state.clear()
await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n"
+ "\n".join(result["errors"][:10]))
@cashier_router.message(Command("add_cashier", "remove_cashier", "cashiers"))
async def manage_cashiers(message: Message):
if message.from_user.id not in ADMIN_IDS:
await message.answer("Назначать кассиров может только главный администратор.")
return
parts = message.text.split()
command = parts[0].split("@")[0].lower()
async with async_session_maker() as session:
if command == "/cashiers":
ids = set((await session.scalars(select(User.telegram_id).where(User.is_cashier.is_(True)))).all())
ids.update(CASHIER_IDS)
await message.answer("Кассиры:\n" + ("\n".join(map(str, sorted(ids))) or "Нет назначенных кассиров"))
return
if len(parts) != 2 or not parts[1].isascii() or not parts[1].isdigit():
await message.answer(f"Формат: {command} TELEGRAM_ID")
return
telegram_id = int(parts[1])
if telegram_id in CASHIER_IDS and command == "/remove_cashier":
await message.answer("Этот кассир задан в CASHIER_IDS; удалите ID из настройки окружения.")
return
changed = await session.execute(update(User).where(User.telegram_id == telegram_id)
.values(is_cashier=command == "/add_cashier"))
await session.commit()
await message.answer("✅ Права обновлены" if changed.rowcount else "Пользователь должен сначала выполнить /start.")

View File

@@ -1,4 +1,7 @@
"""Обработчики пользовательских сообщений в чате"""
from src.utils.errors import public_error
from src.core.access import is_admin
from src.utils.delivery import background_delivery
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
@@ -10,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from typing import List, Dict, Optional, Set, Any
from collections import deque
from src.utils.telegram_messages import copy_preserving_entities
import time
from src.core.chat_services import (
@@ -29,9 +33,6 @@ class ChatStates(StatesGroup):
in_chat = State() # Пользователь находится в режиме чата
def is_admin(user_id: int) -> bool:
"""Проверка является ли пользователь админом"""
return user_id in ADMIN_IDS
def _contains_account_numbers(text: str) -> bool:
@@ -98,10 +99,10 @@ async def exit_chat_command(message: Message, state: FSMContext):
async def exit_chat_callback(callback: CallbackQuery, state: FSMContext):
"""Выйти из режима чата через кнопку"""
await callback.answer()
await exit_chat(callback.message, state)
await exit_chat(callback.message, state, actor=callback.from_user)
async def exit_chat(message: Message, state: FSMContext):
async def exit_chat(message: Message, state: FSMContext, actor=None):
"""Общая функция выхода из чата"""
from src.utils.keyboards import get_main_reply_keyboard
from src.core.config import ADMIN_IDS
@@ -112,9 +113,9 @@ async def exit_chat(message: Message, state: FSMContext):
# Получаем информацию о пользователе
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
user = await UserService.get_user_by_telegram_id(session, (actor or message.from_user).id)
is_registered = user.is_registered if user else False
is_admin_user = message.from_user.id in ADMIN_IDS
is_admin_user = await is_admin((actor or message.from_user).id)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
@@ -171,7 +172,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
# Защита от дубликатов - если сообщение уже обработано, пропускаем
if _is_message_processed(message.message_id):
if _is_message_processed(message.chat.id, message.message_id):
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
return
@@ -179,7 +180,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
# Пропускаем для account_router (который идет после chat_router)
if is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
if await is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
if _contains_account_numbers(message.text):
logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем - account_router обработает")
# Не делаем return, выбрасываем исключение для пропуска в следующий обработчик
@@ -187,13 +188,14 @@ async def check_exit_keywords(message: Message, state: FSMContext):
raise SkipHandler()
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
if message.reply_to_message and is_admin(message.from_user.id):
if message.reply_to_message and await is_admin(message.from_user.id):
if message.text and message.text.lower().strip() in ['удалить', 'del', '-']:
async with async_session_maker() as session:
# Ищем сообщение в БД по telegram_message_id
msg_to_delete = await ChatMessageService.get_message_by_telegram_id(
session,
telegram_message_id=message.reply_to_message.message_id
telegram_message_id=message.reply_to_message.message_id,
chat_id=message.chat.id,
)
if msg_to_delete:
@@ -271,7 +273,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
command = message.text.split()[0] if message.text else ''
# ИЗМЕНЕНИЕ: Если это команда от АДМИНА - не пересылаем (админ сам её видит)
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
if command in admin_commands:
return
@@ -290,7 +292,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
is_admin=await is_admin(message.from_user.id)
)
if not can_send:
@@ -314,6 +316,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
# Режим рассылки с планировщиком
# Передаем объект user для динамического формирования подписей
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
await session.commit()
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
sender_user=user,
@@ -331,7 +334,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
)
# Показываем статистику доставки только админам
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
await message.answer(
f"✅ Сообщение разослано!\n"
f"📤 Доставлено: {success}\n"
@@ -370,11 +373,12 @@ BATCH_DELAY = 1.0 # Задержка между пакетами в секун
_processed_messages: deque = deque(maxlen=100)
def _is_message_processed(message_id: int) -> bool:
def _is_message_processed(chat_id: int, message_id: int) -> bool:
"""Проверка, было ли сообщение уже обработано"""
if message_id in _processed_messages:
key = (chat_id, message_id)
if key in _processed_messages:
return True
_processed_messages.append(message_id)
_processed_messages.append(key)
return False
@@ -420,7 +424,7 @@ async def broadcast_message_with_scheduler(
# Если только для админов - фильтруем
if admin_only:
users = [u for u in users if u.telegram_id in ADMIN_IDS]
users = [u for u in users if (u.telegram_id in ADMIN_IDS or u.is_admin)]
logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей")
forwarded_ids = {}
@@ -435,7 +439,7 @@ async def broadcast_message_with_scheduler(
tasks = []
for recipient_user in batch:
# Формируем подпись в зависимости от получателя
if recipient_user.telegram_id in ADMIN_IDS:
if (recipient_user.telegram_id in ADMIN_IDS or recipient_user.is_admin):
# Админы видят полную информацию: 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
@@ -448,7 +452,7 @@ async def broadcast_message_with_scheduler(
# Обычные пользователи видят:
# - "Админ" если отправитель - админ
# - nickname если отправитель - обычный пользователь
if sender_user.telegram_id in ADMIN_IDS:
if (sender_user.telegram_id in ADMIN_IDS or sender_user.is_admin):
sender_info = "Админ"
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
else:
@@ -478,175 +482,38 @@ async def broadcast_message_with_scheduler(
return forwarded_ids, success_count, fail_count
@background_delivery
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
"""
try:
sent_msg = await message.copy_to(user_telegram_id)
sent_msg = await copy_preserving_entities(message, user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
print(f"Failed to send message to {user_telegram_id}: {public_error(e)}")
return None
@background_delivery
async def _copy_with_sender(message: Message, recipient_id: int, sender_info: str):
"""Preserve Telegram entities and keep headers within message/caption limits."""
try:
sent = await copy_preserving_entities(message, recipient_id, sender_info or "Участник")
return sent.message_id
except Exception:
import logging
logging.getLogger(__name__).exception("Chat delivery failed")
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
return await _copy_with_sender(message, user_telegram_id, sender_info)
async def _send_message_to_admin_with_sender(message: Message, admin_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(
admin_telegram_id,
header + message.text,
parse_mode="HTML"
)
elif message.photo:
# Фото
caption = header + (message.caption or "")
sent_msg = await message.bot.send_photo(
admin_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(
admin_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(
admin_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(
admin_telegram_id,
animation=message.animation.file_id,
caption=caption,
parse_mode="HTML"
)
elif message.sticker:
# Стикер - сначала отправляем заголовок, потом стикер
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
elif message.voice:
# Голосовое сообщение
sent_msg = await message.bot.send_voice(
admin_telegram_id,
voice=message.voice.file_id,
caption=header,
parse_mode="HTML"
)
elif message.video_note:
# Видео-кружок
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
else:
# Неизвестный тип - просто копируем
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
sent_msg = await message.copy_to(admin_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
return None
return await _copy_with_sender(message, admin_telegram_id, sender_info)
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
@@ -656,7 +523,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
sent_msg = await message.forward(channel_id)
return True, sent_msg.message_id
except Exception as e:
print(f"Failed to forward message to channel {channel_id}: {e}")
print(f"Failed to forward message to channel {channel_id}: {public_error(e)}")
return False, None
@@ -665,14 +532,14 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
async def handle_photo_message(message: Message, state: FSMContext):
"""Обработчик фото"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
if _is_message_processed(message.chat.id, message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
is_admin=await is_admin(message.from_user.id)
)
if not can_send:
@@ -693,6 +560,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
if settings.mode == 'broadcast':
# Рассылаем фото - ВСЕГДА исключаем отправителя
await session.commit()
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
sender_user=user,
@@ -710,7 +578,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
)
# Показываем статистику только админам
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
await message.answer(f"✅ Фото разослано: {success} получателей")
elif settings.mode == 'forward':
@@ -734,14 +602,14 @@ async def handle_photo_message(message: Message, state: FSMContext):
async def handle_video_message(message: Message, state: FSMContext):
"""Обработчик видео"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
if _is_message_processed(message.chat.id, message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
is_admin=await is_admin(message.from_user.id)
)
if not can_send:
@@ -759,6 +627,7 @@ async def handle_video_message(message: Message, state: FSMContext):
if settings.mode == 'broadcast':
# Рассылаем видео
await session.commit()
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
sender_user=user,
@@ -776,7 +645,7 @@ async def handle_video_message(message: Message, state: FSMContext):
)
# Показываем статистику только админам
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
await message.answer(f"✅ Видео разослано: {success} получателей")
elif settings.mode == 'forward':
@@ -800,14 +669,14 @@ async def handle_video_message(message: Message, state: FSMContext):
async def handle_document_message(message: Message, state: FSMContext):
"""Обработчик документов"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
if _is_message_processed(message.chat.id, message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
is_admin=await is_admin(message.from_user.id)
)
if not can_send:
@@ -825,6 +694,7 @@ async def handle_document_message(message: Message, state: FSMContext):
if settings.mode == 'broadcast':
# Рассылаем документ
await session.commit()
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
sender_user=user,
@@ -842,7 +712,7 @@ async def handle_document_message(message: Message, state: FSMContext):
)
# Показываем статистику только админам
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
await message.answer(f"✅ Документ разослан: {success} получателей")
elif settings.mode == 'forward':
@@ -866,14 +736,14 @@ async def handle_document_message(message: Message, state: FSMContext):
async def handle_animation_message(message: Message, state: FSMContext):
"""Обработчик GIF анимаций"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
if _is_message_processed(message.chat.id, message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
is_admin=await is_admin(message.from_user.id)
)
if not can_send:
@@ -891,6 +761,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
if settings.mode == 'broadcast':
# Рассылаем анимацию
await session.commit()
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
sender_user=user,
@@ -908,7 +779,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
)
# Показываем статистику только админам
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
await message.answer(f"✅ Анимация разослана: {success} получателей")
elif settings.mode == 'forward':
@@ -932,14 +803,14 @@ async def handle_animation_message(message: Message, state: FSMContext):
async def handle_sticker_message(message: Message, state: FSMContext):
"""Обработчик стикеров"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
if _is_message_processed(message.chat.id, message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id,
is_admin=is_admin(message.from_user.id)
is_admin=await is_admin(message.from_user.id)
)
if not can_send:
@@ -957,6 +828,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
if settings.mode == 'broadcast':
# Рассылаем стикер
await session.commit()
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
sender_user=user,
@@ -973,7 +845,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
)
# Показываем статистику только админам
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
await message.answer(f"✅ Стикер разослан: {success} получателей")
elif settings.mode == 'forward':

View File

@@ -1,4 +1,5 @@
"""Обработчики справки и помощи пользователям"""
from src.core.access import is_admin
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
@@ -7,9 +8,6 @@ from src.core.config import ADMIN_IDS
from src.filters.case_insensitive import CaseInsensitiveCommand
def is_admin(user_id: int) -> bool:
"""Проверка является ли пользователь админом"""
return user_id in ADMIN_IDS
router = Router(name='help_router')
@@ -241,7 +239,7 @@ async def help_commands(callback: CallbackQuery):
await callback.answer()
user_id = callback.from_user.id
is_user_admin = is_admin(user_id)
is_user_admin = await is_admin(user_id)
text = (
"⚙️ <b>Список команд бота</b>\n\n"

View File

@@ -1,6 +1,8 @@
"""
Хэндлеры для управления сообщениями администратором
"""
from src.utils.errors import public_error
from src.core.access import is_admin
import logging
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery
@@ -17,9 +19,6 @@ logger = logging.getLogger(__name__)
message_admin_router = Router(name="message_admin")
def is_admin(user_id: int) -> bool:
"""Проверка, является ли пользователь администратором"""
return user_id in ADMIN_IDS
@message_admin_router.message(CaseInsensitiveCommand("delete"))
@@ -28,7 +27,7 @@ async def delete_replied_message(message: Message):
Удаление сообщения по команде /delete (регистронезависимо)
Работает только если команда является ответом на сообщение бота
"""
if not is_admin(message.from_user.id):
if not await is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
@@ -48,7 +47,7 @@ async def delete_replied_message(message: Message):
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await message.answer(f"Не удалось удалить сообщение: {str(e)}")
await message.answer(f"Не удалось удалить сообщение: {public_error(e)}")
@message_admin_router.callback_query(F.data == "delete_message")
@@ -56,7 +55,7 @@ async def delete_message_callback(callback: CallbackQuery):
"""
Удаление сообщения по нажатию кнопки
"""
if not is_admin(callback.from_user.id):
if not await is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -66,7 +65,7 @@ async def delete_message_callback(callback: CallbackQuery):
logger.info(f"Администратор {callback.from_user.id} удалил сообщение через кнопку")
except Exception as e:
logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
await callback.answer(f"❌ Ошибка: {public_error(e)}", show_alert=True)
# Функция-фильтр для проверки триггерных слов
@@ -93,7 +92,7 @@ async def quick_delete_replied_message(message: Message):
Удаляет сообщение у всех получателей broadcast рассылки
"""
if not is_admin(message.from_user.id):
if not await is_admin(message.from_user.id):
return # Не админ - пропускаем
try:
@@ -115,7 +114,8 @@ async def quick_delete_replied_message(message: Message):
chat_message = await ChatMessageService.get_message_by_telegram_id(
session,
telegram_message_id=replied_msg.message_id
telegram_message_id=replied_msg.message_id,
chat_id=message.chat.id,
)
# Если нашли broadcast сообщение - удаляем у всех получателей

View File

@@ -1,4 +1,8 @@
"""Обработчики P2P чата между пользователями"""
from src.utils.errors import public_error
from src.utils.telegram_messages import copy_preserving_entities
from html import escape
from src.core.access import is_admin
from aiogram import Router, F
from aiogram.filters import Command, StateFilter
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
@@ -25,9 +29,6 @@ class P2PChatStates(StatesGroup):
chatting = State() # В процессе переписки с пользователем
def is_admin(user_id: int) -> bool:
"""Проверка прав администратора"""
return user_id in ADMIN_IDS
def format_sender_name(user: User, is_current_user: bool = False, current_user_is_admin: bool = False) -> str:
@@ -105,7 +106,7 @@ async def show_chat_menu(message: Message, state: FSMContext):
)]
]
if is_admin(message.from_user.id):
if await is_admin(message.from_user.id):
buttons.append([InlineKeyboardButton(
text="📢 Написать всем (broadcast)",
callback_data="p2p:broadcast"
@@ -126,7 +127,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
async with async_session_maker() as session:
# Получаем всех зарегистрированных пользователей кроме себя
users = await UserService.get_all_users(session)
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
users = [u for u in users if u.telegram_id != callback.from_user.id and (u.is_registered or u.is_admin or u.telegram_id in ADMIN_IDS)]
if not users:
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
@@ -136,7 +137,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
buttons = []
for user in users[:20]: # Ограничение 20 пользователей на странице
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
if user.club_card_number:
if user.club_card_number and await is_admin(callback.from_user.id):
display_name += f" (карта: {user.club_card_number})"
buttons.append([InlineKeyboardButton(
@@ -201,10 +202,10 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
# Определяем имя отправителя
is_current = msg.sender_id == sender.id
user_for_display = sender if is_current else recipient
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
sender_name = format_sender_name(user_for_display, is_current, await is_admin(sender.telegram_id))
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
text += f"{sender_name}: {msg_text}\n"
text += f"{escape(sender_name)}: {escape(msg_text)}\n"
text += "\n"
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
@@ -267,12 +268,12 @@ async def show_conversations(callback: CallbackQuery):
callback_data=f"p2p:user:{peer.id}"
)])
text += f"{icon} <b>{peer_name}</b>"
text += f"{icon} <b>{escape(peer_name)}</b>"
# Показываем номер карты если есть
if peer.club_card_number:
text += f" (карта: {peer.club_card_number})"
if peer.club_card_number and await is_admin(callback.from_user.id):
text += f" (карта: {escape(peer.club_card_number)})"
text += "\n"
text += f" {preview}\n"
text += f" {escape(preview)}\n"
if unread > 0:
text += f" 📨 Непрочитанных: {unread}\n"
text += "\n"
@@ -384,13 +385,13 @@ async def handle_p2p_message(message: Message, state: FSMContext):
recipient = await UserService.get_user_by_telegram_id(session, recipient_telegram_id)
# Формируем подпись сообщения для получателя
if sender.is_admin:
if await is_admin(sender.telegram_id):
sender_name = "АДМИН"
else:
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
sender_name = sender.nickname or (f"@{sender.username}" if sender.username else sender.first_name) or "Участник"
# Добавляем карту если получатель админ
if recipient and recipient.is_admin and sender.club_card_number:
if recipient and await is_admin(recipient.telegram_id) and sender.club_card_number:
sender_name += f" (карта: {sender.club_card_number})"
# Определяем тип сообщения
@@ -411,35 +412,11 @@ async def handle_p2p_message(message: Message, state: FSMContext):
file_id = message.document.file_id
text = message.caption
await session.commit()
# Отправляем сообщение получателю
try:
if message_type == "text":
sent = await message.bot.send_message(
recipient_telegram_id,
f"<b>{sender_name}</b>\n\n{text}",
parse_mode="HTML"
)
elif message_type == "photo":
sent = await message.bot.send_photo(
recipient_telegram_id,
photo=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
parse_mode="HTML"
)
elif message_type == "video":
sent = await message.bot.send_video(
recipient_telegram_id,
video=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML"
)
elif message_type == "document":
sent = await message.bot.send_document(
recipient_telegram_id,
document=file_id,
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
parse_mode="HTML"
)
sent = await copy_preserving_entities(message, recipient_telegram_id, sender_name)
# Сохраняем в БД
await P2PMessageService.send_message(
@@ -456,4 +433,14 @@ async def handle_p2p_message(message: Message, state: FSMContext):
await message.answer("✅ Сообщение доставлено")
except Exception as e:
await message.answer(f"Не удалось доставить сообщение: {e}")
await message.answer(f"Не удалось доставить сообщение: {public_error(e)}")
@router.callback_query(F.data == "p2p:broadcast")
async def open_broadcast(callback: CallbackQuery, state: FSMContext):
if not await is_admin(callback.from_user.id):
await callback.answer("Недостаточно прав", show_alert=True)
return
from src.handlers.admin_panel import admin_broadcast_menu
await admin_broadcast_menu(callback, state)

View File

@@ -1,4 +1,5 @@
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
from src.utils.errors import public_error
from aiogram import Router, F
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command
@@ -70,7 +71,10 @@ async def check_unclaimed_winners(message: Message):
for winner in winners:
if not winner.is_claimed and winner.is_notified:
# Проверяем, прошло ли 24 часа
time_passed = now - winner.created_at
created = winner.created_at
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
time_passed = now - created
if time_passed.total_seconds() > 24 * 3600: # 24 часа
unclaimed.append({
'winner': winner,
@@ -116,195 +120,25 @@ async def check_unclaimed_winners(message: Message):
await message.answer(text, parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
await message.answer(f"❌ Ошибка: {public_error(e)}")
@router.message(CaseInsensitiveCommand("redraw"))
@admin_only
async def redraw_lottery(message: Message):
"""
Переиграть розыгрыш для неподтвержденных выигрышей (регистронезависимо)
Формат: /redraw <lottery_id>
"""
from src.core.redraw_services import redraw_unclaimed
from src.utils.notifications import notify_winners_async
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /redraw <lottery_id>"
)
if len(parts) != 2 or not parts[1].isdigit():
await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
return
lottery_id = int(parts[1])
async with async_session_maker() as session:
winners = await redraw_unclaimed(session, lottery_id)
if winners:
await notify_winners_async(message.bot, session, lottery_id)
await message.answer(f"Повторно разыграно призов: {len(winners)}. Учитываются уведомлённые победители, не подтвердившие выигрыш за 24 часа.")
try:
lottery_id = int(parts[1])
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
try:
async with async_session_maker() as session:
from sqlalchemy.orm import selectinload
from src.core.models import Lottery
# Загружаем розыгрыш с участниками
lottery_result = await session.execute(
select(Lottery)
.options(selectinload(Lottery.participations))
.where(Lottery.id == lottery_id)
)
lottery = lottery_result.scalar_one_or_none()
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
# Находим неподтвержденные выигрыши старше 24 часов
now = datetime.now(timezone.utc)
unclaimed_winners = []
for winner in winners:
if not winner.is_claimed and winner.is_notified:
time_passed = now - winner.created_at
if time_passed.total_seconds() > 24 * 3600:
unclaimed_winners.append(winner)
if not unclaimed_winners:
await message.answer(
"✅ Нет неподтвержденных выигрышей старше 24 часов.\n"
"Повторный розыгрыш не требуется."
)
return
# Получаем всех участников, исключая текущих победителей
all_participants = []
current_winner_accounts = set()
for winner in winners:
if winner.account_number:
current_winner_accounts.add(winner.account_number)
for p in lottery.participations:
if p.account_number and p.account_number not in current_winner_accounts:
all_participants.append(p)
if not all_participants:
await message.answer(
"❌ Нет доступных участников для повторного розыгрыша.\n"
"Все участники уже являются победителями."
)
return
# Переигрываем каждое неподтвержденное место
redraw_results = []
for old_winner in unclaimed_winners:
if not all_participants:
break
# Выбираем нового победителя
new_participant = random.choice(all_participants)
all_participants.remove(new_participant)
# Удаляем старого победителя
await session.delete(old_winner)
# Создаем нового победителя
new_winner = Winner(
lottery_id=lottery_id,
user_id=None,
account_number=new_participant.account_number,
account_id=new_participant.account_id,
place=old_winner.place,
prize=old_winner.prize,
is_manual=False,
is_notified=False,
is_claimed=False
)
session.add(new_winner)
redraw_results.append({
'place': old_winner.place,
'prize': old_winner.prize,
'old_account': old_winner.account_number,
'new_account': new_participant.account_number
})
await session.commit()
# Отправляем уведомления новым победителям
for result in redraw_results:
# Находим нового победителя
new_winner_result = await session.execute(
select(Winner).where(
and_(
Winner.lottery_id == lottery_id,
Winner.place == result['place'],
Winner.account_number == result['new_account']
)
)
)
new_winner = new_winner_result.scalar_one_or_none()
if new_winner:
# Отправляем уведомление новому победителю
owner = await AccountService.get_account_owner(session, new_winner.account_number)
if owner and owner.telegram_id:
# Создаем токен верификации
await WinnerNotificationService.create_verification_token(
session,
new_winner.id
)
# Формируем сообщение
notification_message = (
f"🎉 Поздравляем! Ваш счет выиграл!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {new_winner.place}\n"
f"🎁 Приз: {new_winner.prize}\n"
f"💳 Счет: {new_winner.account_number}\n\n"
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить получение приза",
callback_data=f"confirm_win_{new_winner.id}"
)]
])
try:
await message.bot.send_message(
owner.telegram_id,
notification_message,
reply_markup=keyboard,
parse_mode="Markdown"
)
new_winner.is_notified = True
await session.commit()
except:
pass
# Формируем отчет для админа
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n"
text += f"📊 Переиграно мест: {len(redraw_results)}\n\n"
for result in redraw_results:
text += f"🏆 {result['place']} место - {result['prize']}\n"
text += f" ❌ Было: {result['old_account']}\n"
text += f" ✅ Стало: {result['new_account']}\n\n"
text += "📨 Новым победителям отправлены уведомления"
await message.answer(text, parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.callback_query(F.data.startswith("confirm_win_"))
@@ -341,10 +175,16 @@ async def confirm_winner_callback(callback_query):
show_alert=True
)
return
else:
owner = await session.get(User, winner.user_id) if winner.user_id else None
if not owner or owner.telegram_id != callback_query.from_user.id:
await callback_query.answer("❌ Это приз другого пользователя", show_alert=True)
return
# Проверяем срок действия (24 часа с момента создания winner)
if winner.created_at:
time_since_creation = datetime.now(timezone.utc) - winner.created_at
created_at = winner.created_at.replace(tzinfo=timezone.utc) if winner.created_at.tzinfo is None else winner.created_at
time_since_creation = datetime.now(timezone.utc) - created_at
if time_since_creation > timedelta(hours=24):
await callback_query.answer(
"❌ Срок подтверждения истёк (24 часа). Приз будет разыгран заново.",
@@ -353,8 +193,17 @@ async def confirm_winner_callback(callback_query):
return
# Подтверждаем выигрыш
winner.is_claimed = True
winner.claimed_at = datetime.now(timezone.utc)
from src.core.models import Lottery
if not await session.scalar(select(Lottery.is_completed).where(Lottery.id == winner.lottery_id)):
await callback_query.answer("Розыгрыш ещё не завершён", show_alert=True)
return
from sqlalchemy import update
changed = await session.execute(update(Winner).where(Winner.id == winner_id, Winner.is_claimed.is_(False))
.values(is_claimed=True, claimed_at=datetime.now(timezone.utc)))
if changed.rowcount != 1:
await session.rollback()
await callback_query.answer("Приз уже подтвержден", show_alert=True)
return
await session.commit()
# Получаем данные о розыгрыше и пользователе
@@ -419,7 +268,6 @@ async def confirm_winner_callback(callback_query):
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {public_error(e)}")
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)

View File

@@ -1,4 +1,5 @@
"""Обработчики для регистрации пользователей"""
from src.utils.errors import public_error
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command, StateFilter
@@ -19,6 +20,22 @@ logger = logging.getLogger(__name__)
router = Router()
async def begin_registration(message: Message, state: FSMContext, actor=None):
actor = actor or message.from_user
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, actor.id, actor.username, actor.first_name, actor.last_name)
if user.is_registered:
await message.answer("Вы уже зарегистрированы. /my_accounts — ваши счета.")
return
await state.clear()
await state.set_state(RegistrationStates.waiting_for_nickname)
await message.answer(
"📝 Регистрация\n\nШаг 1 из 3: введите никнейм для чата (от 2 до 20 символов).",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]]),
)
# Служебные слова, которые нельзя использовать как никнейм
FORBIDDEN_NICKNAMES = [
'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро',
@@ -56,6 +73,8 @@ def validate_nickname(nickname: str) -> tuple[bool, str]:
if nickname.startswith('/'):
return False, "❌ Никнейм не может начинаться с '/'"
if not all(char.isalnum() or char in " _-" for char in nickname):
return False, "❌ Используйте буквы, цифры, пробел, дефис или подчёркивание"
return True, ""
@@ -68,29 +87,12 @@ class RegistrationStates(StatesGroup):
@router.callback_query(F.data == "start_registration")
async def start_registration(callback: CallbackQuery, state: FSMContext):
"""Начать процесс регистрации"""
logger.info(f"Получен запрос на регистрацию от пользователя {callback.from_user.id}")
text = (
"📝 Регистрация в системе\n\n"
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
"Шаг 1 из 3: Придумайте никнейм\n\n"
"🎭 Введите ваш никнейм для чата:\n"
"• От 2 до 20 символов\n"
"• Может содержать буквы, цифры, пробелы\n"
"• Это имя будут видеть другие участники"
)
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
])
)
await state.set_state(RegistrationStates.waiting_for_nickname)
await callback.answer()
await begin_registration(callback.message, state, actor=callback.from_user)
@router.message(StateFilter(RegistrationStates.waiting_for_nickname))
@router.message(StateFilter(RegistrationStates.waiting_for_nickname), F.text)
async def process_nickname(message: Message, state: FSMContext):
"""Обработка никнейма"""
nickname = message.text.strip()
@@ -116,7 +118,7 @@ async def process_nickname(message: Message, state: FSMContext):
await state.set_state(RegistrationStates.waiting_for_club_card)
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text)
async def process_club_card(message: Message, state: FSMContext):
"""Обработка номера клубной карты"""
club_card_number = message.text.strip()
@@ -143,7 +145,7 @@ async def process_club_card(message: Message, state: FSMContext):
await state.set_state(RegistrationStates.waiting_for_phone)
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text)
async def process_phone(message: Message, state: FSMContext):
"""Обработка номера телефона"""
phone_input = message.text.strip()
@@ -196,7 +198,7 @@ async def process_phone(message: Message, state: FSMContext):
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
await state.clear()
except Exception as e:
await message.answer(f"❌ Произошла ошибка: {str(e)}")
await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
await state.clear()

View File

@@ -20,7 +20,7 @@ async def cmd_test_start(message: Message):
"""Тестовая команда /test_start (регистронезависимо)"""
user_id = message.from_user.id
first_name = message.from_user.first_name
is_admin_user = is_admin(user_id)
is_admin_user = await is_admin(user_id)
welcome_text = f"👋 Привет, {first_name}!\n\n"
welcome_text += "🎯 Это тестовая версия команды /start\n\n"
@@ -51,7 +51,7 @@ async def cmd_test_start(message: Message):
@test_router.message(CaseInsensitiveCommand("test_admin"))
async def cmd_test_admin(message: Message):
"""Тестовая команда /test_admin (регистронезависимо)"""
if not is_admin(message.from_user.id):
if not await is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды")
return
@@ -88,7 +88,7 @@ async def back_to_main_handler(callback: CallbackQuery):
await callback.answer()
user_id = callback.from_user.id
is_admin_user = is_admin(user_id)
is_admin_user = await is_admin(user_id)
text = f"🏠 Главное меню\n\nВаш ID: {user_id}\n"
text += f"Статус: {'👑 Администратор' if is_admin_user else '👤 Пользователь'}"
@@ -107,4 +107,4 @@ async def back_to_main_handler(callback: CallbackQuery):
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
)
)