Separate staff roles and protect message delivery with operator guides
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-13 21:00:53 +09:00
parent d5a9c13b68
commit 3c1a6a02a5
25 changed files with 916 additions and 548 deletions

View File

@@ -107,11 +107,6 @@ class AdminStates(StatesGroup):
user_management_view = State() # Просмотр пользователя
# Управление админами
admin_management_action = State() # Выбор действия (добавить/удалить)
admin_add_search = State() # Поиск пользователя для назначения админом
admin_add_confirm = State() # Подтверждение назначения
admin_remove_select = State() # Выбор админа для удаления
admin_remove_confirm = State() # Подтверждение удаления
admin_router = Router()
@@ -136,6 +131,7 @@ def get_admin_main_keyboard() -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"),
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
[InlineKeyboardButton(text="📖 Инструкция администратора", callback_data="guide:admin")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -3571,7 +3567,7 @@ async def show_admin_settings(callback: CallbackQuery):
# Кнопка управления админами - только для главных админов
if is_super_admin(callback.from_user.id):
buttons.append([InlineKeyboardButton(text="👑 Управление админами", callback_data="admin_manage_admins")])
buttons.append([InlineKeyboardButton(text="👥 Сотрудники и права", callback_data="staff:menu")])
buttons.extend([
[InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")],
@@ -3733,27 +3729,11 @@ async def cleanup_inactive_users(callback: CallbackQuery):
)
inactive_users = result.scalars().all()
# Проверяем, что у них нет связанных данных
deleted_count = 0
for user in inactive_users:
# Проверяем участия
participations = await session.execute(
select(Participation).where(Participation.user_id == user.id)
)
if participations.scalars().first():
continue
# Проверяем счета
accounts = await session.execute(
select(Account).where(Account.user_id == user.id)
)
if accounts.scalars().first():
continue
# Безопасно удаляем
await session.delete(user)
deleted_count += 1
if await UserService.delete_user(session, user.id):
deleted_count += 1
await session.commit()
await callback.message.edit_text(
@@ -4660,7 +4640,11 @@ async def broadcast_select_channel(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.broadcast_message)
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document)
BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть сообщений могла быть доставлена. Проверьте журнал рассылок перед повторной отправкой. Меню доступно: /admin."
BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker)
async def admin_broadcast_send(message: Message, state: FSMContext):
"""Обработка и отправка рассылки"""
if not await check_admin_access(message.from_user.id):
@@ -4669,21 +4653,40 @@ async def admin_broadcast_send(message: Message, state: FSMContext):
data = await state.get_data()
broadcast_type = data.get('broadcast_type', 'direct')
if broadcast_type == 'direct':
# Рассылка в ЛС
await _broadcast_direct(message, state)
else:
# Рассылка в канал/группу
await _broadcast_channel(message, state, data)
from src.utils.broadcast_jobs import start_broadcast
await state.clear()
async def run():
if broadcast_type == 'direct':
await _broadcast_direct(message)
else:
await _broadcast_channel(message, data)
async def report_error():
await message.answer(BROADCAST_FAILURE_TEXT)
if not start_broadcast(message.from_user.id, run, report_error):
await message.answer(BROADCAST_BUSY_TEXT)
async def _broadcast_direct(message: Message, state: FSMContext):
async def _edit_broadcast_status(status_msg, text, **kwargs):
import asyncio
import logging
try:
async with asyncio.timeout(15):
await status_msg.edit_text(text, **kwargs)
except Exception as error:
# A missing status message must not turn a completed delivery into a failed job.
logging.getLogger(__name__).warning("Cannot update broadcast report: %s", type(error).__name__)
async def _broadcast_direct(message: Message):
"""Рассылка в личные сообщения"""
# Отправляем уведомление о начале рассылки
status_msg = await message.answer(
"📤 <b>Начинаю рассылку в ЛС...</b>\n\n"
"⏳ Подождите, это может занять некоторое время.\n"
"💡 Используется Redis очередь и отслеживание заблокированных пользователей.",
"Меню доступно во время отправки: /admin.\n"
"Недоступные получатели будут пропущены; результат появится здесь.",
parse_mode="HTML"
)
@@ -4703,12 +4706,11 @@ async def _broadcast_direct(message: Message, state: FSMContext):
# Проверяем, есть ли пользователи для рассылки
if not registered_users:
await status_msg.edit_text(
await _edit_broadcast_status(status_msg,
"⚠️ <b>Нет зарегистрированных пользователей</b>\n\n"
"Рассылка невозможна, так как нет ни одного зарегистрированного пользователя.",
parse_mode="HTML"
)
await state.clear()
return
# Используем новый сервис рассылок
@@ -4723,7 +4725,7 @@ async def _broadcast_direct(message: Message, state: FSMContext):
delivery_percent = (stats['success'] / stats['total'] * 100) if stats['total'] > 0 else 0
# Итоговый отчет
await status_msg.edit_text(
await _edit_broadcast_status(status_msg,
f"✅ <b>Рассылка завершена!</b>\n\n"
f"📊 <b>Статистика:</b>\n"
f"👥 Всего получателей: {stats['total']}\n"
@@ -4734,16 +4736,14 @@ async def _broadcast_direct(message: Message, state: FSMContext):
parse_mode="HTML"
)
await state.clear()
async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
async def _broadcast_channel(message: Message, data: dict):
"""Рассылка в канал или группу"""
channel_db_id = data.get('channel_db_id')
if not channel_db_id:
await message.answer("❌ Ошибка: не выбран канал")
await state.clear()
return
# Получаем информацию о канале и администратора
@@ -4764,7 +4764,6 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
if not channel:
await message.answer("❌ Ошибка: канал не найден")
await state.clear()
return
# Отправляем уведомление
@@ -4783,20 +4782,19 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
)
if success:
await status_msg.edit_text(
await _edit_broadcast_status(status_msg,
f"✅ <b>Сообщение отправлено!</b>\n\n"
f"📱 {'Канал' if channel.chat_type == 'channel' else 'Группа'}: {channel.title}",
parse_mode="HTML"
)
else:
await status_msg.edit_text(
await _edit_broadcast_status(status_msg,
f"❌ <b>Ошибка отправки</b>\n\n"
f"Не удалось отправить сообщение в {'канал' if channel.chat_type == 'channel' else 'группу'} {channel.title}\n"
f"Проверьте права бота и попробуйте снова.",
parse_mode="HTML"
)
await state.clear()
# ============================================================================
@@ -5494,255 +5492,4 @@ async def admin_user_unban(callback: CallbackQuery, state: FSMContext):
# УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ
# =========================
@admin_router.callback_query(F.data == "admin_manage_admins")
async def manage_admins_menu(callback: CallbackQuery):
"""Главное меню управления администраторами"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Только главные администраторы могут управлять правами", show_alert=True)
return
await callback.answer()
text = "👑 <b>Управление администраторами</b>\n\n"
text += f"Главные администраторы (.env): <code>{len(ADMIN_IDS)}</code>\n\n"
text += "Выберите действие:"
buttons = [
[InlineKeyboardButton(text=" Назначить админа", callback_data="admin_add_admin")],
[InlineKeyboardButton(text=" Удалить админа", callback_data="admin_remove_admin")],
[InlineKeyboardButton(text="📋 Список админов", callback_data="admin_list_admins_view")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_settings")]
]
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@admin_router.callback_query(F.data == "admin_list_admins_view")
async def list_admins_view(callback: CallbackQuery):
"""Показать список всех администраторов"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
await callback.answer()
async with async_session_maker() as session:
from sqlalchemy import select
# Получаем всех администраторов (назначенных через БД)
result = await session.execute(
select(User).where(User.is_admin == True).order_by(User.created_at.desc())
)
db_admins = result.scalars().all()
text = "👑 <b>Список администраторов</b>\n\n"
# Главные администраторы из .env
text += "<b>Главные администраторы (из .env):</b>\n"
for admin_id in ADMIN_IDS:
text += f"🔴 ID: <code>{admin_id}</code>\n"
text += "\n"
# Назначенные администраторы
if db_admins:
text += "<b>Назначенные администраторы:</b>\n"
for admin in db_admins:
icon = "🟠" # Назначенный админ
name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
text += f"{icon} {name} (ID: <code>{admin.telegram_id}</code>)\n"
else:
text += "<b>Назначенные администраторы:</b> нет\n"
buttons = [
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_manage_admins")]
]
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
@admin_router.callback_query(F.data == "admin_add_admin")
async def add_admin_start(callback: CallbackQuery, state: FSMContext):
"""Начать добавление нового администратора"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
await callback.answer()
text = "👤 <b>Назначение администратора</b>\n\n"
text += "Введите Telegram ID пользователя или его имя для поиска:"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")]
]),
parse_mode="HTML"
)
await state.set_state(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):
await message.answer("❌ Доступ запрещен")
return
search_query = message.text.strip()
async with async_session_maker() as session:
user = None
# Пробуем найти по ID
try:
telegram_id = int(search_query)
user = await UserService.get_user_by_telegram_id(session, telegram_id)
except ValueError:
# Если не число, ищем по имени или username
users = await UserService.search_users(session, search_query, limit=5)
if users:
user = users[0]
if not user:
await message.answer("❌ Пользователь не найден")
await state.set_state(AdminStates.admin_add_search)
return
# Проверяем, не главный ли админ из .env
if user.telegram_id in ADMIN_IDS:
await message.answer("❌ Это главный администратор (.env). Уже имеет максимальные права")
await state.set_state(AdminStates.admin_add_search)
return
# Проверяем, не админ ли уже
if user.is_admin:
await message.answer("❌ Этот пользователь уже администратор")
await state.set_state(AdminStates.admin_add_search)
return
# Сохраняем в state и просим подтверждение
await state.update_data(admin_user_id=user.id, admin_telegram_id=user.telegram_id)
text = "👤 <b>Подтверждение назначения администратора</b>\n\n"
text += f"Имя: {user.first_name or 'не указано'}\n"
text += f"Username: {user.username or 'нет'}\n"
text += f"Telegram ID: <code>{user.telegram_id}</code>\n"
text += f"Зарегистрирован: {user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else 'нет'}\n\n"
text += "Вы уверены, что хотите дать этому пользователю права администратора?"
await message.answer(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Да, назначить", callback_data="admin_add_confirm_yes"),
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")],
]),
parse_mode="HTML"
)
await state.set_state(AdminStates.admin_add_confirm)
@admin_router.callback_query(F.data == "admin_add_confirm_yes")
async def confirm_add_admin(callback: CallbackQuery, state: FSMContext):
"""Подтвердить назначение админа"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
data = await state.get_data()
admin_telegram_id = data.get('admin_telegram_id')
async with async_session_maker() as session:
success = await UserService.set_admin(session, admin_telegram_id, is_admin=True)
if success:
await callback.answer("✅ Администратор успешно назначен", show_alert=True)
await state.clear()
await manage_admins_menu(callback)
else:
await callback.answer("❌ Ошибка при назначении администратора", show_alert=True)
@admin_router.callback_query(F.data == "admin_remove_admin")
async def remove_admin_start(callback: CallbackQuery, state: FSMContext):
"""Начать удаление администратора"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
await callback.answer()
async with async_session_maker() as session:
from sqlalchemy import select
# Получаем всех назначенных администраторов
result = await session.execute(
select(User).where(User.is_admin == True).order_by(User.created_at.desc())
)
admins = result.scalars().all()
if not admins:
await callback.answer("❌ Нет назначенных администраторов", show_alert=True)
return
text = "🗑️ <b>Выберите администратора для удаления</b>\n\n"
buttons = []
for admin in admins[:20]: # Максимум 20 администраторов на странице
name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
buttons.append([InlineKeyboardButton(
text=f"🟠 {name}",
callback_data=f"admin_remove_select:{admin.telegram_id}"
)])
buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
await state.set_state(AdminStates.admin_remove_select)
@admin_router.callback_query(F.data.startswith("admin_remove_select:"))
async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
"""Подтвердить удаление администратора"""
if not is_super_admin(callback.from_user.id):
await callback.answer("❌ Доступ запрещен", show_alert=True)
return
admin_telegram_id = int(callback.data.split(":")[1])
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, admin_telegram_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
# Снять права администратора
success = await UserService.set_admin(session, admin_telegram_id, is_admin=False)
if success:
await callback.answer("✅ Права администратора удалены", show_alert=True)
await state.clear()
await manage_admins_menu(callback)
else:
await callback.answer("❌ Ошибка при удалении прав", show_alert=True)
# Экспорт роутера
__all__ = ['admin_router']
__all__ = ["admin_router"]