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

@@ -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():