Separate staff roles and protect message delivery with operator guides
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
95
src/core/staff_service.py
Normal 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)
|
||||
@@ -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}")
|
||||
|
||||
# Создаем тестовых пользователей
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.")
|
||||
|
||||
43
src/handlers/operator_help.py
Normal file
43
src/handlers/operator_help.py
Normal 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])
|
||||
198
src/handlers/staff_handlers.py
Normal file
198
src/handlers/staff_handlers.py
Normal 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)
|
||||
48
src/utils/broadcast_jobs.py
Normal file
48
src/utils/broadcast_jobs.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user