Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
@@ -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
55
src/utils/delivery.py
Normal 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
7
src/utils/errors.py
Normal 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 "Не удалось выполнить действие. Повторите попытку позже."
|
||||
@@ -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
69
src/utils/spreadsheets.py
Normal 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()
|
||||
36
src/utils/telegram_messages.py
Normal file
36
src/utils/telegram_messages.py
Normal 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)
|
||||
Reference in New Issue
Block a user