Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-09-13 19:48:41 +09:00
parent 733298bf06
commit cb35bb12e3
86 changed files with 2993 additions and 2455 deletions

View File

@@ -3,7 +3,7 @@
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, update, func
from ..core.models import User, Lottery, Participation, Winner
from ..core.models import User, Lottery, Participation, Winner, WinnerVerification
from typing import List, Dict, Optional
import csv
import json
@@ -219,6 +219,7 @@ class AdminUtils:
"""Удалить розыгрыш (со всеми связанными данными)"""
try:
# Удаляем победителей
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_(select(Winner.id).where(Winner.lottery_id == lottery_id))))
await session.execute(
delete(Winner).where(Winner.lottery_id == lottery_id)
)
@@ -320,6 +321,7 @@ class AdminUtils:
for lottery_id in lottery_ids:
# Удаляем победителей
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_(select(Winner.id).where(Winner.lottery_id == lottery_id))))
result = await session.execute(
delete(Winner).where(Winner.lottery_id == lottery_id)
)
@@ -420,4 +422,4 @@ class ReportGenerator:
report += f"{i}. {name}\n"
report += f" Участий: {participations} | Побед: {wins}\n\n"
return report
return report

55
src/utils/delivery.py Normal file
View File

@@ -0,0 +1,55 @@
"""Bound background Telegram delivery so interactive replies retain capacity."""
import asyncio
from functools import wraps
import time
from weakref import WeakKeyDictionary
from aiogram.exceptions import TelegramRetryAfter
_limiters = WeakKeyDictionary()
class DeliveryLimiter:
def __init__(self):
self.slots = asyncio.Semaphore(8)
self.pace = asyncio.Lock()
self.next_send = 0.0
async def __aenter__(self):
await self.slots.acquire()
try:
async with self.pace:
await asyncio.sleep(max(0, self.next_send - time.monotonic()))
self.next_send = time.monotonic() + 0.05
except BaseException:
self.slots.release()
raise
async def __aexit__(self, *_):
self.slots.release()
def limiter():
loop = asyncio.get_running_loop()
if loop not in _limiters:
_limiters[loop] = DeliveryLimiter()
return _limiters[loop]
def background_delivery(func):
@wraps(func)
async def wrapped(*args, **kwargs):
async with limiter():
return await func(*args, **kwargs)
return wrapped
@background_delivery
async def send_background(bot, **kwargs):
for attempt in range(2):
try:
return await bot.send_message(**kwargs)
except TelegramRetryAfter as error:
if attempt:
raise
await asyncio.sleep(error.retry_after)

7
src/utils/errors.py Normal file
View File

@@ -0,0 +1,7 @@
"""Keep unexpected database and transport details out of Telegram responses."""
import logging
def public_error(error):
logging.getLogger(__name__).error("Operation failed", exc_info=(type(error), error, error.__traceback__))
return "Не удалось выполнить действие. Повторите попытку позже."

View File

@@ -16,123 +16,39 @@ logger = logging.getLogger(__name__)
async def notify_winners_async(bot: Bot, session: AsyncSession, lottery_id: int):
"""
Асинхронно отправить уведомления победителям с кнопкой подтверждения.
Вызывается после проведения розыгрыша.
Args:
bot: Экземпляр бота для отправки сообщений
session: Сессия БД
lottery_id: ID розыгрыша
"""
# Получаем информацию о розыгрыше
from src.core.database import async_session_maker
from src.utils.delivery import send_background
from sqlalchemy import update
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
logger.error(f"Розыгрыш {lottery_id} не найден")
return
# Получаем всех победителей из БД
winners_result = await session.execute(
select(Winner).where(Winner.lottery_id == lottery_id)
)
winners = winners_result.scalars().all()
logger.info(f"Найдено {len(winners)} победителей для розыгрыша {lottery_id}")
winners = await LotteryService.get_winners(session, lottery_id)
deliveries = []
for winner in winners:
try:
# Если у победителя есть account_number, ищем владельца
if winner.account_number:
owner = await AccountService.get_account_owner(session, winner.account_number)
if owner and owner.telegram_id:
# Создаем токен верификации
verification = await WinnerNotificationService.create_verification_token(
session,
winner.id
)
# Формируем сообщение с кнопкой подтверждения
message = (
f"🎉 **Поздравляем! Ваш счет выиграл!**\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n"
f"💳 **Выигрышный счет: {winner.account_number}**\n\n"
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
f"Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.\n"
f"Если вы не подтвердите в течение 24 часов, "
f"приз будет разыгран заново.\n\n"
f" Если у вас несколько выигрышных счетов, "
f"подтвердите каждый из них отдельно."
)
# Создаем кнопку подтверждения с указанием счета
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"✅ Подтвердить счет {winner.account_number}",
callback_data=f"confirm_win_{winner.id}"
)],
[InlineKeyboardButton(
text="📞 Связаться с администратором",
url=f"tg://user?id={ADMIN_IDS[0]}"
)]
])
# Отправляем уведомление с кнопкой
await bot.send_message(
owner.telegram_id,
message,
reply_markup=keyboard,
parse_mode="Markdown"
)
# Отмечаем, что уведомление отправлено
winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}")
else:
logger.warning(f"⚠️ Владелец счета {winner.account_number} не найден или нет telegram_id")
# Если победитель - обычный пользователь (старая система)
elif winner.user_id:
user_result = await session.execute(
select(User).where(User.id == winner.user_id)
)
user = user_result.scalar_one_or_none()
if user and user.telegram_id:
message = (
f"🎉 Поздравляем! Вы выиграли!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n\n"
f"Свяжитесь с администратором для получения приза."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="📞 Связаться с администратором",
url=f"tg://user?id={ADMIN_IDS[0]}"
)]
])
await bot.send_message(
user.telegram_id,
message,
reply_markup=keyboard
)
winner.is_notified = True
await session.commit()
logger.info(f"✅ Отправлено уведомление победителю {user.telegram_id} (user_id={user.id})")
else:
logger.warning(f"⚠️ Пользователь {winner.user_id} не найден или нет telegram_id")
except Exception as e:
logger.error(f"❌ Ошибка при отправке уведомления победителю {winner.id}: {e}")
if winner.is_notified:
continue
logger.info(f"Завершена отправка уведомлений для розыгрыша {lottery_id}")
owner = winner.user
if not owner and winner.account_number:
owner = await AccountService.get_account_owner(session, winner.account_number)
if owner:
deliveries.append((winner.id, owner.telegram_id,
f"🎉 Поздравляем! Вы выиграли!\n\nРозыгрыш: {lottery.title}\n"
f"Место: {winner.place}\nПриз: {winner.prize}\n"
f"Счет: {winner.account_number or 'участие по Telegram ID'}\n\n"
"Подтвердите выигрыш в течение 24 часов."))
# Release the connection before any Telegram I/O.
await session.commit()
sent = 0
for winner_id, chat_id, text in deliveries:
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(
text="✅ Подтвердить выигрыш", callback_data=f"confirm_win_{winner_id}")]])
try:
await send_background(bot, chat_id=chat_id, text=text, reply_markup=keyboard)
except Exception:
logger.exception("Could not notify winner %s", winner_id)
continue
async with async_session_maker() as status_session:
await status_session.execute(update(Winner).where(Winner.id == winner_id).values(is_notified=True))
await status_session.commit()
sent += 1
return sent

69
src/utils/spreadsheets.py Normal file
View File

@@ -0,0 +1,69 @@
"""Bounded Excel parsing and formula-safe exports, run outside the event loop."""
from io import BytesIO
from itertools import islice
from zipfile import ZipFile
from openpyxl import Workbook, load_workbook
MAX_FILE_BYTES = 5 * 1024 * 1024
MAX_ROWS = 10000
HEADERS = ["Telegram ID", "Username", "Имя", "Фамилия", "Никнейм", "Телефон", "Клубная карта",
"Зарегистрирован", "Админ", "Код верификации", "Дата создания", "Последняя активность", "Заблокирован в чате"]
def export_users(users):
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet("Пользователи")
sheet.append(HEADERS)
from openpyxl.cell import WriteOnlyCell
for user in users:
row = [user.telegram_id, user.username, user.first_name, user.last_name, user.nickname, user.phone,
user.club_card_number, "Да" if user.is_registered else "Нет", "Да" if user.is_admin else "Нет",
user.verification_code, user.created_at.isoformat() if user.created_at else "",
user.last_activity.isoformat() if user.last_activity else "", "Да" if user.is_chat_banned else "Нет"]
cells = []
for value in row:
cell = WriteOnlyCell(sheet, value=value)
if isinstance(value, str):
cell.data_type = "s" # User text such as '=HYPERLINK(...)' must never become a formula.
cells.append(cell)
sheet.append(cells)
stream = BytesIO()
workbook.save(stream)
return stream.getvalue()
def read_users_xlsx(content):
if len(content) > MAX_FILE_BYTES:
raise ValueError("Файл должен быть не больше 5 МБ")
with ZipFile(BytesIO(content)) as archive:
if sum(info.file_size for info in archive.infolist()) > 64 * 1024 * 1024:
raise ValueError("Слишком большой размер распакованного XLSX")
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True, keep_links=False)
try:
sheet = workbook.active
if sheet.max_column and sheet.max_column > 32:
raise ValueError("В файле должно быть не более 32 столбцов")
rows = list(islice(sheet.iter_rows(values_only=True, max_col=32), MAX_ROWS + 2))
if len(rows) > MAX_ROWS + 1:
raise ValueError("В файле должно быть не более 10000 пользователей")
if len(rows) < 2 or "Telegram ID" not in rows[0]:
raise ValueError("Нет данных или колонки Telegram ID")
headers = rows[0]
mapping = {"Username": "username", "Имя": "first_name", "Фамилия": "last_name", "Никнейм": "nickname",
"Телефон": "phone", "Клубная карта": "club_card_number", "Зарегистрирован": "is_registered",
"Код верификации": "verification_code"}
result = []
for row in rows[1:]:
if not any(value is not None for value in row):
continue
record = {"telegram_id": row[headers.index("Telegram ID")]}
for header, field in mapping.items():
if header in headers:
value = row[headers.index(header)]
if value is not None:
record[field] = value in ("Да", "Yes", "True", True, 1) if field == "is_registered" else str(value)
result.append(record)
return result
finally:
workbook.close()

View File

@@ -0,0 +1,36 @@
"""Copy user messages without losing Telegram entities, including custom emoji."""
from aiogram.types import Message, MessageEntity
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
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.
return await message.copy_to(recipient_id)
prefix = "📨 "
header = f"{prefix}{sender_name}:\n\n"
header_entities = [MessageEntity(type="bold", offset=utf16_length(prefix),
length=utf16_length(sender_name + ":"))]
offset = utf16_length(header)
has_caption = any((message.photo, message.video, message.document,
message.animation, message.audio, message.voice))
body = message.text if message.text is not None else (message.caption or "")
original = message.entities if message.text is not None else message.caption_entities
entities = header_entities + [entity.model_copy(update={"offset": entity.offset + offset})
for entity in original or ()]
limit = 4096 if message.text is not None else 1024
if (message.text is not None or has_caption) and offset + utf16_length(body) <= limit:
if message.text is not None:
return await message.bot.send_message(recipient_id, header + body,
entities=entities, parse_mode=None)
return await message.copy_to(recipient_id, caption=header + body,
caption_entities=entities, parse_mode=None)
# Do not trim a long body/caption: send the header separately and copy the original.
await message.bot.send_message(recipient_id, header, entities=header_entities, parse_mode=None)
return await message.copy_to(recipient_id)