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:
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