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

@@ -6,7 +6,7 @@ import json
import logging
from typing import Optional, List, Dict, Tuple, Any
from datetime import datetime, timezone
from src.utils.delivery import background_delivery
from src.utils.delivery import background_delivery, MAX_RETRY_AFTER
from aiogram import Bot
from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
@@ -94,7 +94,6 @@ class BroadcastService:
# Лимиты Telegram
BATCH_SIZE = 30 # Сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
def __init__(self):
self.redis_queue = RedisQueue()
@@ -230,10 +229,10 @@ class BroadcastService:
except TelegramRetryAfter as e:
# FloodWait - слишком много запросов
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
if _retry:
logger.warning("Telegram rate limit: retry_after=%s", e.retry_after)
if _retry or e.retry_after > MAX_RETRY_AFTER:
return False, "rate_limited"
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
await asyncio.sleep(e.retry_after)
# Повторная попытка
return await self.send_message_to_user.__wrapped__(self, bot, user, message, _retry=True)

View File

@@ -80,10 +80,15 @@ class UserService:
@staticmethod
async def delete_user(session: AsyncSession, user_id: int) -> bool:
"""Удалить пользователя и все связанные данные"""
user = await session.get(User, user_id)
user = await session.scalar(select(User).where(User.id == user_id).with_for_update()
.execution_options(populate_existing=True))
if not user:
return False
from .config import ADMIN_IDS, CASHIER_IDS
if user.is_admin or user.is_cashier or user.telegram_id in set(ADMIN_IDS) | set(CASHIER_IDS):
return False
# Keep records needed by draws, claims, chat history, or staff audit trails.
# Cleanup may delete only users without linked data; moderation uses bans.
from .database import Base
@@ -107,17 +112,6 @@ class UserService:
await session.commit()
return True
@staticmethod
async def set_admin(session: AsyncSession, telegram_id: int, is_admin: bool = True) -> bool:
"""Установить/снять права администратора"""
result = await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(is_admin=is_admin)
)
await session.commit()
return result.rowcount > 0
@staticmethod
async def set_account_number(session, telegram_id, account_number):
from sqlalchemy.exc import IntegrityError

95
src/core/staff_service.py Normal file
View File

@@ -0,0 +1,95 @@
"""System-admin-only staff changes with optimistic concurrency protection."""
from dataclasses import dataclass
import logging
from sqlalchemy import func, select, update
from .config import ADMIN_IDS, CASHIER_IDS
from .database import async_session_maker
from .models import User
logger = logging.getLogger(__name__)
ROLE_NAMES = {"user": "Пользователь", "cashier": "Кассир", "admin": "Администратор",
"super_admin": "Системный администратор"}
class StaffError(ValueError):
"""An operator-facing error; no database details."""
def require_system_admin(actor_id):
if actor_id not in ADMIN_IDS:
raise StaffError("Назначать сотрудников может только системный администратор.")
def effective_role(telegram_id, admin=False, cashier=False):
if telegram_id in ADMIN_IDS:
return "super_admin"
if admin:
return "admin"
if cashier or telegram_id in CASHIER_IDS:
return "cashier"
return "user"
@dataclass(frozen=True)
class StaffMember:
telegram_id: int
name: str
role: str
flags: tuple[bool, bool]
async def get_member(actor_id, telegram_id):
require_system_admin(actor_id)
async with async_session_maker() as session:
user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
if user is None:
raise StaffError("Пользователь не найден. Сначала он должен отправить боту /start.")
flags = (bool(user.is_admin), bool(user.is_cashier))
return StaffMember(telegram_id, user.nickname or user.first_name or user.username or "Без имени",
effective_role(telegram_id, *flags), flags)
def check_editable(actor_id, telegram_id):
require_system_admin(actor_id)
if telegram_id in ADMIN_IDS:
raise StaffError("Права системного администратора нельзя изменить через бот.")
if telegram_id in CASHIER_IDS:
raise StaffError("Роль этого сотрудника закреплена в настройках сервера. Обратитесь к ответственному за сервер.")
async def change_role(actor_id, telegram_id, role, expected_flags):
check_editable(actor_id, telegram_id)
if role not in {"user", "admin", "cashier"} or len(expected_flags) != 2:
raise StaffError("Недопустимая роль.")
# Compare and change in one statement: a second operator cannot overwrite
# a role changed since the confirmation screen was opened.
async with async_session_maker() as session:
result = await session.execute(update(User).where(
User.telegram_id == telegram_id,
func.coalesce(User.is_admin, False) == bool(expected_flags[0]),
User.is_cashier == bool(expected_flags[1]),
).values(is_admin=role == "admin", is_cashier=role == "cashier"))
if result.rowcount != 1:
await session.rollback()
raise StaffError("Права уже изменены другим администратором или пользователь удалён. Откройте назначение заново.")
await session.commit()
logger.warning("Staff role changed: actor=%s target=%s previous_flags=%s role=%s",
actor_id, telegram_id, tuple(expected_flags), role)
async def list_members(actor_id, offset=0, limit=20):
require_system_admin(actor_id)
configured = set(ADMIN_IDS) | set(CASHIER_IDS)
async with async_session_maker() as session:
rows = (await session.scalars(select(User).where(
User.is_admin.is_(True) | User.is_cashier.is_(True) | User.telegram_id.in_(configured)
).order_by(User.telegram_id))).all()
members = {u.telegram_id: StaffMember(u.telegram_id, u.nickname or u.first_name or u.username or "Без имени",
effective_role(u.telegram_id, u.is_admin, u.is_cashier), (bool(u.is_admin), bool(u.is_cashier))) for u in rows}
for telegram_id in configured - members.keys():
members[telegram_id] = StaffMember(telegram_id, "Ещё не запускал бот",
effective_role(telegram_id), (False, False))
ordered = sorted(members.values(), key=lambda member: member.telegram_id)
return ordered[offset:offset + limit], len(ordered)

View File

@@ -23,7 +23,7 @@ async def demo_admin_features():
first_name="Администратор",
last_name="Системы"
)
await UserService.set_admin(session, 123456789, True)
# Staff roles are assigned by a system administrator through /staff.
print(f"✅ Создан администратор: {admin.first_name}")
# Создаем тестовых пользователей

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

View File

@@ -4,12 +4,9 @@ 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
@@ -32,6 +29,7 @@ async def cashier_menu(message: Message, state: FSMContext):
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]
buttons.append([InlineKeyboardButton(text="📖 Инструкция кассира", callback_data="guide:cashier")])
await message.answer(
"💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n"
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
@@ -72,29 +70,3 @@ async def add_accounts(message: Message, state: FSMContext):
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

@@ -0,0 +1,43 @@
"""Role-specific operator manuals, included in every release."""
from pathlib import Path
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import CallbackQuery, FSInputFile, Message
from src.core.access import get_role
operator_help_router = Router(name="operator_help")
GUIDES = {
"admin": ("ADMIN_GUIDE.md", "Инструкция администратора", {"admin", "super_admin"}),
"cashier": ("CASHIER_GUIDE.md", "Инструкция кассира", {"cashier", "admin", "super_admin"}),
"system": ("SYSTEM_ADMIN_GUIDE.md", "Инструкция системного администратора", {"super_admin"}),
}
DOCS = Path(__file__).resolve().parents[2] / "docs"
@operator_help_router.message(Command("my_id"))
async def own_id(message: Message):
await message.answer(f"Ваш Telegram ID: {message.from_user.id}")
async def send_guide(event, kind):
spec = GUIDES.get(kind)
if spec is None or await get_role(event.from_user.id) not in spec[2]:
await event.answer("Недостаточно прав", **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
return
message = event.message if isinstance(event, CallbackQuery) else event
if isinstance(event, CallbackQuery):
await event.answer()
await message.answer_document(FSInputFile(DOCS / spec[0]), caption=spec[1])
@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
async def help_command(message: Message):
kind = message.text.split()[0].split("@")[0].removeprefix("/").removesuffix("_help")
await send_guide(message, kind)
@operator_help_router.callback_query(F.data.startswith("guide:"))
async def help_callback(callback: CallbackQuery):
await send_guide(callback, callback.data.split(":", 1)[1])

View File

@@ -0,0 +1,198 @@
"""One staff-management dialog, restricted at every step to system admins."""
from html import escape
import secrets
from aiogram import BaseMiddleware, 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 src.core.staff_service import (ROLE_NAMES, StaffError, change_role, check_editable,
get_member, list_members, require_system_admin)
staff_router = Router(name="staff_management")
class SystemAdminMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
try:
require_system_admin(event.from_user.id)
except StaffError as error:
await event.answer(str(error), **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
return
return await handler(event, data)
staff_router.message.middleware(SystemAdminMiddleware())
staff_router.callback_query.middleware(SystemAdminMiddleware())
class StaffStates(StatesGroup):
target = State()
confirm = State()
def menu_keyboard():
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Назначить администратора", callback_data="staff:role:admin")],
[InlineKeyboardButton(text="Назначить кассира", callback_data="staff:role:cashier")],
[InlineKeyboardButton(text="Снять права сотрудника", callback_data="staff:role:user")],
[InlineKeyboardButton(text="Список сотрудников", callback_data="staff:list:0")],
[InlineKeyboardButton(text="Инструкция системного администратора", callback_data="guide:system")],
[InlineKeyboardButton(text="Назад в настройки", callback_data="admin_settings")],
])
async def show_menu(message, state):
await state.clear()
await message.answer(
"👥 <b>Сотрудники и права</b>\n\n"
"Администратор: пользователи, розыгрыши, рассылки и модерация.\n"
"Кассир: счета, добавление участия и подтверждение выдачи призов.\n"
"Назначать и снимать эти роли может только системный администратор.\n\n"
"У сотрудника одна назначенная роль. Смена роли заменяет предыдущие права.",
parse_mode="HTML", reply_markup=menu_keyboard())
@staff_router.message(Command("staff"))
async def staff_menu(message: Message, state: FSMContext):
await show_menu(message, state)
@staff_router.callback_query(F.data.in_({"staff:menu", "admin_manage_admins"}))
async def staff_menu_callback(callback: CallbackQuery, state: FSMContext):
await callback.answer()
await show_menu(callback.message, state)
async def request_confirmation(message, state, actor_id, target_id, role, removal_role=None):
try:
check_editable(actor_id, target_id)
member = await get_member(actor_id, target_id)
if removal_role and member.role != removal_role:
raise StaffError(f"У пользователя другая роль: {ROLE_NAMES[member.role]}. Используйте /staff.")
if member.flags == (role == "admin", role == "cashier"):
raise StaffError("У пользователя уже установлена эта роль.")
except StaffError as error:
await message.answer(str(error))
return
nonce = secrets.token_hex(8)
await state.set_data({"target_id": target_id, "role": role, "expected_flags": list(member.flags), "nonce": nonce})
await state.set_state(StaffStates.confirm)
await message.answer(
f"Сотрудник: {escape(member.name[:100])}\nTelegram ID: <code>{target_id}</code>\n"
f"Текущая роль: {ROLE_NAMES[member.role]}\nНовая роль: <b>{ROLE_NAMES[role]}</b>\n\n"
"Проверьте ID и подтвердите изменение прав.", parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Подтвердить изменение", callback_data=f"staff:confirm:{nonce}")],
[InlineKeyboardButton(text="Отмена", callback_data="staff:menu")],
]))
@staff_router.callback_query(F.data.startswith("staff:role:"))
async def choose_role(callback: CallbackQuery, state: FSMContext):
role = callback.data.rsplit(":", 1)[-1]
if role not in {"user", "admin", "cashier"}:
await callback.answer("Недопустимая роль", show_alert=True)
return
await state.set_data({"role": role})
await state.set_state(StaffStates.target)
await callback.answer()
await callback.message.answer("Отправьте точный Telegram ID сотрудника (число). Он должен сначала выполнить /start. Для отмены — /cancel.")
def parse_target(text):
if text and text.isascii() and text.isdigit() and 0 < int(text) < 2**63:
return int(text)
raise StaffError("Нужен положительный числовой Telegram ID. Имя и @username не подходят.")
@staff_router.message(Command("add_admin", "remove_admin", "add_cashier", "remove_cashier"))
async def staff_command(message: Message, state: FSMContext):
await state.clear()
parts = message.text.split()
command = parts[0].split("@")[0].lower()
try:
target_id = parse_target(parts[1] if len(parts) == 2 else None)
except StaffError as error:
await message.answer(f"{error}\nФормат: {command} TELEGRAM_ID")
return
role = "admin" if command.endswith("admin") else "cashier"
removal = command.startswith("/remove_")
await request_confirmation(message, state, message.from_user.id, target_id,
"user" if removal else role, role if removal else None)
@staff_router.message(StaffStates.target, F.text)
async def staff_target(message: Message, state: FSMContext):
try:
target_id = parse_target(message.text.strip())
except StaffError as error:
await message.answer(str(error))
return
data = await state.get_data()
await request_confirmation(message, state, message.from_user.id, target_id, data["role"])
@staff_router.callback_query(F.data.startswith("staff:confirm:"))
async def confirm_role(callback: CallbackQuery, state: FSMContext):
data = await state.get_data()
if await state.get_state() != StaffStates.confirm.state or callback.data != f"staff:confirm:{data.get('nonce')}":
await callback.answer("Подтверждение устарело. Откройте /staff заново.", show_alert=True)
return
# Consume confirmation before mutation or any Telegram request. A repeated
# click cannot reuse it even if the acknowledgement fails to reach Telegram.
await state.clear()
try:
await change_role(callback.from_user.id, data["target_id"], data["role"], data["expected_flags"])
except StaffError as error:
await callback.answer(str(error), show_alert=True)
return
await callback.answer("Права обновлены")
await callback.message.answer(f"✅ Telegram ID {data['target_id']}: {ROLE_NAMES[data['role']]}.\n"
"Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
@staff_router.message(StaffStates.confirm)
async def confirmation_pending(message: Message):
await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")
async def show_members(message, state, actor_id, page=0):
await state.clear()
members, total = await list_members(actor_id, page * 20)
lines = ["<b>Сотрудники и права</b>"]
for member in members:
lines.append(f"<code>{member.telegram_id}</code> — {escape(member.name[:50])}: {ROLE_NAMES[member.role]}")
buttons = []
if page:
buttons.append(InlineKeyboardButton(text="◀️", callback_data=f"staff:list:{page - 1}"))
if (page + 1) * 20 < total:
buttons.append(InlineKeyboardButton(text="▶️", callback_data=f"staff:list:{page + 1}"))
await message.answer("\n".join(lines), parse_mode="HTML", reply_markup=InlineKeyboardMarkup(
inline_keyboard=([buttons] if buttons else []) + [[InlineKeyboardButton(text="Назад", callback_data="staff:menu")]]))
@staff_router.message(Command("admins", "cashiers"))
async def staff_list_command(message: Message, state: FSMContext):
await show_members(message, state, message.from_user.id)
@staff_router.callback_query(F.data.startswith("staff:list:"))
async def staff_list_callback(callback: CallbackQuery, state: FSMContext):
page = callback.data.rsplit(":", 1)[-1]
if not page.isdigit() or len(page) > 6:
await callback.answer("Недопустимая страница")
return
await callback.answer()
await show_members(callback.message, state, callback.from_user.id, int(page))
# Old keyboards cannot bypass the new identity check and confirmation protocol.
@staff_router.callback_query(F.data.in_({"admin_add_admin", "admin_remove_admin", "admin_list_admins_view",
"admin_add_confirm_yes", "admin_add_confirm_no"}) |
F.data.startswith("admin_remove_select:"))
async def legacy_staff_callback(callback: CallbackQuery, state: FSMContext):
await callback.answer("Меню обновлено. Выберите действие заново.")
await show_menu(callback.message, state)

View File

@@ -0,0 +1,48 @@
"""Supervised bulk sends that do not keep the initiating user's FSM locked."""
import asyncio
import logging
from weakref import WeakKeyDictionary
logger = logging.getLogger(__name__)
_jobs = WeakKeyDictionary()
MAX_JOBS = 2
def jobs():
return _jobs.setdefault(asyncio.get_running_loop(), {})
def start_broadcast(actor_id, run, on_error):
active = jobs()
if actor_id in active or len(active) >= MAX_JOBS:
return False
async def supervise():
try:
await run()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Broadcast job failed for actor=%s", actor_id)
try:
await on_error()
except Exception:
logger.warning("Could not deliver broadcast failure report for actor=%s", actor_id)
finally:
active.pop(actor_id, None)
task = asyncio.create_task(supervise(), name=f"broadcast-{actor_id}")
active[actor_id] = task
# Cancellation can happen before supervise() enters its try/finally.
def remove_finished(completed):
if active.get(actor_id) is completed:
active.pop(actor_id, None)
task.add_done_callback(remove_finished)
return True
async def shutdown_broadcasts():
active = list(jobs().values())
for task in active:
task.cancel()
await asyncio.gather(*active, return_exceptions=True)

View File

@@ -7,6 +7,8 @@ from weakref import WeakKeyDictionary
from aiogram.exceptions import TelegramRetryAfter
_limiters = WeakKeyDictionary()
DELIVERY_TIMEOUT = 30.0
MAX_RETRY_AFTER = 5.0
class DeliveryLimiter:
@@ -40,7 +42,8 @@ def background_delivery(func):
@wraps(func)
async def wrapped(*args, **kwargs):
async with limiter():
return await func(*args, **kwargs)
async with asyncio.timeout(DELIVERY_TIMEOUT):
return await func(*args, **kwargs)
return wrapped
@@ -48,8 +51,8 @@ def background_delivery(func):
async def send_background(bot, **kwargs):
for attempt in range(2):
try:
return await bot.send_message(**kwargs)
return await bot.send_message(**{"request_timeout": 15, **kwargs})
except TelegramRetryAfter as error:
if attempt:
if attempt or error.retry_after > MAX_RETRY_AFTER:
raise
await asyncio.sleep(error.retry_after)

View File

@@ -1,12 +1,26 @@
"""Copy user messages without losing Telegram entities, including custom emoji."""
import asyncio
from functools import wraps
from aiogram.types import Message, MessageEntity
COPY_TIMEOUT = 15.0
def bounded_copy(func):
@wraps(func)
async def wrapped(*args, **kwargs):
async with asyncio.timeout(COPY_TIMEOUT):
return await func(*args, **kwargs)
return wrapped
def utf16_length(text: str) -> int:
"""Telegram entity offsets count UTF-16 code units, not Python characters."""
return len(text.encode("utf-16-le")) // 2
@bounded_copy
async def copy_preserving_entities(message: Message, recipient_id: int, sender_name: str | None = None):
if sender_name is None:
# Native copy keeps the original body/caption and its entities unchanged.

View File

@@ -11,18 +11,9 @@ from ..core.config import ADMIN_IDS
async def setup_admin_users():
"""Установить права администратора для пользователей из ADMIN_IDS"""
if not ADMIN_IDS:
print("❌ Список ADMIN_IDS пуст")
return
async with async_session_maker() as session:
for admin_id in ADMIN_IDS:
success = await UserService.set_admin(session, admin_id, True)
if success:
print(f"✅ Права администратора установлены для ID: {admin_id}")
else:
print(f"⚠️ Пользователь с ID {admin_id} не найден в базе")
"""Configured system admins already have access; never persist it as a second role."""
print(f"System administrators are loaded directly from ADMIN_IDS ({len(ADMIN_IDS)} configured).")
print("Use /staff in the bot to appoint ordinary administrators and cashiers.")
async def create_sample_lottery():