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:
@@ -34,50 +34,12 @@ class LotteryServiceImpl(ILotteryService):
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
async def conduct_draw(self, lottery_id: int) -> Dict[str, Any]:
|
||||
"""Провести розыгрыш"""
|
||||
lottery = await self.lottery_repo.get_by_id(lottery_id)
|
||||
if not lottery or lottery.is_completed:
|
||||
return {}
|
||||
|
||||
# Получаем участников
|
||||
participations = await self.participation_repo.get_by_lottery(lottery_id)
|
||||
if not participations:
|
||||
return {}
|
||||
|
||||
# Проводим розыгрыш
|
||||
random.shuffle(participations)
|
||||
results = {}
|
||||
|
||||
num_prizes = len(lottery.prizes) if lottery.prizes else 3
|
||||
winners = participations[:num_prizes]
|
||||
|
||||
for i, participation in enumerate(winners):
|
||||
place = i + 1
|
||||
prize = lottery.prizes[i] if lottery.prizes and i < len(lottery.prizes) else f"Приз {place}"
|
||||
|
||||
# Создаем запись о победителе
|
||||
winner = await self.winner_repo.create(
|
||||
lottery_id=lottery_id,
|
||||
user_id=participation.user_id,
|
||||
account_number=participation.account_number,
|
||||
place=place,
|
||||
prize=prize,
|
||||
is_manual=False
|
||||
)
|
||||
|
||||
results[str(place)] = {
|
||||
'winner': winner,
|
||||
'user': participation.user,
|
||||
'prize': prize
|
||||
}
|
||||
|
||||
# Помечаем розыгрыш как завершенный
|
||||
lottery.is_completed = True
|
||||
lottery.draw_results = {str(k): v['prize'] for k, v in results.items()}
|
||||
await self.lottery_repo.update(lottery)
|
||||
|
||||
return results
|
||||
async def conduct_draw(self, lottery_id):
|
||||
from src.core.services import LotteryService
|
||||
result = await LotteryService.conduct_draw(self.lottery_repo.session, lottery_id)
|
||||
winners = await self.winner_repo.get_by_lottery(lottery_id) if result else []
|
||||
return {str(place): dict(info, winner=next(w for w in winners if w.place == place))
|
||||
for place, info in result.items()}
|
||||
|
||||
async def get_active_lotteries(self) -> List[Lottery]:
|
||||
"""Получить активные розыгрыши"""
|
||||
@@ -92,26 +54,13 @@ class UserServiceImpl(IUserService):
|
||||
|
||||
async def get_or_create_user(self, telegram_id: int, **kwargs) -> User:
|
||||
"""Получить или создать пользователя"""
|
||||
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
||||
if not user:
|
||||
user_data = {
|
||||
'telegram_id': telegram_id,
|
||||
'created_at': datetime.now(timezone.utc),
|
||||
**kwargs
|
||||
}
|
||||
user = await self.user_repo.create(**user_data)
|
||||
return user
|
||||
from src.core.services import UserService
|
||||
return await UserService.get_or_create_user(self.user_repo.session, telegram_id, **kwargs)
|
||||
|
||||
async def register_user(self, telegram_id: int, phone: str, club_card_number: str) -> bool:
|
||||
"""Зарегистрировать пользователя"""
|
||||
user = await self.user_repo.get_by_telegram_id(telegram_id)
|
||||
if not user:
|
||||
from src.core.registration_services import RegistrationService
|
||||
try:
|
||||
await RegistrationService.register_user(self.user_repo.session, telegram_id, club_card_number, phone)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
user.phone = phone
|
||||
user.club_card_number = club_card_number
|
||||
user.is_registered = True
|
||||
user.generate_verification_code()
|
||||
|
||||
await self.user_repo.update(user)
|
||||
return True
|
||||
@@ -81,17 +81,20 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
|
||||
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
|
||||
"""Форматировать информацию о розыгрыше"""
|
||||
text = f"🎲 **{lottery.title}**\n\n"
|
||||
from html import escape
|
||||
text = f"🎲 <b>{escape(lottery.title)}</b>\n\n"
|
||||
|
||||
if lottery.description:
|
||||
text += f"📝 {lottery.description}\n\n"
|
||||
text += f"📝 {escape(lottery.description[:1200])}\n\n"
|
||||
|
||||
text += f"👥 Участников: {participants_count}\n"
|
||||
|
||||
if lottery.prizes:
|
||||
text += "\n🏆 **Призы:**\n"
|
||||
for i, prize in enumerate(lottery.prizes, 1):
|
||||
text += f"{i}. {prize}\n"
|
||||
text += "\n🏆 <b>Призы:</b>\n"
|
||||
for i, prize in enumerate(lottery.prizes[:5], 1):
|
||||
text += f"{i}. {escape(str(prize)[:100])}\n"
|
||||
if len(lottery.prizes) > 5:
|
||||
text += f"И ещё {len(lottery.prizes) - 5} призов\n"
|
||||
|
||||
status = "🟢 Активный" if lottery.is_active and not lottery.is_completed else "🔴 Завершен"
|
||||
text += f"\n📊 Статус: {status}"
|
||||
@@ -134,4 +137,4 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
text += f"✅ Завершенных розыгрышей: {stats.get('completed_lotteries', 0)}\n"
|
||||
text += f"🎯 Всего участий: {stats.get('total_participations', 0)}\n"
|
||||
|
||||
return text
|
||||
return text
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from src.core.access import is_admin
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram import F
|
||||
import logging
|
||||
@@ -29,26 +30,27 @@ class BotController(IBotController):
|
||||
self.lottery_repo = lottery_repo
|
||||
self.participation_repo = participation_repo
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
async def is_admin(self, user_id: int) -> bool:
|
||||
"""Проверить, является ли пользователь администратором"""
|
||||
return user_id in ADMIN_IDS
|
||||
return await is_admin(user_id)
|
||||
|
||||
async def handle_start(self, message: Message):
|
||||
async def handle_start(self, message: Message, actor=None):
|
||||
"""Обработать команду /start"""
|
||||
from src.utils.keyboards import get_main_reply_keyboard
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
actor = actor or message.from_user
|
||||
user = await self.user_service.get_or_create_user(
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name
|
||||
telegram_id=actor.id,
|
||||
username=actor.username,
|
||||
first_name=actor.first_name,
|
||||
last_name=actor.last_name
|
||||
)
|
||||
|
||||
# Логирование статуса регистрации
|
||||
logger.info(f"User {message.from_user.id}: is_registered={user.is_registered}, is_admin={self.is_admin(message.from_user.id)}")
|
||||
staff_admin = await self.is_admin(actor.id)
|
||||
|
||||
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
|
||||
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
|
||||
@@ -60,13 +62,19 @@ class BotController(IBotController):
|
||||
|
||||
# Inline клавиатура
|
||||
inline_keyboard = self.keyboard_builder.get_main_keyboard(
|
||||
is_admin=self.is_admin(message.from_user.id),
|
||||
is_admin=staff_admin,
|
||||
is_registered=user.is_registered
|
||||
)
|
||||
from src.core.access import is_staff
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
if await is_staff(actor.id):
|
||||
inline_keyboard.inline_keyboard.append([
|
||||
InlineKeyboardButton(text="💼 Касса", callback_data="cashier_panel")
|
||||
])
|
||||
|
||||
# Обычная клавиатура
|
||||
reply_keyboard = get_main_reply_keyboard(
|
||||
is_admin=self.is_admin(message.from_user.id),
|
||||
is_admin=staff_admin,
|
||||
is_registered=user.is_registered
|
||||
)
|
||||
|
||||
@@ -94,7 +102,7 @@ class BotController(IBotController):
|
||||
for lottery in lotteries:
|
||||
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
|
||||
lottery_info = self.message_formatter.format_lottery_info(lottery, participants_count)
|
||||
text += lottery_info + "\n" + "="*30 + "\n\n"
|
||||
await callback.message.answer(lottery_info, parse_mode="HTML")
|
||||
|
||||
# Получаем информацию о регистрации пользователя
|
||||
user = await self.user_service.get_or_create_user(
|
||||
@@ -105,7 +113,7 @@ class BotController(IBotController):
|
||||
)
|
||||
|
||||
keyboard = self.keyboard_builder.get_main_keyboard(
|
||||
is_admin=self.is_admin(callback.from_user.id),
|
||||
is_admin=await self.is_admin(callback.from_user.id),
|
||||
is_registered=user.is_registered
|
||||
)
|
||||
|
||||
|
||||
56
src/core/access.py
Normal file
56
src/core/access.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Single source of truth for staff permissions."""
|
||||
from contextvars import ContextVar
|
||||
from functools import wraps
|
||||
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy import select
|
||||
|
||||
from .config import ADMIN_IDS, CASHIER_IDS
|
||||
from .database import async_session_maker
|
||||
from .models import User
|
||||
|
||||
current_role = ContextVar("current_staff_role", default=None)
|
||||
|
||||
|
||||
async def get_role(telegram_id: int) -> str:
|
||||
if telegram_id in ADMIN_IDS:
|
||||
return "super_admin"
|
||||
cached = current_role.get()
|
||||
if cached and cached[0] == telegram_id:
|
||||
return cached[1]
|
||||
async with async_session_maker() as session:
|
||||
row = (await session.execute(
|
||||
select(User.is_admin, User.is_cashier).where(User.telegram_id == telegram_id)
|
||||
)).first()
|
||||
if row and row.is_admin:
|
||||
return "admin"
|
||||
if telegram_id in CASHIER_IDS or (row and row.is_cashier):
|
||||
return "cashier"
|
||||
return "user"
|
||||
|
||||
|
||||
async def is_admin(telegram_id: int) -> bool:
|
||||
return await get_role(telegram_id) in {"super_admin", "admin"}
|
||||
|
||||
|
||||
async def is_staff(telegram_id: int) -> bool:
|
||||
return await get_role(telegram_id) in {"super_admin", "admin", "cashier"}
|
||||
|
||||
|
||||
def require_access(check):
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(event, *args, **kwargs):
|
||||
if not event.from_user or not await check(event.from_user.id):
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer("❌ Недостаточно прав", show_alert=True)
|
||||
else:
|
||||
await event.answer("❌ Недостаточно прав")
|
||||
return
|
||||
return await func(event, *args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
admin_only = require_access(is_admin)
|
||||
staff_only = require_access(is_staff)
|
||||
@@ -84,35 +84,24 @@ class ActivityService:
|
||||
Количество помеченных пользователей
|
||||
"""
|
||||
try:
|
||||
days = days if days is not None else ActivityService.INACTIVITY_PERIOD_DAYS
|
||||
if session.bind.dialect.name == "postgresql":
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
else:
|
||||
from sqlalchemy.dialects.sqlite import insert
|
||||
inactive_users = await ActivityService.get_inactive_users(session, days)
|
||||
marked_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
# Проверяем, не помечен ли уже
|
||||
stmt = select(BlockedUser).where(
|
||||
and_(
|
||||
BlockedUser.telegram_id == user.telegram_id,
|
||||
BlockedUser.error_type == 'inactive',
|
||||
BlockedUser.is_active == True
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
# Создаем новую запись
|
||||
blocked = BlockedUser(
|
||||
telegram_id=user.telegram_id,
|
||||
error_type='inactive',
|
||||
error_message=f'User inactive for {days} days',
|
||||
first_blocked_at=datetime.now(timezone.utc),
|
||||
last_attempt_at=datetime.now(timezone.utc),
|
||||
attempt_count=1,
|
||||
is_active=True
|
||||
)
|
||||
session.add(blocked)
|
||||
marked_count += 1
|
||||
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})")
|
||||
now = datetime.now(timezone.utc)
|
||||
values = dict(error_type='inactive', error_message=f'User inactive for {days} days',
|
||||
last_attempt_at=now, attempt_count=1, is_active=True)
|
||||
statement = insert(BlockedUser).values(telegram_id=user.telegram_id,
|
||||
first_blocked_at=now, **values)
|
||||
result = await session.execute(statement.on_conflict_do_update(
|
||||
index_elements=[BlockedUser.telegram_id], set_=values,
|
||||
where=BlockedUser.is_active.is_(False)))
|
||||
marked_count += result.rowcount
|
||||
|
||||
await session.commit()
|
||||
return marked_count
|
||||
|
||||
@@ -6,6 +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 aiogram import Bot
|
||||
from aiogram.types import Message
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
@@ -16,6 +17,7 @@ import redis.asyncio as redis
|
||||
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
|
||||
from .config import REDIS_URL, ADMIN_IDS
|
||||
from .database import async_session_maker
|
||||
from src.utils.telegram_messages import copy_preserving_entities
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,27 +133,16 @@ class BroadcastService:
|
||||
error_type: Тип ошибки
|
||||
error_message: Сообщение об ошибке
|
||||
"""
|
||||
# Проверяем, есть ли уже запись
|
||||
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
||||
result = await session.execute(stmt)
|
||||
blocked_user = result.scalar_one_or_none()
|
||||
|
||||
if blocked_user:
|
||||
# Обновляем существующую запись
|
||||
blocked_user.error_type = error_type
|
||||
blocked_user.error_message = error_message
|
||||
blocked_user.last_attempt_at = datetime.now(timezone.utc)
|
||||
blocked_user.attempt_count += 1
|
||||
blocked_user.is_active = True
|
||||
if session.bind.dialect.name == "postgresql":
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
else:
|
||||
# Создаем новую запись
|
||||
blocked_user = BlockedUser(
|
||||
telegram_id=telegram_id,
|
||||
error_type=error_type,
|
||||
error_message=error_message
|
||||
)
|
||||
session.add(blocked_user)
|
||||
|
||||
from sqlalchemy.dialects.sqlite import insert
|
||||
now = datetime.now(timezone.utc)
|
||||
statement = insert(BlockedUser).values(telegram_id=telegram_id, error_type=error_type,
|
||||
error_message=error_message, last_attempt_at=now, attempt_count=1, is_active=True)
|
||||
await session.execute(statement.on_conflict_do_update(index_elements=[BlockedUser.telegram_id],
|
||||
set_={"error_type": error_type, "error_message": error_message, "last_attempt_at": now,
|
||||
"attempt_count": BlockedUser.attempt_count + 1, "is_active": True}))
|
||||
await session.commit()
|
||||
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
|
||||
|
||||
@@ -175,11 +166,13 @@ class BroadcastService:
|
||||
await session.commit()
|
||||
logger.info(f"Пользователь {telegram_id} разблокирован")
|
||||
|
||||
@background_delivery
|
||||
async def send_message_to_user(
|
||||
self,
|
||||
bot: Bot,
|
||||
user: User,
|
||||
message: Message
|
||||
message: Message,
|
||||
_retry: bool = False
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Отправить сообщение пользователю с обработкой ошибок
|
||||
@@ -201,36 +194,8 @@ class BroadcastService:
|
||||
return False, "blocked"
|
||||
|
||||
# Отправляем сообщение
|
||||
if message.text:
|
||||
await bot.send_message(
|
||||
user.telegram_id,
|
||||
message.text,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.photo:
|
||||
await bot.send_photo(
|
||||
user.telegram_id,
|
||||
photo=message.photo[-1].file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.video:
|
||||
await bot.send_video(
|
||||
user.telegram_id,
|
||||
video=message.video.file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.document:
|
||||
await bot.send_document(
|
||||
user.telegram_id,
|
||||
document=message.document.file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
else:
|
||||
# Копируем сообщение как есть
|
||||
await message.copy_to(user.telegram_id)
|
||||
# Copy preserves Telegram entities and captions without reparsing user text.
|
||||
await copy_preserving_entities(message, user.telegram_id)
|
||||
|
||||
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее)
|
||||
async with async_session_maker() as session:
|
||||
@@ -257,16 +222,20 @@ class BroadcastService:
|
||||
else:
|
||||
error_type = "bad_request"
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
||||
# A rejected custom emoji or malformed message does not mean the recipient blocked us.
|
||||
if error_type != "bad_request":
|
||||
async with async_session_maker() as session:
|
||||
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
||||
return False, error_type
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
# FloodWait - слишком много запросов
|
||||
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
|
||||
if _retry:
|
||||
return False, "rate_limited"
|
||||
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
|
||||
# Повторная попытка
|
||||
return await self.send_message_to_user(bot, user, message)
|
||||
return await self.send_message_to_user.__wrapped__(self, bot, user, message, _retry=True)
|
||||
|
||||
except Exception as e:
|
||||
# Другие ошибки
|
||||
@@ -416,32 +385,7 @@ class BroadcastService:
|
||||
log_id = broadcast_log.id
|
||||
|
||||
try:
|
||||
# Отправляем в канал
|
||||
if message.text:
|
||||
await bot.send_message(channel_id, message.text, parse_mode="Markdown")
|
||||
elif message.photo:
|
||||
await bot.send_photo(
|
||||
channel_id,
|
||||
photo=message.photo[-1].file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.video:
|
||||
await bot.send_video(
|
||||
channel_id,
|
||||
video=message.video.file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
elif message.document:
|
||||
await bot.send_document(
|
||||
channel_id,
|
||||
document=message.document.file_id,
|
||||
caption=message.caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
else:
|
||||
await message.copy_to(channel_id)
|
||||
await copy_preserving_entities(message, channel_id)
|
||||
|
||||
# Обновляем лог
|
||||
async with async_session_maker() as session:
|
||||
|
||||
@@ -24,10 +24,13 @@ class ChatSettingsService:
|
||||
"""Получить или создать настройки чата"""
|
||||
settings = await ChatSettingsService.get_settings(session)
|
||||
if not settings:
|
||||
settings = ChatSettings(id=1, mode='broadcast', global_ban=False)
|
||||
session.add(settings)
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
||||
await session.execute(insert(ChatSettings).values(id=1, mode="broadcast", global_ban=False)
|
||||
.on_conflict_do_nothing(index_elements=[ChatSettings.id]))
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
settings = await ChatSettingsService.get_settings(session)
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
@@ -73,7 +76,7 @@ class BanService:
|
||||
BannedUser.telegram_id == telegram_id,
|
||||
BannedUser.is_active == True
|
||||
)
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@@ -86,6 +89,7 @@ class BanService:
|
||||
reason: Optional[str] = None
|
||||
) -> BannedUser:
|
||||
"""Забанить пользователя"""
|
||||
await session.execute(update(User).where(User.id == user_id).values(is_chat_banned=True))
|
||||
# Проверяем есть ли уже активный бан
|
||||
existing_ban = await session.execute(
|
||||
select(BannedUser).where(
|
||||
@@ -95,7 +99,7 @@ class BanService:
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = existing_ban.scalar_one_or_none()
|
||||
existing = existing_ban.scalars().first()
|
||||
|
||||
if existing:
|
||||
# Обновляем причину
|
||||
@@ -120,6 +124,8 @@ class BanService:
|
||||
@staticmethod
|
||||
async def unban_user(session: AsyncSession, telegram_id: int) -> bool:
|
||||
"""Разбанить пользователя"""
|
||||
user_result = await session.execute(update(User).where(User.telegram_id == telegram_id, User.is_chat_banned.is_(True))
|
||||
.values(is_chat_banned=False))
|
||||
result = await session.execute(
|
||||
update(BannedUser)
|
||||
.where(
|
||||
@@ -131,7 +137,7 @@ class BanService:
|
||||
.values(is_active=False)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
return result.rowcount > 0 or user_result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_banned_users(session: AsyncSession, active_only: bool = True) -> List[BannedUser]:
|
||||
@@ -189,47 +195,26 @@ class ChatMessageService:
|
||||
async def get_message_by_telegram_id(
|
||||
session: AsyncSession,
|
||||
telegram_message_id: int,
|
||||
user_id: Optional[int] = None
|
||||
user_id: Optional[int] = None,
|
||||
chat_id: Optional[int] = None,
|
||||
) -> Optional[ChatMessage]:
|
||||
"""
|
||||
Получить сообщение по telegram_message_id
|
||||
Ищет как по оригинальному telegram_message_id, так и в forwarded_message_ids
|
||||
"""
|
||||
# Сначала ищем по оригинальному telegram_message_id
|
||||
query = select(ChatMessage).where(
|
||||
ChatMessage.telegram_message_id == telegram_message_id
|
||||
)
|
||||
|
||||
if user_id:
|
||||
query = query.where(ChatMessage.user_id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
message = result.scalar_one_or_none()
|
||||
|
||||
# Если нашли - возвращаем
|
||||
if message:
|
||||
return message
|
||||
|
||||
# Если не нашли - ищем в forwarded_message_ids
|
||||
# Загружаем все недавние сообщения и ищем в них
|
||||
query = select(ChatMessage).where(
|
||||
ChatMessage.forwarded_message_ids.isnot(None)
|
||||
).order_by(ChatMessage.created_at.desc()).limit(100)
|
||||
|
||||
result = await session.execute(query)
|
||||
messages = result.scalars().all()
|
||||
|
||||
# Ищем сообщение, где telegram_message_id есть в forwarded_message_ids
|
||||
for msg in messages:
|
||||
if msg.forwarded_message_ids:
|
||||
for user_tid, fwd_msg_id in msg.forwarded_message_ids.items():
|
||||
if fwd_msg_id == telegram_message_id:
|
||||
return msg
|
||||
|
||||
return None
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
"""Telegram message IDs are unique only inside the receiving chat."""
|
||||
from sqlalchemy import or_
|
||||
query = select(ChatMessage).options(selectinload(ChatMessage.sender)).where(
|
||||
ChatMessage.is_deleted.is_(False))
|
||||
if chat_id is not None:
|
||||
query = query.where(or_(
|
||||
and_(ChatMessage.telegram_message_id == telegram_message_id,
|
||||
ChatMessage.sender.has(User.telegram_id == chat_id)),
|
||||
ChatMessage.forwarded_message_ids[str(chat_id)].as_integer() == telegram_message_id,
|
||||
))
|
||||
elif user_id is not None:
|
||||
query = query.where(ChatMessage.user_id == user_id,
|
||||
ChatMessage.telegram_message_id == telegram_message_id)
|
||||
else:
|
||||
return None
|
||||
matches = (await session.scalars(query.limit(2))).all()
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
@staticmethod
|
||||
async def get_user_messages(
|
||||
@@ -368,6 +353,8 @@ class ChatPermissionService:
|
||||
|
||||
if user and user.is_chat_banned:
|
||||
return False, "Вы заблокированы и не можете отправлять сообщения в чат"
|
||||
if not user or not user.is_registered:
|
||||
return False, "Для отправки сообщений пройдите регистрацию: /register"
|
||||
|
||||
# Проверяем личный бан (старая система через BannedUser)
|
||||
is_banned = await BanService.is_banned(session, telegram_id)
|
||||
|
||||
@@ -13,7 +13,8 @@ if not BOT_TOKEN:
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||
|
||||
# Redis
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
REDIS_URL = os.getenv("REDIS_URL", "")
|
||||
FSM_TTL_SECONDS = int(os.getenv("FSM_TTL_SECONDS", "86400"))
|
||||
|
||||
# Администраторы
|
||||
ADMIN_IDS = []
|
||||
@@ -27,6 +28,9 @@ if admin_ids_str:
|
||||
# Логирование
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# Кассиры имеют доступ только к участникам, счетам и выдаче призов.
|
||||
CASHIER_IDS = [int(value.strip()) for value in os.getenv("CASHIER_IDS", "").split(",") if value.strip()]
|
||||
|
||||
# Настройки бота
|
||||
MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше
|
||||
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей
|
||||
MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -10,11 +11,27 @@ load_dotenv()
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||
|
||||
# Создаем асинхронный движок
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=True, # Логирование SQL запросов
|
||||
future=True,
|
||||
)
|
||||
engine_options = {"echo": False, "pool_pre_ping": True, "hide_parameters": True}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
engine_options["connect_args"] = {"timeout": 30}
|
||||
else:
|
||||
engine_options.update(
|
||||
pool_size=int(os.getenv("DB_POOL_SIZE", "10")),
|
||||
max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "10")),
|
||||
pool_timeout=float(os.getenv("DB_POOL_TIMEOUT", "15")),
|
||||
pool_recycle=int(os.getenv("DB_POOL_RECYCLE", "1800")),
|
||||
connect_args={"timeout": float(os.getenv("DB_CONNECT_TIMEOUT", "10")),
|
||||
"command_timeout": float(os.getenv("DATABASE_TIMEOUT", "30"))},
|
||||
)
|
||||
engine = create_async_engine(DATABASE_URL, **engine_options)
|
||||
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def configure_sqlite(connection, _):
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA busy_timeout=30000")
|
||||
cursor.close()
|
||||
|
||||
# Создаем фабрику сессий
|
||||
async_session_maker = async_sessionmaker(
|
||||
@@ -33,9 +50,10 @@ async def get_session() -> AsyncSession:
|
||||
|
||||
async def init_db():
|
||||
"""Инициализация базы данных"""
|
||||
from . import models # Register tables before create_all (development/tests only).
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def close_db():
|
||||
"""Закрытие соединения с базой данных"""
|
||||
await engine.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from html import escape
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
@@ -33,15 +36,27 @@ class EmojiMappingService:
|
||||
Returns:
|
||||
Созданный объект EmojiMapping
|
||||
"""
|
||||
if not emoji_text or len(emoji_text) > 10:
|
||||
raise ValueError("Отправьте один эмодзи")
|
||||
if not emoji_id or not emoji_id.isascii() or not emoji_id.isdigit() or len(emoji_id) > 32:
|
||||
raise ValueError("Нужен настоящий custom_emoji_id из Telegram")
|
||||
if description and len(description) > 255:
|
||||
raise ValueError("Описание должно быть не длиннее 255 символов")
|
||||
from .services import UserService
|
||||
admin = await UserService.get_or_create_user(self.session, admin_id)
|
||||
emoji = EmojiMapping(
|
||||
emoji_text=emoji_text,
|
||||
emoji_id=emoji_id,
|
||||
admin_id=admin_id,
|
||||
admin_id=admin.id,
|
||||
description=description,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
self.session.add(emoji)
|
||||
await self.session.commit()
|
||||
try:
|
||||
await self.session.commit()
|
||||
except IntegrityError:
|
||||
await self.session.rollback()
|
||||
raise ValueError("Этот эмодзи или его вариант уже зарегистрирован") from None
|
||||
await self.session.refresh(emoji)
|
||||
return emoji
|
||||
|
||||
@@ -58,7 +73,7 @@ class EmojiMappingService:
|
||||
"""
|
||||
query = select(EmojiMapping).where(EmojiMapping.emoji_text == emoji_text)
|
||||
if admin_id:
|
||||
query = query.where(EmojiMapping.admin_id == admin_id)
|
||||
query = query.where(EmojiMapping.admin.has(User.telegram_id == admin_id))
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return result.scalars().first()
|
||||
@@ -74,7 +89,7 @@ class EmojiMappingService:
|
||||
EmojiMapping объект или None
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(EmojiMapping).where(EmojiMapping.emoji_id == emoji_id)
|
||||
select(EmojiMapping).options(selectinload(EmojiMapping.admin)).where(EmojiMapping.emoji_id == emoji_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -89,14 +104,14 @@ class EmojiMappingService:
|
||||
Список EmojiMapping объектов
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(EmojiMapping).where(EmojiMapping.admin_id == admin_id)
|
||||
select(EmojiMapping).where(EmojiMapping.admin.has(User.telegram_id == admin_id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_all_emojis(self) -> List[EmojiMapping]:
|
||||
"""Получить все зарегистрированные эмодзи"""
|
||||
result = await self.session.execute(
|
||||
select(EmojiMapping).order_by(EmojiMapping.created_at.desc())
|
||||
select(EmojiMapping).options(selectinload(EmojiMapping.admin)).order_by(EmojiMapping.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -136,42 +151,39 @@ class EmojiMappingService:
|
||||
return True
|
||||
|
||||
async def replace_emojis_in_text(self, text: str) -> str:
|
||||
"""
|
||||
Заменить все известные эмодзи на их emoji_id в тексте
|
||||
|
||||
Это используется перед отправкой сообщения в Telegram,
|
||||
чтобы эмодзи выглядели так же, как их отправил админ
|
||||
|
||||
Args:
|
||||
text: Исходный текст с эмодзи
|
||||
|
||||
Returns:
|
||||
Текст с заменой эмодзи на emoji_id
|
||||
"""
|
||||
# Получаем все эмодзи маппинги
|
||||
"""Render a plain-text template as safe Telegram HTML with saved custom emoji."""
|
||||
emojis = await self.get_all_emojis()
|
||||
|
||||
# Заменяем каждый эмодзи на его emoji_id
|
||||
mapping = {}
|
||||
for emoji in emojis:
|
||||
# Экранируем специальные символы если нужно
|
||||
if emoji.emoji_text in text:
|
||||
# Замена с сохранением контекста - оборачиваем в специальные маркеры
|
||||
# Это позволит потом распознать что это эмодзи ID а не обычный текст
|
||||
text = text.replace(emoji.emoji_text, f"|{emoji.emoji_id}|")
|
||||
|
||||
return text
|
||||
if emoji.emoji_id.isascii() and emoji.emoji_id.isdigit() and emoji.emoji_text:
|
||||
mapping.setdefault(emoji.emoji_text, emoji.emoji_id)
|
||||
if not mapping:
|
||||
return escape(text)
|
||||
pattern = re.compile("|".join(re.escape(key) for key in sorted(mapping, key=len, reverse=True)))
|
||||
output, position = [], 0
|
||||
for match in pattern.finditer(text):
|
||||
output.append(escape(text[position:match.start()]))
|
||||
output.append(f'<tg-emoji emoji-id="{mapping[match[0]]}">{escape(match[0])}</tg-emoji>')
|
||||
position = match.end()
|
||||
output.append(escape(text[position:]))
|
||||
return "".join(output)
|
||||
|
||||
async def restore_emojis_in_text(self, text: str) -> str:
|
||||
"""
|
||||
Восстановить эмодзи из их emoji_id в тексте (обратная операция)
|
||||
|
||||
Args:
|
||||
text: Текст с emoji_id маркерами (|emoji_id|)
|
||||
|
||||
Returns:
|
||||
Текст с восстановленными эмодзи
|
||||
"""
|
||||
# Получаем все эмодзи маппинги
|
||||
"""Recover plain text from rendered HTML or legacy |emoji_id| markers."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class PlainText(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts = []
|
||||
|
||||
def handle_data(self, data):
|
||||
self.parts.append(data)
|
||||
|
||||
parser = PlainText()
|
||||
parser.feed(text)
|
||||
parser.close()
|
||||
text = "".join(parser.parts)
|
||||
emojis = await self.get_all_emojis()
|
||||
|
||||
# Восстанавливаем каждый эмодзи из его ID
|
||||
|
||||
30
src/core/health.py
Normal file
30
src/core/health.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Health reflects a live event loop and a usable database connection."""
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from .database import async_session_maker
|
||||
|
||||
HEARTBEAT = Path(os.getenv("HEARTBEAT_FILE", "/tmp/lottery-heartbeat"))
|
||||
|
||||
|
||||
async def heartbeat():
|
||||
while True:
|
||||
async with async_session_maker() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
HEARTBEAT.write_text(str(time.time()), encoding="ascii")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
def healthy():
|
||||
try:
|
||||
return 0 <= time.time() - float(HEARTBEAT.read_text(encoding="ascii")) < 60
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(0 if healthy() else 1)
|
||||
39
src/core/import_services.py
Normal file
39
src/core/import_services.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Import independent records without poisoning the transaction on a bad row."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError, DataError
|
||||
|
||||
from .database import async_session_maker
|
||||
from .models import User
|
||||
|
||||
|
||||
async def import_users(records):
|
||||
added = updated = errors = 0
|
||||
for record in records:
|
||||
try:
|
||||
telegram_id = int(record["telegram_id"])
|
||||
if telegram_id <= 0 or telegram_id > 2**63 - 1:
|
||||
raise ValueError("Invalid Telegram ID")
|
||||
values = {key: value for key, value in record.items() if key in {
|
||||
"username", "first_name", "last_name", "nickname", "phone", "club_card_number",
|
||||
"is_registered", "verification_code"}}
|
||||
limits = {"username": 255, "first_name": 255, "last_name": 255, "nickname": 100,
|
||||
"phone": 20, "club_card_number": 50, "verification_code": 10}
|
||||
for key, limit in limits.items():
|
||||
if key in values and (not isinstance(values[key], str) or len(values[key]) > limit):
|
||||
raise ValueError("Invalid field length")
|
||||
async with async_session_maker() as session:
|
||||
user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
|
||||
is_new = user is None
|
||||
if is_new:
|
||||
user = User(telegram_id=telegram_id)
|
||||
session.add(user)
|
||||
for key, value in values.items():
|
||||
setattr(user, key, value)
|
||||
if user.is_registered and not user.verification_code:
|
||||
user.generate_verification_code()
|
||||
await session.commit()
|
||||
added += int(is_new)
|
||||
updated += int(not is_new)
|
||||
except (ValueError, TypeError, IntegrityError, DataError):
|
||||
errors += 1
|
||||
return added, updated, errors
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint, BigInteger
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint, BigInteger, Index, text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from .database import Base
|
||||
@@ -19,6 +19,7 @@ class User(Base):
|
||||
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
|
||||
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
|
||||
is_admin = Column(Boolean, default=False)
|
||||
is_cashier = Column(Boolean, default=False, server_default="false", nullable=False)
|
||||
is_chat_banned = Column(Boolean, default=False) # Заблокирован ли в чате бота
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
last_activity = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Последняя активность
|
||||
@@ -114,6 +115,11 @@ class Lottery(Base):
|
||||
class Participation(Base):
|
||||
"""Модель участия в розыгрыше"""
|
||||
__tablename__ = "participations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("lottery_id", "account_number", name="uq_participation_account"),
|
||||
Index("uq_participation_user", "lottery_id", "user_id", unique=True,
|
||||
postgresql_where=text("account_number IS NULL"), sqlite_where=text("account_number IS NULL")),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
@@ -136,6 +142,7 @@ class Participation(Base):
|
||||
class Winner(Base):
|
||||
"""Модель победителя розыгрыша"""
|
||||
__tablename__ = "winners"
|
||||
__table_args__ = (UniqueConstraint("lottery_id", "place", name="uq_winner_place"),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
"""
|
||||
Система управления правами доступа к командам бота
|
||||
"""
|
||||
from src.core.access import is_admin
|
||||
from src.core.access import admin_only
|
||||
from functools import wraps
|
||||
from aiogram.types import Message
|
||||
from src.core.config import ADMIN_IDS
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка является ли пользователь администратором"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
def admin_only(func):
|
||||
"""
|
||||
Декоратор для команд, доступных только администраторам.
|
||||
Если пользователь не админ - отправляется сообщение об отказе в доступе.
|
||||
"""
|
||||
@wraps(func)
|
||||
async def wrapper(message: Message, *args, **kwargs):
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
return await func(message, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def user_command(func):
|
||||
@@ -177,7 +164,7 @@ def get_admin_commands_by_category():
|
||||
return commands_by_category
|
||||
|
||||
|
||||
def format_commands_help(user_id: int) -> str:
|
||||
async def format_commands_help(user_id: int) -> str:
|
||||
"""
|
||||
Форматировать справку по командам в зависимости от прав пользователя
|
||||
"""
|
||||
@@ -189,7 +176,7 @@ def format_commands_help(user_id: int) -> str:
|
||||
help_text += f"/{cmd} - {info['description']}\n"
|
||||
|
||||
# Если админ - показываем административные команды
|
||||
if is_admin(user_id):
|
||||
if await is_admin(user_id):
|
||||
help_text += "\n" + "=" * 30 + "\n\n"
|
||||
help_text += "🔐 <b>Административные команды:</b>\n\n"
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Поддержка премиум эмодзи для ботов, созданных с премиум аккаунтов
|
||||
Telegram Bot API поддерживает премиум эмодзи начиная с версии 7.0
|
||||
"""Compatibility helpers for emoji-aware HTML templates.
|
||||
|
||||
Для использования премиум эмодзи:
|
||||
1. Бот должен быть создан с премиум аккаунта
|
||||
2. Использовать эмодзи напрямую в тексте сообщений
|
||||
3. Использовать parse_mode="HTML" или parse_mode="Markdown"
|
||||
Plain Unicode never identifies a custom emoji. Incoming chat and broadcast messages
|
||||
use their original Telegram entities via src.utils.telegram_messages.
|
||||
Telegram decides availability from bot ownership/subscription or Fragment eligibility:
|
||||
https://core.telegram.org/bots/api#formatting-options
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
@@ -16,7 +14,7 @@ from aiogram.enums import MessageEntityType
|
||||
class PremiumEmojiConfig:
|
||||
"""Конфигурация поддержки премиум эмодзи"""
|
||||
|
||||
# Флаг, что бот может использовать премиум эмодзи
|
||||
# Поддержка в коде; этот флаг не проверяет права бота на стороне Telegram
|
||||
SUPPORTS_PREMIUM_EMOJI = True
|
||||
|
||||
# Стандартные parse_mode для автоматической поддержки эмодзи
|
||||
@@ -60,7 +58,7 @@ def ensure_emoji_support(text: str) -> str:
|
||||
Returns:
|
||||
Обработанный текст с поддержкой эмодзи
|
||||
"""
|
||||
# В Aiogram 3.16+ эмодзи автоматически поддерживаются при правильном parse_mode
|
||||
# Уже подготовленные tg-emoji теги сохраняются; обычный символ не превращается в premium
|
||||
# Эта функция может быть расширена для дополнительной обработки если нужно
|
||||
return text
|
||||
|
||||
@@ -83,7 +81,9 @@ async def send_message_with_emoji(
|
||||
Returns:
|
||||
Результат отправки сообщения
|
||||
"""
|
||||
if parse_mode is None:
|
||||
if kwargs.get("entities") is not None:
|
||||
parse_mode = None
|
||||
elif parse_mode is None:
|
||||
parse_mode = get_parse_mode()
|
||||
|
||||
# Убедиться что текст может содержать эмодзи
|
||||
|
||||
47
src/core/redraw_services.py
Normal file
47
src/core/redraw_services.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Replace expired unclaimed prizes atomically, preserving all other results."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import random
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from .models import Lottery, Participation, Winner, WinnerVerification
|
||||
|
||||
|
||||
async def redraw_unclaimed(session, lottery_id):
|
||||
locked = await session.execute(update(Lottery).where(Lottery.id == lottery_id, Lottery.is_completed.is_(True))
|
||||
.values(is_completed=True).execution_options(synchronize_session=False))
|
||||
if locked.rowcount != 1:
|
||||
await session.rollback()
|
||||
return []
|
||||
# Synchronize with cashier confirmations, which UPDATE the same Winner row.
|
||||
winners = list((await session.scalars(select(Winner).where(Winner.lottery_id == lottery_id)
|
||||
.order_by(Winner.place).with_for_update().execution_options(populate_existing=True))).all())
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
def expired(winner):
|
||||
created = winner.created_at
|
||||
if created and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
return not winner.is_claimed and winner.is_notified and created and created < cutoff
|
||||
unclaimed = [winner for winner in winners if expired(winner)]
|
||||
used = {("account", w.account_number) if w.account_number else ("user", w.user_id) for w in winners}
|
||||
tickets = list((await session.scalars(select(Participation).where(Participation.lottery_id == lottery_id)
|
||||
.options(selectinload(Participation.user)))).all())
|
||||
available = [p for p in tickets if (("account", p.account_number) if p.account_number else ("user", p.user_id)) not in used]
|
||||
random.SystemRandom().shuffle(available)
|
||||
lottery = await session.get(Lottery, lottery_id)
|
||||
results = dict(lottery.draw_results or {})
|
||||
replacements = []
|
||||
for previous, ticket in zip(unclaimed, available):
|
||||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id == previous.id))
|
||||
await session.delete(previous)
|
||||
await session.flush()
|
||||
winner = Winner(lottery_id=lottery_id, user_id=ticket.user_id, account_number=ticket.account_number,
|
||||
place=previous.place, prize=previous.prize, is_manual=False)
|
||||
session.add(winner)
|
||||
replacements.append(winner)
|
||||
results[str(winner.place)] = dict(user_id=ticket.user_id, account_number=ticket.account_number,
|
||||
telegram_id=ticket.user.telegram_id if ticket.user else None, prize=winner.prize, is_manual=False)
|
||||
lottery.draw_results = results
|
||||
await session.commit()
|
||||
return replacements
|
||||
@@ -4,6 +4,8 @@ from sqlalchemy import select, and_
|
||||
from .models import User, Account, Winner, WinnerVerification
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from src.utils.account_utils import format_account_number
|
||||
import secrets
|
||||
|
||||
|
||||
@@ -18,6 +20,10 @@ class RegistrationService:
|
||||
phone: Optional[str] = None
|
||||
) -> User:
|
||||
"""Зарегистрировать нового пользователя с клубной картой"""
|
||||
if not club_card_number or not club_card_number.isascii() or not club_card_number.isdigit() or len(club_card_number) > 50:
|
||||
raise ValueError("Номер клубной карты должен содержать от 1 до 50 цифр")
|
||||
if phone and len(phone) > 20:
|
||||
raise ValueError("Телефон должен быть не длиннее 20 символов")
|
||||
# Проверяем, не занята ли клубная карта
|
||||
existing = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
@@ -40,7 +46,11 @@ class RegistrationService:
|
||||
user.is_registered = True
|
||||
user.generate_verification_code()
|
||||
|
||||
await session.commit()
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise ValueError("Клубная карта уже зарегистрирована. Обратитесь к администратору.") from None
|
||||
await session.refresh(user)
|
||||
|
||||
return user
|
||||
@@ -78,6 +88,9 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> Account:
|
||||
"""Создать новый счет для пользователя по номеру клубной карты"""
|
||||
account_number = format_account_number(account_number)
|
||||
if not account_number:
|
||||
raise ValueError("Некорректный номер счета: требуется 14 цифр")
|
||||
# Находим владельца по клубной карте
|
||||
user_result = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
@@ -101,7 +114,11 @@ class AccountService:
|
||||
is_active=True
|
||||
)
|
||||
session.add(account)
|
||||
await session.commit()
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise ValueError("Счет уже существует") from None
|
||||
await session.refresh(account)
|
||||
|
||||
return account
|
||||
@@ -112,6 +129,9 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> Optional[User]:
|
||||
"""Найти владельца счета"""
|
||||
account_number = format_account_number(account_number)
|
||||
if not account_number:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(Account).where(
|
||||
and_(
|
||||
@@ -201,74 +221,33 @@ class WinnerNotificationService:
|
||||
return verification
|
||||
|
||||
@staticmethod
|
||||
async def verify_winner(
|
||||
session: AsyncSession,
|
||||
verification_code: str,
|
||||
lottery_id: int
|
||||
) -> Optional[Winner]:
|
||||
"""Подтвердить выигрыш по коду верификации пользователя"""
|
||||
# Находим пользователя по коду
|
||||
user_result = await session.execute(
|
||||
select(User).where(User.verification_code == verification_code)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
async def verify_winner(session, verification_code, lottery_id):
|
||||
"""An atomic conditional update prevents two cashiers claiming the same prize."""
|
||||
from sqlalchemy import update, or_
|
||||
from .models import Lottery
|
||||
if not await session.scalar(select(Lottery.is_completed).where(Lottery.id == lottery_id)):
|
||||
return None
|
||||
user = await session.scalar(select(User).where(User.verification_code == verification_code.strip().upper()))
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Находим выигрыш этого пользователя в данном розыгрыше
|
||||
winner_result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.user_id == user.id,
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.is_claimed == False
|
||||
)
|
||||
)
|
||||
)
|
||||
winner = winner_result.scalar_one_or_none()
|
||||
|
||||
if not winner:
|
||||
# Проверяем, может быть выигрыш по счету
|
||||
# Получаем все счета пользователя
|
||||
accounts_result = await session.execute(
|
||||
select(Account).where(Account.owner_id == user.id)
|
||||
)
|
||||
accounts = accounts_result.scalars().all()
|
||||
account_numbers = [acc.account_number for acc in accounts]
|
||||
|
||||
# Ищем выигрыш по любому из счетов
|
||||
winner_result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.account_number.in_(account_numbers),
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.is_claimed == False
|
||||
)
|
||||
)
|
||||
)
|
||||
winner = winner_result.scalar_one_or_none()
|
||||
|
||||
if not winner:
|
||||
accounts = select(Account.account_number).where(Account.owner_id == user.id)
|
||||
# A user may win on several accounts. Claim one specific, ordered prize per call.
|
||||
winner_id = await session.scalar(select(Winner.id).where(
|
||||
Winner.lottery_id == lottery_id, Winner.is_claimed.is_(False),
|
||||
or_(Winner.user_id == user.id, and_(Winner.user_id.is_(None), Winner.account_number.in_(accounts)))
|
||||
).order_by(Winner.place).limit(1))
|
||||
if winner_id is None:
|
||||
return None
|
||||
|
||||
# Помечаем как подтвержденный
|
||||
winner.is_claimed = True
|
||||
|
||||
# Обновляем верификацию если есть
|
||||
verification_result = await session.execute(
|
||||
select(WinnerVerification).where(WinnerVerification.winner_id == winner.id)
|
||||
)
|
||||
verification = verification_result.scalar_one_or_none()
|
||||
|
||||
if verification:
|
||||
verification.is_verified = True
|
||||
verification.verified_at = datetime.now(timezone.utc)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
changed = await session.execute(update(Winner).where(Winner.id == winner_id, Winner.is_claimed.is_(False))
|
||||
.values(is_claimed=True, claimed_at=now))
|
||||
if changed.rowcount != 1:
|
||||
await session.rollback()
|
||||
return None
|
||||
await session.execute(update(WinnerVerification).where(WinnerVerification.winner_id == winner_id)
|
||||
.values(is_verified=True, verified_at=now))
|
||||
await session.commit()
|
||||
await session.refresh(winner)
|
||||
|
||||
return winner
|
||||
return await session.get(Winner, winner_id)
|
||||
|
||||
@staticmethod
|
||||
async def get_unverified_winners(
|
||||
|
||||
@@ -11,39 +11,22 @@ class UserService:
|
||||
"""Сервис для работы с пользователями"""
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_user(session: AsyncSession, telegram_id: int,
|
||||
username: str = None, first_name: str = None,
|
||||
last_name: str = None, nickname: str = None) -> User:
|
||||
"""Получить или создать пользователя"""
|
||||
# Пробуем найти существующего пользователя
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
# Обновляем информацию о пользователе
|
||||
user.username = username
|
||||
user.first_name = first_name
|
||||
user.last_name = last_name
|
||||
# Обновляем nickname только если он передан
|
||||
if nickname is not None:
|
||||
user.nickname = nickname
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
# Создаем нового пользователя
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
nickname=nickname
|
||||
)
|
||||
session.add(user)
|
||||
async def get_or_create_user(session, telegram_id, username=None, first_name=None, last_name=None, nickname=None):
|
||||
"""Upsert without racing or erasing metadata when only an ID is supplied."""
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert
|
||||
values = {key: value for key, value in dict(username=username, first_name=first_name,
|
||||
last_name=last_name, nickname=nickname).items() if value is not None}
|
||||
stmt = insert(User).values(telegram_id=telegram_id, **values)
|
||||
if values:
|
||||
stmt = stmt.on_conflict_do_update(index_elements=[User.telegram_id], set_=values)
|
||||
else:
|
||||
stmt = stmt.on_conflict_do_nothing(index_elements=[User.telegram_id])
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
return (await session.scalars(select(User).options(selectinload(User.accounts))
|
||||
.where(User.telegram_id == telegram_id).execution_options(populate_existing=True))).one()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
@@ -101,6 +84,14 @@ class UserService:
|
||||
if not user:
|
||||
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
|
||||
for table in Base.metadata.sorted_tables:
|
||||
for foreign_key in table.foreign_keys:
|
||||
if foreign_key.column.table.name == "users":
|
||||
if await session.scalar(select(table.c.id).where(foreign_key.parent == user_id).limit(1)):
|
||||
return False
|
||||
# Удаляем все участия
|
||||
await session.execute(
|
||||
delete(Participation).where(Participation.user_id == user_id)
|
||||
@@ -128,72 +119,41 @@ class UserService:
|
||||
return result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
|
||||
"""Установить номер клиентского счета пользователю"""
|
||||
# Валидируем и форматируем номер
|
||||
formatted_number = format_account_number(account_number)
|
||||
if not formatted_number:
|
||||
async def set_account_number(session, telegram_id, account_number):
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
formatted = format_account_number(account_number)
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
if not formatted or not user:
|
||||
return False
|
||||
|
||||
# Проверяем уникальность номера
|
||||
existing = await session.execute(
|
||||
select(User).where(User.account_number == formatted_number)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False # Номер уже занят
|
||||
|
||||
# Обновляем пользователя
|
||||
result = await session.execute(
|
||||
update(User)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
.values(account_number=formatted_number)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
if await session.scalar(select(Account.id).where(Account.account_number == formatted)):
|
||||
return False
|
||||
session.add(Account(account_number=formatted, owner_id=user.id))
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
|
||||
"""Получить пользователя по номеру счета"""
|
||||
formatted_number = format_account_number(account_number)
|
||||
if not formatted_number:
|
||||
async def get_user_by_account(session, account_number):
|
||||
formatted = format_account_number(account_number)
|
||||
if not formatted:
|
||||
return None
|
||||
return await session.scalar(select(User).join(Account).options(selectinload(User.accounts)).where(
|
||||
Account.account_number == formatted, Account.is_active.is_(True)))
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
|
||||
"""
|
||||
Получить пользователя по номеру клубной карты
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
club_card_number: Номер клубной карты (4 цифры)
|
||||
|
||||
Returns:
|
||||
User или None если не найден
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.account_number == formatted_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
async def get_user_by_club_card(session, club_card_number):
|
||||
return await session.scalar(select(User).where(User.club_card_number == club_card_number))
|
||||
|
||||
@staticmethod
|
||||
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
|
||||
"""Поиск пользователей по части номера счета"""
|
||||
# Убираем все кроме цифр и дефисов
|
||||
clean_pattern = ''.join(c for c in account_pattern if c.isdigit() or c == '-')
|
||||
if not clean_pattern:
|
||||
async def search_by_account(session, account_pattern):
|
||||
clean = ''.join(c for c in account_pattern if c in '0123456789-')
|
||||
if not clean:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.account_number.like(f'%{clean_pattern}%')
|
||||
).limit(20)
|
||||
)
|
||||
return result.scalars().all()
|
||||
return list((await session.scalars(select(User).join(Account).distinct()
|
||||
.where(Account.account_number.contains(clean, autoescape=True)).limit(20))).all())
|
||||
|
||||
|
||||
class LotteryService:
|
||||
@@ -221,6 +181,7 @@ class LotteryService:
|
||||
select(Lottery)
|
||||
.options(selectinload(Lottery.participations).selectinload(Participation.user))
|
||||
.where(Lottery.id == lottery_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -245,14 +206,16 @@ class LotteryService:
|
||||
**updates
|
||||
) -> bool:
|
||||
"""Обновить данные розыгрыша"""
|
||||
if set(updates) - {"title", "description", "prizes", "start_date", "end_date", "is_active", "winner_display_type"}:
|
||||
return False
|
||||
try:
|
||||
await session.execute(
|
||||
result = await session.execute(
|
||||
update(Lottery)
|
||||
.where(Lottery.id == lottery_id)
|
||||
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
||||
.values(**updates)
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
return result.rowcount > 0
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
return False
|
||||
@@ -269,126 +232,90 @@ class LotteryService:
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def set_manual_winner(session: AsyncSession, lottery_id: int,
|
||||
place: int, telegram_id: int) -> bool:
|
||||
"""Установить ручного победителя для определенного места"""
|
||||
# Получаем пользователя
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
if not user:
|
||||
async def set_manual_winner(session, lottery_id, place, telegram_id):
|
||||
from .transactions import lock_open_lottery
|
||||
if not await lock_open_lottery(session, lottery_id):
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
# Получаем розыгрыш
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
if not user or place < 1 or place > len(lottery.prizes or [None]):
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
# Обновляем ручных победителей
|
||||
if not lottery.manual_winners:
|
||||
lottery.manual_winners = {}
|
||||
|
||||
lottery.manual_winners[str(place)] = telegram_id
|
||||
if not any(p.user_id == user.id for p in lottery.participations):
|
||||
await session.rollback()
|
||||
return False
|
||||
# Reassign JSON: in-place edits are not tracked by a plain JSON column.
|
||||
winners = dict(lottery.manual_winners or {})
|
||||
if telegram_id in (value for key, value in winners.items() if key != str(place)):
|
||||
await session.rollback()
|
||||
return False
|
||||
winners[str(place)] = telegram_id
|
||||
lottery.manual_winners = winners
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def conduct_draw(session: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]:
|
||||
"""Провести розыгрыш с учетом ручных победителей"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"conduct_draw: начало для lottery_id={lottery_id}")
|
||||
async def conduct_draw(session, lottery_id):
|
||||
"""Select and persist winners once, in one transaction, before notifications."""
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime, timezone
|
||||
from .transactions import lock_open_lottery
|
||||
from .models import WinnerVerification
|
||||
if not await lock_open_lottery(session, lottery_id):
|
||||
await session.rollback()
|
||||
return {}
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery or lottery.is_completed:
|
||||
logger.warning(f"conduct_draw: lottery не найден или завершён")
|
||||
participations = list(lottery.participations)
|
||||
if not participations:
|
||||
await session.rollback()
|
||||
return {}
|
||||
|
||||
logger.info(f"conduct_draw: получаем участников")
|
||||
# Получаем всех участников (включая тех, у кого нет user)
|
||||
participants = []
|
||||
for p in lottery.participations:
|
||||
if p.user:
|
||||
participants.append(p.user)
|
||||
else:
|
||||
# Создаем временный объект для участников без пользователя
|
||||
# Храним только номер счета
|
||||
participants.append(type('obj', (object,), {
|
||||
'id': None,
|
||||
'telegram_id': None,
|
||||
'account_number': p.account_number
|
||||
})())
|
||||
|
||||
logger.info(f"conduct_draw: участников {len(participants)}")
|
||||
if not participants:
|
||||
logger.warning(f"conduct_draw: нет участников")
|
||||
return {}
|
||||
|
||||
# Определяем количество призовых мест
|
||||
num_prizes = len(lottery.prizes) if lottery.prizes else 1
|
||||
|
||||
results = {}
|
||||
remaining_participants = participants.copy()
|
||||
manual_winners = lottery.manual_winners or {}
|
||||
|
||||
# Сначала обрабатываем ручных победителей
|
||||
num_prizes = len(lottery.prizes or [None])
|
||||
existing = list((await session.scalars(select(Winner).where(Winner.lottery_id == lottery_id))).all())
|
||||
if any(w.is_claimed for w in existing):
|
||||
raise ValueError("В розыгрыше уже есть выданные призы")
|
||||
selected = {}
|
||||
available = participations.copy()
|
||||
for place in range(1, num_prizes + 1):
|
||||
place_str = str(place)
|
||||
if place_str in manual_winners:
|
||||
# Находим пользователя среди участников
|
||||
manual_winner = None
|
||||
for participant in remaining_participants:
|
||||
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
|
||||
manual_winner = participant
|
||||
break
|
||||
|
||||
if manual_winner:
|
||||
results[place] = {
|
||||
'user': manual_winner,
|
||||
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
|
||||
'is_manual': True
|
||||
}
|
||||
remaining_participants.remove(manual_winner)
|
||||
|
||||
# Заполняем оставшиеся места случайными участниками
|
||||
preset = next((w for w in existing if w.place == place and w.is_manual), None)
|
||||
manual = (lottery.manual_winners or {}).get(str(place))
|
||||
candidate = next((p for p in available if
|
||||
(preset and preset.account_number and p.account_number == preset.account_number) or
|
||||
(manual and p.user and p.user.telegram_id == manual)), None)
|
||||
if candidate:
|
||||
selected[place] = (candidate, True)
|
||||
available.remove(candidate)
|
||||
rng = random.SystemRandom()
|
||||
for place in range(1, num_prizes + 1):
|
||||
if place not in results and remaining_participants:
|
||||
winner = random.choice(remaining_participants)
|
||||
results[place] = {
|
||||
'user': winner,
|
||||
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
|
||||
'is_manual': False
|
||||
}
|
||||
remaining_participants.remove(winner)
|
||||
|
||||
# Сохраняем победителей в базу данных
|
||||
for place, winner_info in results.items():
|
||||
user_obj = winner_info['user']
|
||||
winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
|
||||
account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None,
|
||||
place=place,
|
||||
prize=winner_info['prize'],
|
||||
is_manual=winner_info['is_manual']
|
||||
)
|
||||
session.add(winner)
|
||||
|
||||
# Обновляем статус розыгрыша
|
||||
logger.info(f"conduct_draw: обновляем статус lottery")
|
||||
if place not in selected and available:
|
||||
candidate = rng.choice(available)
|
||||
available.remove(candidate)
|
||||
selected[place] = (candidate, False)
|
||||
if existing:
|
||||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_([w.id for w in existing])))
|
||||
await session.execute(delete(Winner).where(Winner.lottery_id == lottery_id))
|
||||
results, stored = {}, {}
|
||||
for place, (participation, manual) in sorted(selected.items()):
|
||||
owner = participation.user
|
||||
# Preserve the selected ticket, including for users with multiple accounts.
|
||||
actor = SimpleNamespace(id=participation.user_id,
|
||||
telegram_id=owner.telegram_id if owner else None,
|
||||
username=owner.username if owner else None,
|
||||
first_name=owner.first_name if owner else None,
|
||||
nickname=owner.nickname if owner else None,
|
||||
account_number=participation.account_number)
|
||||
prize = lottery.prizes[place - 1] if lottery.prizes else f"Приз {place} места"
|
||||
session.add(Winner(lottery_id=lottery_id, user_id=actor.id, account_number=actor.account_number,
|
||||
place=place, prize=prize, is_manual=manual))
|
||||
results[place] = dict(user=actor, prize=prize, is_manual=manual)
|
||||
stored[str(place)] = dict(user_id=actor.id, telegram_id=actor.telegram_id,
|
||||
username=actor.username, account_number=actor.account_number,
|
||||
prize=prize, is_manual=manual)
|
||||
lottery.is_completed = True
|
||||
lottery.draw_results = {}
|
||||
for place, info in results.items():
|
||||
user_obj = info['user']
|
||||
lottery.draw_results[str(place)] = {
|
||||
'user_id': user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
|
||||
'telegram_id': user_obj.telegram_id if hasattr(user_obj, 'telegram_id') else None,
|
||||
'username': user_obj.username if hasattr(user_obj, 'username') else None,
|
||||
'account_number': user_obj.account_number if hasattr(user_obj, 'account_number') else None,
|
||||
'prize': info['prize'],
|
||||
'is_manual': info['is_manual']
|
||||
}
|
||||
|
||||
# НЕ коммитим здесь - это должно сделать вызывающая функция
|
||||
logger.info(f"conduct_draw: изменения подготовлены, победителей: {len(results)}")
|
||||
lottery.is_active = False
|
||||
lottery.end_date = datetime.now(timezone.utc)
|
||||
lottery.draw_results = stored
|
||||
await session.commit()
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
@@ -423,7 +350,7 @@ class LotteryService:
|
||||
"""Установить статус активности розыгрыша"""
|
||||
result = await session.execute(
|
||||
update(Lottery)
|
||||
.where(Lottery.id == lottery_id)
|
||||
.where(Lottery.id == lottery_id, Lottery.is_completed.is_(False))
|
||||
.values(is_active=is_active)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -445,6 +372,9 @@ class LotteryService:
|
||||
@staticmethod
|
||||
async def delete_lottery(session: AsyncSession, lottery_id: int) -> bool:
|
||||
"""Удалить розыгрыш и все связанные данные"""
|
||||
from .models import WinnerVerification
|
||||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id.in_(
|
||||
select(Winner.id).where(Winner.lottery_id == lottery_id))))
|
||||
# Сначала удаляем все связанные данные
|
||||
# Удаляем победителей
|
||||
await session.execute(
|
||||
@@ -469,36 +399,31 @@ class ParticipationService:
|
||||
"""Сервис для работы с участием в розыгрышах"""
|
||||
|
||||
@staticmethod
|
||||
async def add_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
|
||||
"""Добавить участника в розыгрыш"""
|
||||
# Проверяем, не участвует ли уже пользователь
|
||||
existing = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
async def add_participant(session, lottery_id, user_id):
|
||||
from .transactions import lock_open_lottery
|
||||
if not await lock_open_lottery(session, lottery_id):
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
participation = Participation(lottery_id=lottery_id, user_id=user_id)
|
||||
session.add(participation)
|
||||
exists = await session.scalar(select(Participation.id).where(
|
||||
Participation.lottery_id == lottery_id, Participation.user_id == user_id).limit(1))
|
||||
user = await session.get(User, user_id)
|
||||
if exists or not user:
|
||||
await session.rollback()
|
||||
return False
|
||||
session.add(Participation(lottery_id=lottery_id, user_id=user_id))
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def remove_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
|
||||
"""Удалить участника из розыгрыша"""
|
||||
participation = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
|
||||
)
|
||||
participation = participation.scalar_one_or_none()
|
||||
|
||||
if not participation:
|
||||
async def remove_participant(session, lottery_id, user_id):
|
||||
from .transactions import lock_open_lottery
|
||||
if not await lock_open_lottery(session, lottery_id):
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
await session.delete(participation)
|
||||
result = await session.execute(delete(Participation).where(
|
||||
Participation.lottery_id == lottery_id, Participation.user_id == user_id))
|
||||
await session.commit()
|
||||
return True
|
||||
return result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
||||
@@ -523,13 +448,9 @@ class ParticipationService:
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
|
||||
"""Получить количество участников в розыгрыше"""
|
||||
result = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.lottery_id == lottery_id)
|
||||
)
|
||||
return len(result.scalars().all())
|
||||
async def get_participants_count(session, lottery_id):
|
||||
from sqlalchemy import func
|
||||
return await session.scalar(select(func.count()).select_from(Participation).where(Participation.lottery_id == lottery_id))
|
||||
|
||||
@staticmethod
|
||||
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
|
||||
@@ -593,190 +514,24 @@ class ParticipationService:
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
||||
"""Массовое добавление участников по номерам счетов"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
results = {
|
||||
"added": 0,
|
||||
"skipped": 0,
|
||||
"errors": [],
|
||||
"details": [],
|
||||
"invalid_accounts": []
|
||||
}
|
||||
|
||||
for account_input in account_numbers:
|
||||
account_input = account_input.strip()
|
||||
if not account_input:
|
||||
continue
|
||||
|
||||
logger.info(f"DEBUG: Processing account_input={account_input!r}")
|
||||
|
||||
try:
|
||||
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
||||
parts = account_input.split()
|
||||
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
|
||||
|
||||
if len(parts) == 2:
|
||||
card_number = parts[0] # Номер клубной карты
|
||||
account_number = parts[1] # Номер счета
|
||||
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
|
||||
elif len(parts) == 1:
|
||||
# Если нет пробела, считаем что это просто номер счета
|
||||
card_number = None
|
||||
account_number = parts[0]
|
||||
logger.info(f"DEBUG: 1 part - account={account_number!r}")
|
||||
else:
|
||||
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат: {account_input}")
|
||||
continue
|
||||
|
||||
# Валидируем и форматируем номер счета
|
||||
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
|
||||
formatted_account = format_account_number(account_number)
|
||||
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
|
||||
|
||||
if not formatted_account:
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
|
||||
logger.error(f"DEBUG: Format failed for {account_number!r}")
|
||||
continue
|
||||
|
||||
# Ищем владельца счёта через таблицу Account
|
||||
from ..core.registration_services import AccountService
|
||||
user = await AccountService.get_account_owner(session, formatted_account)
|
||||
if not user:
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
results["errors"].append(f"Пользователь с счётом {formatted_account}{card_info} не найден")
|
||||
continue
|
||||
|
||||
# Получаем запись Account для этого счета
|
||||
account_record = await session.execute(
|
||||
select(Account).where(Account.account_number == formatted_account)
|
||||
)
|
||||
account_record = account_record.scalar_one_or_none()
|
||||
|
||||
if not account_record:
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
results["errors"].append(f"Запись счета {formatted_account}{card_info} не найдена в базе")
|
||||
continue
|
||||
|
||||
# Проверяем, не участвует ли уже этот счет
|
||||
existing = await session.execute(
|
||||
select(Participation).where(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_number == formatted_account
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
results["skipped"] += 1
|
||||
detail = f"{user.first_name} ({formatted_account})"
|
||||
if card_number:
|
||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||
results["details"].append(f"Уже участвует: {detail}")
|
||||
continue
|
||||
|
||||
# Добавляем участие по счету
|
||||
participation = Participation(
|
||||
lottery_id=lottery_id,
|
||||
user_id=user.id,
|
||||
account_id=account_record.id,
|
||||
account_number=formatted_account
|
||||
)
|
||||
session.add(participation)
|
||||
await session.commit()
|
||||
|
||||
results["added"] += 1
|
||||
detail = f"{user.first_name} ({formatted_account})"
|
||||
if card_number:
|
||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||
results["details"].append(detail)
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
||||
|
||||
return results
|
||||
async def add_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
||||
from src.handlers.account_services import AccountParticipationService
|
||||
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, account_numbers)
|
||||
result['invalid_accounts'] = [a for a in account_numbers if not a.split() or not format_account_number(a.split()[-1])]
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def remove_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
||||
"""Массовое удаление участников по номерам счетов"""
|
||||
results = {
|
||||
"removed": 0,
|
||||
"not_found": 0,
|
||||
"errors": [],
|
||||
"details": [],
|
||||
"invalid_accounts": []
|
||||
}
|
||||
|
||||
for account_input in account_numbers:
|
||||
account_input = account_input.strip()
|
||||
if not account_input:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
||||
parts = account_input.split()
|
||||
if len(parts) == 2:
|
||||
card_number = parts[0] # Номер клубной карты
|
||||
account_number = parts[1] # Номер счета
|
||||
elif len(parts) == 1:
|
||||
# Если нет пробела, считаем что это просто номер счета
|
||||
card_number = None
|
||||
account_number = parts[0]
|
||||
else:
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат: {account_input}")
|
||||
continue
|
||||
|
||||
# Валидируем и форматируем номер счета
|
||||
formatted_account = format_account_number(account_number)
|
||||
if not formatted_account:
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
|
||||
continue
|
||||
|
||||
# Ищем владельца счёта через таблицу Account
|
||||
from ..core.registration_services import AccountService
|
||||
user = await AccountService.get_account_owner(session, formatted_account)
|
||||
if not user:
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
results["not_found"] += 1
|
||||
results["details"].append(f"Не найден: {formatted_account}{card_info}")
|
||||
continue
|
||||
|
||||
# Ищем участие по номеру счета (не по user_id!)
|
||||
participation = await session.execute(
|
||||
select(Participation).where(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_number == formatted_account
|
||||
)
|
||||
)
|
||||
participation = participation.scalar_one_or_none()
|
||||
|
||||
if participation:
|
||||
await session.delete(participation)
|
||||
await session.commit()
|
||||
|
||||
results["removed"] += 1
|
||||
detail = f"{user.first_name} ({formatted_account})"
|
||||
if card_number:
|
||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||
results["details"].append(detail)
|
||||
else:
|
||||
results["not_found"] += 1
|
||||
detail = f"{user.first_name} ({formatted_account})"
|
||||
if card_number:
|
||||
detail = f"{user.first_name} (карта: {card_number}, счёт: {formatted_account})"
|
||||
results["details"].append(f"Не участвовал: {detail}")
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Ошибка с {account_input}: {str(e)}")
|
||||
|
||||
return results
|
||||
async def remove_participants_by_accounts_bulk(session, lottery_id, account_numbers):
|
||||
from src.handlers.account_services import AccountParticipationService
|
||||
result = dict(removed=0, not_found=0, errors=[], details=[], invalid_accounts=[])
|
||||
for account in account_numbers:
|
||||
value = account.split()[-1] if account.split() else ''
|
||||
item = await AccountParticipationService.remove_account_from_lottery(session, lottery_id, value)
|
||||
result['removed' if item['success'] else 'not_found'] += 1
|
||||
result['details'].append(item['message'])
|
||||
if not item['success']:
|
||||
result['errors'].append(item['message'])
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
|
||||
@@ -804,4 +559,4 @@ class ParticipationService:
|
||||
"participations_count": participations_count,
|
||||
"wins_count": wins_count,
|
||||
"last_participation": last_participation.created_at if last_participation else None
|
||||
}
|
||||
}
|
||||
|
||||
15
src/core/transactions.py
Normal file
15
src/core/transactions.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Serialize mutations of one draw; unrelated users/draws remain concurrent."""
|
||||
from sqlalchemy import update
|
||||
|
||||
from .models import Lottery
|
||||
|
||||
|
||||
async def lock_open_lottery(session, lottery_id: int) -> bool:
|
||||
# UPDATE also serializes writers on SQLite, where FOR UPDATE is ignored.
|
||||
result = await session.execute(
|
||||
update(Lottery)
|
||||
.where(Lottery.id == lottery_id, Lottery.is_active.is_(True), Lottery.is_completed.is_(False))
|
||||
.values(is_active=Lottery.is_active)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
return result.rowcount == 1
|
||||
@@ -1,3 +1,4 @@
|
||||
from html import escape
|
||||
"""
|
||||
Сервис управления пользователями с поиском и пагинацией
|
||||
"""
|
||||
@@ -159,6 +160,9 @@ class UserManagementService:
|
||||
return False
|
||||
|
||||
user.is_chat_banned = False
|
||||
from .models import BannedUser
|
||||
from sqlalchemy import update
|
||||
await session.execute(update(BannedUser).where(BannedUser.telegram_id == user.telegram_id).values(is_active=False))
|
||||
await session.commit()
|
||||
logger.info(f"Пользователь {user.telegram_id} разблокирован в чате")
|
||||
return True
|
||||
@@ -215,13 +219,13 @@ class UserManagementService:
|
||||
str: Форматированная информация
|
||||
"""
|
||||
# Базовая информация
|
||||
info = f"👤 <b>{user.first_name}"
|
||||
info = f"👤 <b>{escape(user.first_name or '')}"
|
||||
if user.last_name:
|
||||
info += f" {user.last_name}"
|
||||
info += f" {escape(user.last_name or '')}"
|
||||
info += "</b>"
|
||||
|
||||
if user.username:
|
||||
info += f" (@{user.username})"
|
||||
info += f" (@{escape(user.username or '')})"
|
||||
|
||||
info += f"\n🆔 ID: <code>{user.telegram_id}</code>"
|
||||
|
||||
@@ -240,16 +244,19 @@ class UserManagementService:
|
||||
# Детальная информация
|
||||
if detailed:
|
||||
if user.nickname:
|
||||
info += f"\n📝 Никнейм: {user.nickname}"
|
||||
info += f"\n📝 Никнейм: {escape(user.nickname or '')}"
|
||||
if user.club_card_number:
|
||||
info += f"\n🎫 Клубная карта: <code>{user.club_card_number}</code>"
|
||||
info += f"\n🎫 Клубная карта: <code>{escape(user.club_card_number or '')}</code>"
|
||||
if user.phone:
|
||||
info += f"\n📞 Телефон: <code>{user.phone}</code>"
|
||||
info += f"\n📞 Телефон: <code>{escape(user.phone or '')}</code>"
|
||||
|
||||
# Даты
|
||||
info += f"\n📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}"
|
||||
if user.last_activity:
|
||||
days_inactive = (datetime.now(timezone.utc) - user.last_activity).days
|
||||
last_activity = user.last_activity
|
||||
if last_activity.tzinfo is None:
|
||||
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
||||
days_inactive = (datetime.now(timezone.utc) - last_activity).days
|
||||
info += f"\n⏰ Последняя активность: {user.last_activity.strftime('%d.%m.%Y %H:%M')}"
|
||||
if days_inactive > 0:
|
||||
info += f" ({days_inactive} дн. назад)"
|
||||
|
||||
@@ -36,7 +36,7 @@ async def conduct_lottery_draw(lottery_id: int):
|
||||
# Выводим список участников
|
||||
print("\n👥 Список участников:")
|
||||
for i, user in enumerate(participants, 1):
|
||||
accounts = user.account_number.split(',') if user.account_number else ['Нет счетов']
|
||||
accounts = [p.account_number for p in lottery.participations if p.user_id == user.id and p.account_number]
|
||||
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)")
|
||||
|
||||
# Проводим розыгрыш
|
||||
@@ -48,14 +48,13 @@ async def conduct_lottery_draw(lottery_id: int):
|
||||
|
||||
if winners:
|
||||
print(f"\n🎉 Победители определены:")
|
||||
for i, winner_data in enumerate(winners, 1):
|
||||
for i, winner_data in winners.items():
|
||||
user = winner_data['user']
|
||||
prize = winner_data['prize']
|
||||
print(f" 🏆 {i} место: {user.first_name} (@{user.username})")
|
||||
print(f" 💎 Приз: {prize}")
|
||||
|
||||
# Обновляем статус розыгрыша
|
||||
await LotteryService.set_lottery_completed(session, lottery_id, True)
|
||||
print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
|
||||
|
||||
else:
|
||||
@@ -99,4 +98,4 @@ async def main():
|
||||
print("❌ Неверный ID розыгрыша")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
import asyncio
|
||||
from ..core.database import async_session_maker, init_db
|
||||
from ..core.services import UserService, LotteryService
|
||||
from ..core.services import UserService, LotteryService, ParticipationService
|
||||
from ..utils.admin_utils import AdminUtils, ReportGenerator
|
||||
|
||||
|
||||
@@ -87,17 +87,17 @@ async def demo_admin_features():
|
||||
participants_added = 0
|
||||
for user in users:
|
||||
# В первый розыгрыш добавляем всех
|
||||
if await LotteryService.add_participant(session, lottery1.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery1.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
# Во второй - половину
|
||||
if user.id % 2 == 0:
|
||||
if await LotteryService.add_participant(session, lottery2.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery2.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
# В третий - треть
|
||||
if user.id % 3 == 0:
|
||||
if await LotteryService.add_participant(session, lottery3.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery3.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
print(f"✅ Добавлено {participants_added} участий")
|
||||
@@ -188,4 +188,4 @@ async def demo_admin_features():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(demo_admin_features())
|
||||
asyncio.run(demo_admin_features())
|
||||
|
||||
@@ -33,15 +33,16 @@ def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: boo
|
||||
|
||||
elif display_type == 'account_number':
|
||||
# Отображаем номер клиентского счета
|
||||
if not user.account_number:
|
||||
account_number = getattr(user, "account_number", None)
|
||||
if not account_number:
|
||||
return "Счёт не указан"
|
||||
|
||||
if show_sensitive_data:
|
||||
# Для админов показываем полный номер
|
||||
return f"Счёт: {user.account_number}"
|
||||
return f"Счёт: {account_number}"
|
||||
else:
|
||||
# Для публичного показа маскируем номер
|
||||
masked = mask_account_number(user.account_number, show_last_digits=4)
|
||||
masked = mask_account_number(account_number, show_last_digits=4)
|
||||
return f"Счёт: {masked}"
|
||||
|
||||
else:
|
||||
@@ -113,4 +114,4 @@ def validate_display_type(display_type: str) -> bool:
|
||||
Returns:
|
||||
bool: True если тип корректен
|
||||
"""
|
||||
return display_type in ['username', 'chat_id', 'account_number']
|
||||
return display_type in ['username', 'chat_id', 'account_number']
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Обработчики для работы со счетами в розыгрышах
|
||||
"""
|
||||
from src.core.access import is_admin
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import StateFilter
|
||||
@@ -28,9 +29,6 @@ class AccountStates(StatesGroup):
|
||||
account_router = Router()
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@account_router.message(
|
||||
@@ -44,7 +42,7 @@ async def detect_account_input(message: Message, state: FSMContext):
|
||||
Активируется только для администраторов
|
||||
Извлекает номер клубной карты и определяет владельца
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
return
|
||||
|
||||
# Парсим счета из сообщения
|
||||
@@ -156,7 +154,7 @@ async def cancel_account_action(callback: CallbackQuery, state: FSMContext):
|
||||
@account_router.callback_query(F.data == "account_action:add_to_lottery")
|
||||
async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор розыгрыша для добавления счетов"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -200,7 +198,7 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext
|
||||
@account_router.callback_query(F.data.startswith("add_accounts_to:"))
|
||||
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавление счетов в выбранный розыгрыш"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -258,7 +256,7 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
@account_router.callback_query(F.data == "account_action:set_as_winner")
|
||||
async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор розыгрыша для установки победителя"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -319,7 +317,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
|
||||
@account_router.callback_query(F.data.startswith("winner_lottery:"))
|
||||
async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор места для победителя"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -382,7 +380,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
|
||||
@account_router.callback_query(F.data.startswith("winner_place:"))
|
||||
async def set_account_winner(callback: CallbackQuery, state: FSMContext):
|
||||
"""Установка счета как победителя"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("⛔ Доступно только администраторам", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select, delete, func
|
||||
from ..core.models import Lottery, Participation, Winner
|
||||
from ..utils.account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..core.transactions import lock_open_lottery
|
||||
|
||||
|
||||
class AccountParticipationService:
|
||||
@@ -49,6 +50,9 @@ class AccountParticipationService:
|
||||
"account_number": account_number
|
||||
}
|
||||
|
||||
if not await lock_open_lottery(session, lottery_id):
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Розыгрыш закрыт или не найден", "account_number": formatted_account}
|
||||
# Проверяем существование розыгрыша
|
||||
lottery = await session.get(Lottery, lottery_id)
|
||||
if not lottery:
|
||||
@@ -66,6 +70,7 @@ class AccountParticipationService:
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
await session.rollback()
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
return {
|
||||
"success": False,
|
||||
@@ -82,6 +87,12 @@ class AccountParticipationService:
|
||||
select(Account).where(Account.account_number == formatted_account)
|
||||
)
|
||||
account_record = account_record.scalar_one_or_none()
|
||||
if account_record and not account_record.is_active:
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Счет неактивен", "account_number": formatted_account}
|
||||
if card_number and (not user or user.club_card_number != card_number):
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Карта не соответствует владельцу счета", "account_number": formatted_account}
|
||||
|
||||
# Добавляем участие с полными данными
|
||||
participation = Participation(
|
||||
@@ -149,6 +160,9 @@ class AccountParticipationService:
|
||||
"message": f"Неверный формат счета: {account_number}"
|
||||
}
|
||||
|
||||
if not await lock_open_lottery(session, lottery_id):
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Розыгрыш закрыт или не найден"}
|
||||
participation = await session.execute(
|
||||
select(Participation).where(
|
||||
Participation.lottery_id == lottery_id,
|
||||
@@ -158,6 +172,7 @@ class AccountParticipationService:
|
||||
participation = participation.scalar_one_or_none()
|
||||
|
||||
if not participation:
|
||||
await session.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Счет {formatted_account} не участвует в розыгрыше"
|
||||
@@ -236,6 +251,13 @@ class AccountParticipationService:
|
||||
Устанавливает счет как победителя в розыгрыше.
|
||||
Поддерживает формат: "КАРТА СЧЕТ" или просто "СЧЕТ"
|
||||
"""
|
||||
if not await lock_open_lottery(session, lottery_id):
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Розыгрыш закрыт или не найден"}
|
||||
lottery = await session.get(Lottery, lottery_id)
|
||||
if place < 1 or place > len(lottery.prizes or [None]):
|
||||
await session.rollback()
|
||||
return {"success": False, "message": "Некорректное призовое место"}
|
||||
# Разделяем номер карты и счета, если они указаны вместе
|
||||
card_number = None
|
||||
parts = account_number.split()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Админские обработчики для управления счетами и верификации"""
|
||||
from src.utils.errors import public_error
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command
|
||||
@@ -12,7 +13,7 @@ from src.core.registration_services import AccountService, WinnerNotificationSer
|
||||
from src.core.services import UserService, LotteryService, ParticipationService
|
||||
from src.core.models import User, Winner, Account, Participation
|
||||
from src.core.config import ADMIN_IDS
|
||||
from src.core.permissions import admin_only
|
||||
from src.core.access import staff_only
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -24,7 +25,7 @@ class AddAccountStates(StatesGroup):
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("cancel"))
|
||||
@admin_only
|
||||
@staff_only
|
||||
async def cancel_command(message: Message, state: FSMContext):
|
||||
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
|
||||
await state.clear()
|
||||
@@ -32,7 +33,7 @@ async def cancel_command(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("add_account"))
|
||||
@admin_only
|
||||
@staff_only
|
||||
async def add_account_command(message: Message, state: FSMContext):
|
||||
"""
|
||||
Добавить счет пользователю по клубной карте (регистронезависимо)
|
||||
@@ -107,7 +108,7 @@ async def process_single_account(message: Message, club_card: str, account_numbe
|
||||
)
|
||||
text += "📨 Владельцу отправлено уведомление\n\n"
|
||||
except Exception as e:
|
||||
text += f"⚠️ Не удалось отправить уведомление: {str(e)}\n\n"
|
||||
text += f"⚠️ Не удалось отправить уведомление: {public_error(e)}\n\n"
|
||||
|
||||
# Предлагаем добавить в розыгрыш
|
||||
await show_lottery_selection(message, text, state)
|
||||
@@ -116,11 +117,11 @@ async def process_single_account(message: Message, club_card: str, account_numbe
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
||||
await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AddAccountStates.waiting_for_data)
|
||||
@router.message(AddAccountStates.waiting_for_data, F.text)
|
||||
async def process_accounts_data(message: Message, state: FSMContext):
|
||||
"""Обработка данных счетов (один или несколько)"""
|
||||
if message.text.strip().lower() == '/cancel':
|
||||
@@ -220,7 +221,7 @@ async def process_accounts_data(message: Message, state: FSMContext):
|
||||
except ValueError as e:
|
||||
errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}")
|
||||
except Exception as e:
|
||||
errors.append(f"Счет {account_number}: {str(e)}")
|
||||
errors.append(f"Счет {account_number}: {public_error(e)}")
|
||||
|
||||
i += 1
|
||||
|
||||
@@ -383,36 +384,10 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
for acc in accounts:
|
||||
try:
|
||||
# Добавляем участие через account_id
|
||||
# Проверяем, не участвует ли уже
|
||||
existing = await session.execute(
|
||||
select(Participation).where(
|
||||
and_(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_id == acc['account_id']
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if existing.scalar_one_or_none():
|
||||
errors.append(f"{acc['account_number']}: уже участвует")
|
||||
continue
|
||||
|
||||
# Создаем участие
|
||||
participation = Participation(
|
||||
lottery_id=lottery_id,
|
||||
account_id=acc['account_id'],
|
||||
account_number=acc['account_number']
|
||||
)
|
||||
session.add(participation)
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{acc['account_number']}: {str(e)}")
|
||||
|
||||
await session.commit()
|
||||
from .account_services import AccountParticipationService
|
||||
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts])
|
||||
success_count = result['added']
|
||||
errors = result['errors']
|
||||
|
||||
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
|
||||
|
||||
@@ -436,7 +411,7 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("remove_account"))
|
||||
@admin_only
|
||||
@staff_only
|
||||
async def remove_account_command(message: Message):
|
||||
"""
|
||||
Деактивировать счет(а) (регистронезависимо)
|
||||
@@ -502,11 +477,11 @@ async def remove_account_command(message: Message):
|
||||
await message.answer("\n\n".join(response_parts), parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Критическая ошибка: {str(e)}")
|
||||
await message.answer(f"❌ Критическая ошибка: {public_error(e)}")
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("verify_winner"))
|
||||
@admin_only
|
||||
@staff_only
|
||||
async def verify_winner_command(message: Message):
|
||||
"""
|
||||
Подтвердить выигрыш по коду верификации (регистронезависимо)
|
||||
@@ -585,7 +560,7 @@ async def verify_winner_command(message: Message):
|
||||
)
|
||||
text += "\n📨 Победителю отправлено уведомление"
|
||||
except Exception as e:
|
||||
text += f"\n⚠️ Не удалось отправить уведомление: {str(e)}"
|
||||
text += f"\n⚠️ Не удалось отправить уведомление: {public_error(e)}"
|
||||
|
||||
if winner.account_number:
|
||||
text += f"💳 Счет: {winner.account_number}\n"
|
||||
@@ -593,11 +568,11 @@ async def verify_winner_command(message: Message):
|
||||
await message.answer(text)
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("winner_status"))
|
||||
@admin_only
|
||||
@staff_only
|
||||
async def winner_status_command(message: Message):
|
||||
"""
|
||||
Показать статус всех победителей розыгрыша (регистронезависимо)
|
||||
@@ -666,11 +641,11 @@ async def winner_status_command(message: Message):
|
||||
await message.answer(text)
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("user_info"))
|
||||
@admin_only
|
||||
@staff_only
|
||||
async def user_info_command(message: Message):
|
||||
"""
|
||||
Показать информацию о пользователе (регистронезависимо)
|
||||
@@ -741,7 +716,7 @@ async def user_info_command(message: Message):
|
||||
await message.answer(text)
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "view_my_accounts")
|
||||
@@ -807,6 +782,6 @@ async def view_my_accounts_callback(callback: CallbackQuery):
|
||||
except Exception as e:
|
||||
# Не используем callback.answer в except - может быть timeout
|
||||
try:
|
||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||
except:
|
||||
pass # Игнорируем если не получилось отправить
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Админские обработчики для управления чатом"""
|
||||
from src.utils.errors import public_error
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
@@ -290,12 +291,9 @@ async def cmd_delete_message(message: Message):
|
||||
from sqlalchemy import select
|
||||
from src.core.models import ChatMessage
|
||||
|
||||
result = await session.execute(
|
||||
select(ChatMessage).where(
|
||||
ChatMessage.telegram_message_id == message.reply_to_message.message_id
|
||||
)
|
||||
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
||||
session, message.reply_to_message.message_id, chat_id=message.chat.id,
|
||||
)
|
||||
chat_message = result.scalar_one_or_none()
|
||||
|
||||
if not chat_message:
|
||||
await message.answer("❌ Сообщение не найдено в базе данных")
|
||||
@@ -317,7 +315,7 @@ async def cmd_delete_message(message: Message):
|
||||
await message.bot.delete_message(int(user_telegram_id), msg_id)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {e}")
|
||||
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {public_error(e)}")
|
||||
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение удалено</b>\n\n"
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
Хендлеры для управления кастомными эмодзи админом
|
||||
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
||||
"""
|
||||
from src.utils.errors import public_error
|
||||
import logging
|
||||
from html import escape
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command, StateFilter
|
||||
@@ -11,6 +13,7 @@ from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import async_session_maker
|
||||
from ..core.access import is_admin
|
||||
from ..core.config import ADMIN_IDS
|
||||
from ..core.emoji_mapping_service import EmojiMappingService
|
||||
|
||||
@@ -26,73 +29,36 @@ class EmojiStates(StatesGroup):
|
||||
@router.message(Command("add_emoji"), StateFilter(None))
|
||||
async def add_emoji_start(message: Message, state: FSMContext):
|
||||
"""Начать процесс добавления нового эмодзи"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"🎨 Отправьте эмодзи, который хотите зарегистрировать.\n\n"
|
||||
"Бот получит его <code>emoji_id</code> и будет использовать этот ID "
|
||||
"при отправке сообщений в чаты, чтобы эмодзи выглядел точно так же.",
|
||||
"🎨 Отправьте один премиум-эмодзи из панели Telegram.\n\n"
|
||||
"Бот сохранит его в каталоге для использования в шаблонах. "
|
||||
"В чатах и рассылках оформление сохраняется прямо из отправленного сообщения.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(EmojiStates.waiting_for_emoji)
|
||||
|
||||
|
||||
@router.message(EmojiStates.waiting_for_emoji)
|
||||
@router.message(EmojiStates.waiting_for_emoji, F.text)
|
||||
async def receive_emoji(message: Message, state: FSMContext):
|
||||
"""Получить эмодзи от админа и сохранить его emoji_id"""
|
||||
# Проверяем что это именно тект сообщение с эмодзи
|
||||
if not message.text or len(message.text) > 10:
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, отправьте просто эмодзи или маленький текст с эмодзи"
|
||||
)
|
||||
entities = [entity for entity in message.entities or () if entity.type == "custom_emoji"]
|
||||
if len(entities) != 1:
|
||||
await message.answer("Отправьте один премиум-эмодзи из панели эмодзи Telegram. Обычный символ не содержит его ID.")
|
||||
return
|
||||
|
||||
emoji_text = message.text.strip()
|
||||
|
||||
# Проверяем что хотя бы один символ это эмодзи
|
||||
has_emoji = any(ord(c) > 127 for c in emoji_text)
|
||||
if not has_emoji:
|
||||
await message.answer(
|
||||
"❌ Текст не содержит эмодзи. Пожалуйста, отправьте эмодзи"
|
||||
)
|
||||
entity = entities[0]
|
||||
emoji_text = entity.extract_from(message.text)
|
||||
emoji_id = entity.custom_emoji_id
|
||||
if message.text.strip() != emoji_text or len(emoji_text) > 10 or not emoji_id or not emoji_id.isascii() or not emoji_id.isdigit():
|
||||
await message.answer("Отправьте только один премиум-эмодзи, без дополнительного текста.")
|
||||
return
|
||||
|
||||
# Извлекаем emoji_id из entities если это есть
|
||||
emoji_id = None
|
||||
|
||||
# Проверяем есть ли entities в сообщении (custom emoji имеют свой entitytype)
|
||||
if message.entities:
|
||||
for entity in message.entities:
|
||||
if entity.type == "custom_emoji":
|
||||
# Получаем text с этим entity
|
||||
emoji_id = entity.custom_emoji_id
|
||||
break
|
||||
|
||||
# Если нет custom_emoji entity, пробуем другой способ
|
||||
if not emoji_id:
|
||||
# Используем встроенный способ Telegram - отправляем тестовое сообщение с этим эмодзи
|
||||
# и смотрим entities
|
||||
try:
|
||||
# Отправляем сообщение с эмодзи обратно
|
||||
test_msg = await message.answer(
|
||||
f"Тестирую эмодзи: {emoji_text}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
# Пытаемся получить emoji_id из реакции
|
||||
# В Telegram для premium emoji нужно обращаться к API
|
||||
# Но мы можем просто использовать сам emoji как ID - он уникален
|
||||
emoji_id = emoji_text
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing emoji: {e}")
|
||||
emoji_id = emoji_text
|
||||
|
||||
# Сохраняем в состояние
|
||||
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id if emoji_id else emoji_text)
|
||||
|
||||
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Получил эмодзи: <code>{emoji_text}</code>\n\n"
|
||||
f"✅ Получил эмодзи: <code>{escape(emoji_text or '')}</code>\n\n"
|
||||
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
|
||||
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
|
||||
parse_mode="HTML"
|
||||
@@ -100,7 +66,7 @@ async def receive_emoji(message: Message, state: FSMContext):
|
||||
await state.set_state(EmojiStates.waiting_for_description)
|
||||
|
||||
|
||||
@router.message(EmojiStates.waiting_for_description)
|
||||
@router.message(EmojiStates.waiting_for_description, F.text)
|
||||
async def receive_emoji_description(message: Message, state: FSMContext):
|
||||
"""Получить описание эмодзи и сохранить в БД"""
|
||||
if not message.text:
|
||||
@@ -108,10 +74,18 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
||||
return
|
||||
|
||||
description = message.text.strip()
|
||||
if not description or len(description) > 255:
|
||||
await message.answer("Введите описание длиной от 1 до 255 символов.")
|
||||
return
|
||||
data = await state.get_data()
|
||||
emoji_text = data.get("emoji_text")
|
||||
emoji_id = data.get("emoji_id")
|
||||
|
||||
if not emoji_text or not emoji_id:
|
||||
await state.clear()
|
||||
await message.answer("Начните добавление заново: /add_emoji")
|
||||
return
|
||||
|
||||
# Сохраняем в БД
|
||||
async with async_session_maker() as session:
|
||||
emoji_service = EmojiMappingService(session)
|
||||
@@ -120,8 +94,8 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
||||
existing = await emoji_service.get_emoji_by_text(emoji_text, message.from_user.id)
|
||||
if existing:
|
||||
await message.answer(
|
||||
f"⚠️ Вы уже зарегистрировали этот эмодзи: {emoji_text}\n"
|
||||
f"Описание: <code>{existing.description}</code>",
|
||||
f"⚠️ Вы уже зарегистрировали этот эмодзи: {escape(emoji_text or '')}\n"
|
||||
f"Описание: <code>{escape(existing.description or '')}</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
@@ -137,16 +111,18 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
||||
|
||||
await message.answer(
|
||||
f"✅ <b>Эмодзи успешно зарегистрировано!</b>\n\n"
|
||||
f"Эмодзи: <code>{emoji_text}</code>\n"
|
||||
f"Описание: <code>{description}</code>\n"
|
||||
f"Эмодзи: <code>{escape(emoji_text or '')}</code>\n"
|
||||
f"Описание: <code>{escape(description or '')}</code>\n"
|
||||
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
|
||||
f"Теперь это эмодзи будет автоматически использоваться в сообщениях бота.",
|
||||
"Эмодзи сохранён в каталоге. В чатах и рассылках отправляйте его из панели Telegram.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except ValueError as error:
|
||||
await message.answer(str(error), parse_mode=None)
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering emoji: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при сохранении эмодзи: {str(e)}",
|
||||
f"❌ Ошибка при сохранении эмодзи: {public_error(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -156,7 +132,7 @@ async def receive_emoji_description(message: Message, state: FSMContext):
|
||||
@router.message(Command("my_emojis"))
|
||||
async def list_my_emojis(message: Message):
|
||||
"""Показать все эмодзи, добавленные этим админом"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
@@ -174,7 +150,7 @@ async def list_my_emojis(message: Message):
|
||||
text = "🎨 <b>Ваши зарегистрированные эмодзи:</b>\n\n"
|
||||
for emoji in emojis:
|
||||
text += (
|
||||
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
||||
f"<code>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
|
||||
f" ID: <code>{emoji.emoji_id[:30]}</code>...\n"
|
||||
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||
)
|
||||
@@ -185,7 +161,7 @@ async def list_my_emojis(message: Message):
|
||||
@router.message(Command("all_emojis"))
|
||||
async def list_all_emojis(message: Message):
|
||||
"""Показать все зарегистрированные эмодзи (для всех админов)"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
@@ -202,8 +178,8 @@ async def list_all_emojis(message: Message):
|
||||
text = "🎨 <b>Все зарегистрированные эмодзи в системе:</b>\n\n"
|
||||
for emoji in emojis:
|
||||
text += (
|
||||
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
||||
f" Админ: <code>{emoji.admin.first_name or 'Unknown'}</code> "
|
||||
f"<code>{escape(emoji.emoji_text or '')}</code> — {escape(emoji.description or '')}\n"
|
||||
f" Админ: <code>{escape(emoji.admin.first_name or 'Администратор') if emoji.admin else 'Администратор'}</code> "
|
||||
f"(ID: {emoji.admin_id})\n"
|
||||
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||
)
|
||||
@@ -214,7 +190,7 @@ async def list_all_emojis(message: Message):
|
||||
@router.message(Command("delete_emoji"))
|
||||
async def delete_emoji_start(message: Message, state: FSMContext):
|
||||
"""Удалить эмодзи"""
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Эта команда доступна только администраторам")
|
||||
return
|
||||
|
||||
@@ -233,7 +209,7 @@ async def delete_emoji_start(message: Message, state: FSMContext):
|
||||
for emoji in emojis:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji.emoji_text} ({emoji.description})",
|
||||
text=f"{emoji.emoji_text} ({emoji.description or ''})",
|
||||
callback_data=f"delete_emoji_{emoji.emoji_id}"
|
||||
)
|
||||
])
|
||||
@@ -258,14 +234,14 @@ async def delete_emoji_confirm(callback: CallbackQuery):
|
||||
await callback.answer("❌ Эмодзи не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if emoji.admin_id != callback.from_user.id and callback.from_user.id not in ADMIN_IDS:
|
||||
if (not emoji.admin or emoji.admin.telegram_id != callback.from_user.id) and callback.from_user.id not in ADMIN_IDS:
|
||||
await callback.answer("❌ Вы не можете удалить эмодзи другого админа", show_alert=True)
|
||||
return
|
||||
|
||||
success = await emoji_service.delete_emoji(emoji_id)
|
||||
if success:
|
||||
await callback.answer(
|
||||
f"✅ Эмодзи <code>{emoji.emoji_text}</code> удалено",
|
||||
f"✅ Эмодзи {emoji.emoji_text} удалено",
|
||||
show_alert=True
|
||||
)
|
||||
await callback.message.delete()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""
|
||||
Расширенная админ-панель для управления розыгрышами
|
||||
"""
|
||||
from src.utils.errors import public_error
|
||||
from src.core.access import is_admin
|
||||
from src.core.access import is_admin as check_admin_access
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import timezone
|
||||
import logging
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import (
|
||||
@@ -112,9 +117,6 @@ class AdminStates(StatesGroup):
|
||||
admin_router = Router()
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора (быстрая проверка только .env)"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
def is_super_admin(user_id: int) -> bool:
|
||||
@@ -122,23 +124,6 @@ def is_super_admin(user_id: int) -> bool:
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
async def check_admin_access(user_id: int) -> bool:
|
||||
"""
|
||||
Асинхронная проверка доступа администратора.
|
||||
Проверяет как главных администраторов (.env), так и назначенных (БД)
|
||||
"""
|
||||
# Сначала проверяем главных администраторов
|
||||
if user_id in ADMIN_IDS:
|
||||
return True
|
||||
|
||||
# Затем проверяем назначенных администраторов в БД
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == user_id, User.is_admin == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
return user is not None
|
||||
|
||||
|
||||
def get_admin_main_keyboard() -> InlineKeyboardMarkup:
|
||||
@@ -233,7 +218,7 @@ async def show_admin_panel(callback: CallbackQuery):
|
||||
# УПРАВЛЕНИЕ РОЗЫГРЫШАМИ
|
||||
# ======================
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_lotteries")
|
||||
@admin_router.callback_query(F.data.in_({"admin_lotteries", "lottery_management"}))
|
||||
async def show_lottery_management(callback: CallbackQuery):
|
||||
"""Управление розыгрышами"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
@@ -277,7 +262,7 @@ async def start_create_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
logging.info(f"✅ Состояние установлено: AdminStates.lottery_title")
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Ошибка при создании розыгрыша: {e}")
|
||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await callback.message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.lottery_title))
|
||||
@@ -784,7 +769,7 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.add_participant_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.add_participant_user))
|
||||
@admin_router.message(StateFilter(AdminStates.add_participant_user), F.text)
|
||||
async def process_add_participant(message: Message, state: FSMContext):
|
||||
"""Обработка добавления участника"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -905,7 +890,7 @@ async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMC
|
||||
)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.remove_participant_user))
|
||||
@admin_router.message(StateFilter(AdminStates.remove_participant_user), F.text)
|
||||
async def process_remove_participant(message: Message, state: FSMContext):
|
||||
"""Обработка удаления участника"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -1180,7 +1165,7 @@ async def start_search_participants(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.participant_search)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.participant_search))
|
||||
@admin_router.message(StateFilter(AdminStates.participant_search), F.text)
|
||||
async def process_search_participants(message: Message, state: FSMContext):
|
||||
"""Обработка поиска участников"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -1490,7 +1475,7 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
|
||||
@admin_router.callback_query(F.data.startswith("admin_add_to_"))
|
||||
async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавление участника в конкретный розыгрыш"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1520,10 +1505,10 @@ async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext)
|
||||
await state.set_state(AdminStates.add_to_lottery_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user))
|
||||
@admin_router.message(StateFilter(AdminStates.add_to_lottery_user), F.text)
|
||||
async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||
"""Обработка добавления участника в конкретный розыгрыш"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
@@ -1624,7 +1609,7 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
|
||||
@admin_router.callback_query(F.data.startswith("admin_remove_from_"))
|
||||
async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
"""Удаление участника из конкретного розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1656,10 +1641,10 @@ async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMCon
|
||||
await state.set_state(AdminStates.remove_from_lottery_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user))
|
||||
@admin_router.message(StateFilter(AdminStates.remove_from_lottery_user), F.text)
|
||||
async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
"""Обработка удаления участника из конкретного розыгрыша"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
@@ -1741,7 +1726,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
|
||||
@admin_router.callback_query(F.data.startswith("admin_check_winners_"))
|
||||
async def check_winners(callback: CallbackQuery):
|
||||
"""Проверка подтверждения победителей"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1810,7 +1795,7 @@ async def check_winners(callback: CallbackQuery):
|
||||
@admin_router.callback_query(F.data.startswith("admin_redraw_"))
|
||||
async def redraw_lottery(callback: CallbackQuery):
|
||||
"""Повторный розыгрыш для неподтверждённых призов"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1860,123 +1845,23 @@ async def redraw_lottery(callback: CallbackQuery):
|
||||
|
||||
@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_"))
|
||||
async def confirm_redraw(callback: CallbackQuery):
|
||||
"""Подтверждение и выполнение повторного розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
from src.core.redraw_services import redraw_unclaimed
|
||||
from src.utils.notifications import notify_winners_async
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
|
||||
await callback.answer("⏳ Проводится повторный розыгрыш...", show_alert=True)
|
||||
|
||||
await callback.answer("Проверяю неподтверждённые призы")
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select, delete
|
||||
from ..core.models import Winner, Participation
|
||||
import random
|
||||
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
if not lottery:
|
||||
await callback.message.edit_text("❌ Розыгрыш не найден")
|
||||
return
|
||||
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
# Собираем подтверждённые и неподтверждённые
|
||||
confirmed_winners = [w for w in winners if w.is_claimed]
|
||||
unconfirmed_winners = [w for w in winners if not w.is_claimed]
|
||||
|
||||
if not unconfirmed_winners:
|
||||
await callback.message.edit_text(
|
||||
"✅ Все победители уже подтверждены!",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Собираем исключённые счета (подтверждённые победители + бывшие неподтверждённые)
|
||||
excluded_accounts = set()
|
||||
for w in winners:
|
||||
if w.account_number:
|
||||
excluded_accounts.add(w.account_number)
|
||||
|
||||
# Получаем всех участников, исключая уже выигравших
|
||||
result = await session.execute(
|
||||
select(Participation)
|
||||
.where(Participation.lottery_id == lottery_id)
|
||||
)
|
||||
all_participations = result.scalars().all()
|
||||
|
||||
# Фильтруем участников
|
||||
available_participations = [
|
||||
p for p in all_participations
|
||||
if p.account_number not in excluded_accounts
|
||||
]
|
||||
|
||||
if not available_participations:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных участников для переигровки.\n"
|
||||
"Все участники уже выиграли или были исключены.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Удаляем неподтверждённых победителей
|
||||
for winner in unconfirmed_winners:
|
||||
await session.delete(winner)
|
||||
|
||||
# Проводим розыгрыш для неподтверждённых мест
|
||||
new_winners_text = ""
|
||||
random.shuffle(available_participations)
|
||||
|
||||
for i, old_winner in enumerate(unconfirmed_winners):
|
||||
if i >= len(available_participations):
|
||||
break
|
||||
|
||||
new_participation = available_participations[i]
|
||||
|
||||
# Создаём нового победителя
|
||||
new_winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=new_participation.user_id,
|
||||
account_number=new_participation.account_number,
|
||||
place=old_winner.place,
|
||||
is_manual=False,
|
||||
is_claimed=False
|
||||
)
|
||||
session.add(new_winner)
|
||||
|
||||
# Исключаем из следующих итераций
|
||||
if new_participation.account_number:
|
||||
excluded_accounts.add(new_participation.account_number)
|
||||
|
||||
prize = lottery.prizes[old_winner.place - 1] if lottery.prizes and len(lottery.prizes) >= old_winner.place else "Приз"
|
||||
name = new_participation.account_number or f"ID: {new_participation.user_id}"
|
||||
new_winners_text += f"🏆 {old_winner.place} место: {name} → {prize}\n"
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Отправляем уведомления новым победителям
|
||||
from ..utils.notifications import notify_winners_async
|
||||
try:
|
||||
winners = await redraw_unclaimed(session, lottery_id)
|
||||
if winners:
|
||||
await notify_winners_async(callback.bot, session, lottery_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
||||
text = "Повторный розыгрыш завершён.\n" if winners else "Нет просроченных неподтверждённых призов или новых участников."
|
||||
for winner in winners:
|
||||
text += f"{winner.place} место: {winner.account_number or winner.user_id} — {winner.prize}\n"
|
||||
await callback.message.answer(text)
|
||||
|
||||
text = f"🎉 Повторный розыгрыш завершён!\n\n"
|
||||
text += f"🎯 Розыгрыш: {lottery.title}\n\n"
|
||||
text += f"Новые победители:\n{new_winners_text}\n"
|
||||
text += "✅ Уведомления отправлены новым победителям"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✅ Проверить победителей", callback_data=f"admin_check_winners_{lottery_id}")],
|
||||
[InlineKeyboardButton(text="🔙 К розыгрышу", callback_data=f"admin_lottery_detail_{lottery_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
# ======================
|
||||
@@ -1986,7 +1871,7 @@ async def confirm_redraw(callback: CallbackQuery):
|
||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_"))
|
||||
async def delete_lottery_confirm(callback: CallbackQuery):
|
||||
"""Подтверждение удаления розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2020,7 +1905,7 @@ async def delete_lottery_confirm(callback: CallbackQuery):
|
||||
@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_"))
|
||||
async def delete_lottery_execute(callback: CallbackQuery):
|
||||
"""Выполнение удаления розыгрыша"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2037,15 +1922,7 @@ async def delete_lottery_execute(callback: CallbackQuery):
|
||||
|
||||
lottery_title = lottery.title
|
||||
|
||||
# Удаляем победителей
|
||||
await session.execute(sql_delete(Winner).where(Winner.lottery_id == lottery_id))
|
||||
|
||||
# Удаляем участников
|
||||
await session.execute(sql_delete(Participation).where(Participation.lottery_id == lottery_id))
|
||||
|
||||
# Удаляем розыгрыш
|
||||
await session.delete(lottery)
|
||||
await session.commit()
|
||||
await LotteryService.delete_lottery(session, lottery_id)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Розыгрыш удалён\n\n"
|
||||
@@ -2384,7 +2261,7 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
select(
|
||||
User.first_name,
|
||||
User.username,
|
||||
User.account_number,
|
||||
func.min(Participation.account_number),
|
||||
func.count(Participation.id).label('participations')
|
||||
)
|
||||
.join(Participation)
|
||||
@@ -2396,11 +2273,11 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
|
||||
# Участники с аккаунтами vs без
|
||||
users_with_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.isnot(None))
|
||||
select(func.count(User.id)).where(User.accounts.any())
|
||||
)
|
||||
|
||||
users_without_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.is_(None))
|
||||
select(func.count(User.id)).where(~User.accounts.any())
|
||||
)
|
||||
|
||||
text = "📈 Отчет по участникам\n\n"
|
||||
@@ -2951,7 +2828,7 @@ async def process_winner_place(message: Message, state: FSMContext):
|
||||
await state.set_state(AdminStates.set_winner_user)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.set_winner_user))
|
||||
@admin_router.message(StateFilter(AdminStates.set_winner_user), F.text)
|
||||
async def process_winner_user(message: Message, state: FSMContext):
|
||||
"""Обработка пользователя-победителя (по ID, username или номеру счета)"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -3391,6 +3268,8 @@ async def do_remove_winner(callback: CallbackQuery):
|
||||
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
|
||||
|
||||
# Удаляем победителя
|
||||
from src.core.models import WinnerVerification
|
||||
await session.execute(delete(WinnerVerification).where(WinnerVerification.winner_id == winner_id))
|
||||
await session.execute(delete(Winner).where(Winner.id == winner_id))
|
||||
await session.commit()
|
||||
|
||||
@@ -3443,7 +3322,7 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_draws")])
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
@@ -3546,7 +3425,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проведении розыгрыша {lottery_id}: {e}", exc_info=True)
|
||||
await session.rollback()
|
||||
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
|
||||
await callback.answer(f"❌ Ошибка: {public_error(e)}", show_alert=True)
|
||||
return
|
||||
|
||||
if winners_dict:
|
||||
@@ -3587,7 +3466,7 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_draws")]
|
||||
[InlineKeyboardButton(text="◀️ К розыгрышам", callback_data="admin_lotteries")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
@@ -4095,7 +3974,7 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 Исключение при смене типа отображения: {e}")
|
||||
text = f"❌ Ошибка: {str(e)}"
|
||||
text = f"❌ Ошибка: {public_error(e)}"
|
||||
await callback.answer("❌ Ошибка при сохранении!", show_alert=True)
|
||||
return
|
||||
await callback.answer("❌ Ошибка сохранения", show_alert=True)
|
||||
@@ -4402,6 +4281,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
winners_dict: Словарь с победителями {место: данные}
|
||||
"""
|
||||
import asyncio
|
||||
from html import escape
|
||||
|
||||
# Получаем розыгрыш
|
||||
lottery = await LotteryService.get_lottery(session, lottery_id)
|
||||
@@ -4428,7 +4308,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
# Формируем сообщение с результатами
|
||||
message = (
|
||||
f"📢 <b>Результаты розыгрыша</b>\n\n"
|
||||
f"🎯 <b>{lottery.title}</b>\n\n"
|
||||
f"🎯 <b>{escape(lottery.title)}</b>\n\n"
|
||||
f"🏆 <b>Победители:</b>\n"
|
||||
)
|
||||
|
||||
@@ -4451,7 +4331,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
|
||||
# Формируем строку победителя
|
||||
winner_name = nickname if nickname else display_name if display_name else f"Счет {winner.account_number}"
|
||||
message += f"{winner.place} место: {winner_name}\n"
|
||||
message += f"{winner.place} место: {escape(winner_name)}\n"
|
||||
|
||||
message += (
|
||||
f"\n🎁 Поздравляем победителей!\n"
|
||||
@@ -4459,6 +4339,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
)
|
||||
|
||||
# Рассылаем всем кроме победителей
|
||||
await session.commit()
|
||||
from src.utils.delivery import send_background
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
@@ -4468,9 +4350,8 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
continue
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
await send_background(bot, chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
success_count += 1
|
||||
@@ -4488,100 +4369,19 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_export_users")
|
||||
async def admin_export_users(callback: CallbackQuery):
|
||||
"""Экспорт всех пользователей в XLSX"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
await callback.answer("Готовлю файл")
|
||||
import asyncio
|
||||
from aiogram.types import BufferedInputFile
|
||||
from src.utils.spreadsheets import export_users
|
||||
async with async_session_maker() as session:
|
||||
users = await UserService.get_all_users(session)
|
||||
content = await asyncio.to_thread(export_users, users)
|
||||
await callback.message.answer_document(BufferedInputFile(content, filename="users.xlsx"),
|
||||
caption=f"Пользователей: {len(users)}")
|
||||
|
||||
await callback.answer("⏳ Формирую файл...", show_alert=False)
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from io import BytesIO
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем всех пользователей
|
||||
all_users = await UserService.get_all_users(session)
|
||||
|
||||
# Создаем Excel файл
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Пользователи"
|
||||
|
||||
# Заголовки
|
||||
headers = [
|
||||
'Telegram ID', 'Username', 'Имя', 'Фамилия', 'Никнейм',
|
||||
'Телефон', 'Клубная карта', 'Зарегистрирован', 'Админ',
|
||||
'Код верификации', 'Дата создания', 'Последняя активность', 'Заблокирован в чате'
|
||||
]
|
||||
|
||||
# Стиль для заголовков
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
# Данные пользователей
|
||||
for row_num, user in enumerate(all_users, 2):
|
||||
ws.cell(row=row_num, column=1, value=user.telegram_id)
|
||||
ws.cell(row=row_num, column=2, value=user.username or '')
|
||||
ws.cell(row=row_num, column=3, value=user.first_name or '')
|
||||
ws.cell(row=row_num, column=4, value=user.last_name or '')
|
||||
ws.cell(row=row_num, column=5, value=user.nickname or '')
|
||||
ws.cell(row=row_num, column=6, value=user.phone or '')
|
||||
ws.cell(row=row_num, column=7, value=user.club_card_number or '')
|
||||
ws.cell(row=row_num, column=8, value='Да' if user.is_registered else 'Нет')
|
||||
ws.cell(row=row_num, column=9, value='Да' if user.is_admin else 'Нет')
|
||||
ws.cell(row=row_num, column=10, value=user.verification_code or '')
|
||||
ws.cell(row=row_num, column=11, value=user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else '')
|
||||
ws.cell(row=row_num, column=12, value=user.last_activity.strftime('%d.%m.%Y %H:%M') if user.last_activity else '')
|
||||
ws.cell(row=row_num, column=13, value='Да' if user.is_chat_banned else 'Нет')
|
||||
|
||||
# Автоподбор ширины колонок
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50)
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# Сохраняем в BytesIO
|
||||
excel_file = BytesIO()
|
||||
wb.save(excel_file)
|
||||
excel_file.seek(0)
|
||||
|
||||
# Отправляем файл
|
||||
filename = f"users_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
file = BufferedInputFile(excel_file.read(), filename=filename)
|
||||
|
||||
registered_count = len([u for u in all_users if u.is_registered])
|
||||
|
||||
await callback.message.answer_document(
|
||||
document=file,
|
||||
caption=(
|
||||
f"📥 <b>Экспорт пользователей</b>\n\n"
|
||||
f"📊 Всего пользователей: {len(all_users)}\n"
|
||||
f"✅ Зарегистрировано: {registered_count}\n"
|
||||
f"📅 Дата экспорта: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Файл отправлен", show_alert=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка экспорта пользователей: {e}")
|
||||
await callback.answer("❌ Ошибка при создании файла", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_import_users")
|
||||
@@ -4619,172 +4419,32 @@ async def admin_import_users_start(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
@admin_router.message(StateFilter(AdminStates.import_users_json), F.document)
|
||||
async def admin_import_users_process(message: Message, state: FSMContext):
|
||||
"""Обработка импорта пользователей из XLSX"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
return
|
||||
|
||||
# Проверяем формат файла
|
||||
if not message.document.file_name.endswith('.xlsx'):
|
||||
await message.answer("❌ Неверный формат файла. Отправьте XLSX файл.")
|
||||
import asyncio
|
||||
from src.utils.spreadsheets import read_users_xlsx, MAX_FILE_BYTES
|
||||
from src.core.import_services import import_users
|
||||
if not (message.document.file_name or "").lower().endswith(".xlsx"):
|
||||
await message.answer("Отправьте XLSX файл.")
|
||||
return
|
||||
|
||||
status_msg = await message.answer("⏳ Загружаю файл...")
|
||||
|
||||
if (message.document.file_size or 0) > MAX_FILE_BYTES:
|
||||
await message.answer("Файл должен быть не больше 5 МБ.")
|
||||
return
|
||||
status = await message.answer("Импортирую пользователей…")
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
from io import BytesIO
|
||||
|
||||
# Скачиваем файл
|
||||
file = await message.bot.get_file(message.document.file_id)
|
||||
file_content = await message.bot.download_file(file.file_path)
|
||||
|
||||
# Читаем Excel файл
|
||||
excel_file = BytesIO(file_content.read())
|
||||
wb = load_workbook(excel_file, read_only=True)
|
||||
ws = wb.active
|
||||
|
||||
# Читаем данные
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
|
||||
if len(rows) < 2:
|
||||
await status_msg.edit_text("❌ Файл пуст или не содержит данных.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Первая строка - заголовки
|
||||
headers = [h if h else '' for h in rows[0]]
|
||||
|
||||
# Находим индекс колонки Telegram ID
|
||||
try:
|
||||
telegram_id_idx = headers.index('Telegram ID')
|
||||
except ValueError:
|
||||
await status_msg.edit_text("❌ Не найдена обязательная колонка 'Telegram ID'.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Создаем маппинг индексов для других полей
|
||||
field_mapping = {
|
||||
'Username': 'username',
|
||||
'Имя': 'first_name',
|
||||
'Фамилия': 'last_name',
|
||||
'Никнейм': 'nickname',
|
||||
'Телефон': 'phone',
|
||||
'Клубная карта': 'club_card_number',
|
||||
'Зарегистрирован': 'is_registered',
|
||||
'Код верификации': 'verification_code'
|
||||
}
|
||||
|
||||
users_data = []
|
||||
for row in rows[1:]: # Пропускаем заголовки
|
||||
if not row or len(row) <= telegram_id_idx or not row[telegram_id_idx]:
|
||||
continue
|
||||
|
||||
user_dict = {'telegram_id': row[telegram_id_idx]}
|
||||
|
||||
for header_name, field_name in field_mapping.items():
|
||||
try:
|
||||
idx = headers.index(header_name)
|
||||
if idx < len(row):
|
||||
value = row[idx]
|
||||
if field_name == 'is_registered':
|
||||
user_dict[field_name] = value in ['Да', 'Yes', 'True', True, 1]
|
||||
else:
|
||||
user_dict[field_name] = value if value else None
|
||||
except (ValueError, IndexError):
|
||||
user_dict[field_name] = None
|
||||
|
||||
users_data.append(user_dict)
|
||||
|
||||
await status_msg.edit_text(
|
||||
f"📊 Найдено пользователей в файле: {len(users_data)}\n"
|
||||
f"⏳ Импортирую..."
|
||||
)
|
||||
|
||||
# Импортируем пользователей
|
||||
async with async_session_maker() as session:
|
||||
added_count = 0
|
||||
updated_count = 0
|
||||
error_count = 0
|
||||
|
||||
for user_data in users_data:
|
||||
try:
|
||||
telegram_id = user_data.get('telegram_id')
|
||||
if not telegram_id:
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# Преобразуем telegram_id в int если это строка
|
||||
try:
|
||||
telegram_id = int(telegram_id)
|
||||
except (ValueError, TypeError):
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# Ищем существующего пользователя
|
||||
existing_user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
|
||||
if existing_user:
|
||||
# Обновляем существующего
|
||||
if user_data.get('username') is not None:
|
||||
existing_user.username = user_data.get('username')
|
||||
if user_data.get('first_name') is not None:
|
||||
existing_user.first_name = user_data.get('first_name')
|
||||
if user_data.get('last_name') is not None:
|
||||
existing_user.last_name = user_data.get('last_name')
|
||||
if user_data.get('nickname') is not None:
|
||||
existing_user.nickname = user_data.get('nickname')
|
||||
if user_data.get('phone') is not None:
|
||||
existing_user.phone = user_data.get('phone')
|
||||
if user_data.get('club_card_number') is not None:
|
||||
existing_user.club_card_number = user_data.get('club_card_number')
|
||||
if user_data.get('is_registered') is not None:
|
||||
existing_user.is_registered = user_data.get('is_registered', False)
|
||||
if user_data.get('verification_code') is not None:
|
||||
existing_user.verification_code = user_data.get('verification_code')
|
||||
# is_admin не обновляем из соображений безопасности
|
||||
|
||||
updated_count += 1
|
||||
else:
|
||||
# Создаем нового
|
||||
new_user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=user_data.get('username'),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
nickname=user_data.get('nickname'),
|
||||
phone=user_data.get('phone'),
|
||||
club_card_number=user_data.get('club_card_number'),
|
||||
is_registered=user_data.get('is_registered', False),
|
||||
is_admin=False, # Не импортируем админов из соображений безопасности
|
||||
verification_code=user_data.get('verification_code')
|
||||
)
|
||||
session.add(new_user)
|
||||
added_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка импорта пользователя {user_data.get('telegram_id')}: {e}")
|
||||
error_count += 1
|
||||
|
||||
# Сохраняем изменения
|
||||
await session.commit()
|
||||
|
||||
# Итоговый отчет
|
||||
await status_msg.edit_text(
|
||||
f"✅ <b>Импорт завершен!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"➕ Добавлено: {added_count}\n"
|
||||
f"🔄 Обновлено: {updated_count}\n"
|
||||
f"❌ Ошибок: {error_count}\n"
|
||||
f"📝 Всего обработано: {added_count + updated_count}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
content = await message.bot.download_file(file.file_path)
|
||||
records = await asyncio.to_thread(read_users_xlsx, content.read())
|
||||
added, updated, errors = await import_users(records)
|
||||
await status.edit_text(f"Добавлено: {added}. Обновлено: {updated}. Ошибок: {errors}.")
|
||||
except ValueError as error:
|
||||
await status.edit_text(str(error))
|
||||
except Exception:
|
||||
logger.exception("Не удалось импортировать XLSX")
|
||||
await status.edit_text("Не удалось обработать файл. Проверьте формат данных.")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка импорта пользователей: {e}")
|
||||
await status_msg.edit_text(f"❌ Ошибка импорта: {str(e)}\n\nПроверьте формат файла.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -5176,7 +4836,7 @@ async def admin_broadcast_channels_menu(callback: CallbackQuery, state: FSMConte
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✨ Добавить канал/группу", callback_data="admin_broadcast_add_channel")],
|
||||
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_list_channels")],
|
||||
[InlineKeyboardButton(text="📜 Список всех", callback_data="admin_broadcast_channels")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_broadcast")]
|
||||
]
|
||||
|
||||
@@ -5278,7 +4938,7 @@ async def admin_broadcast_add_channel_id(message: Message, state: FSMContext):
|
||||
f"• Бот не добавлен в канал/группу\n"
|
||||
f"• Неверный ID\n"
|
||||
f"• Бот не имеет прав администратора\n\n"
|
||||
f"Детали: {str(e)}",
|
||||
f"Детали: {public_error(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
@@ -5551,7 +5211,7 @@ async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.user_management_search)
|
||||
|
||||
|
||||
@admin_router.message(AdminStates.user_management_search)
|
||||
@admin_router.message(AdminStates.user_management_search, F.text)
|
||||
async def admin_users_search_process(message: Message, state: FSMContext):
|
||||
"""Обработка поискового запроса"""
|
||||
if not await check_admin_access(message.from_user.id):
|
||||
@@ -5932,7 +5592,7 @@ async def add_admin_start(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminStates.admin_add_search)
|
||||
|
||||
|
||||
@admin_router.message(StateFilter(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):
|
||||
@@ -6085,4 +5745,4 @@ async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
|
||||
# Экспорт роутера
|
||||
__all__ = ['admin_router']
|
||||
__all__ = ['admin_router']
|
||||
|
||||
100
src/handlers/cashier_handlers.py
Normal file
100
src/handlers/cashier_handlers.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Independent cashier dialogs; staff assignment is restricted to super admins."""
|
||||
from aiogram import 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 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
|
||||
from src.utils.account_utils import parse_accounts_from_message
|
||||
|
||||
cashier_router = Router(name="cashier")
|
||||
cashier_router.message.middleware(AccessMiddleware(allow_cashier=True))
|
||||
cashier_router.callback_query.middleware(AccessMiddleware(allow_cashier=True))
|
||||
|
||||
|
||||
class CashierStates(StatesGroup):
|
||||
accounts = State()
|
||||
|
||||
|
||||
@cashier_router.message(Command("cashier"))
|
||||
@staff_only
|
||||
async def cashier_menu(message: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
async with async_session_maker() as session:
|
||||
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]
|
||||
await message.answer(
|
||||
"💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n"
|
||||
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
|
||||
"/remove_account СЧЕТ — деактивировать счет\n"
|
||||
"/verify_winner КОД ID_РОЗЫГРЫША — подтвердить выдачу приза\n"
|
||||
"/winner_status ID_РОЗЫГРЫША — проверить выдачу\n"
|
||||
"/cancel — отменить ввод",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None,
|
||||
)
|
||||
|
||||
|
||||
@cashier_router.callback_query(F.data == "cashier_panel")
|
||||
async def cashier_menu_callback(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
# Do not use the bot-authored message as the identity for authorization.
|
||||
await cashier_menu.__wrapped__(callback.message, state)
|
||||
|
||||
|
||||
@cashier_router.callback_query(F.data.startswith("cash_add:"))
|
||||
async def choose_draw(callback: CallbackQuery, state: FSMContext):
|
||||
lottery_id = int(callback.data.split(":")[1])
|
||||
await state.clear()
|
||||
await state.update_data(lottery_id=lottery_id)
|
||||
await state.set_state(CashierStates.accounts)
|
||||
await callback.answer()
|
||||
await callback.message.answer("Отправьте счета, каждый с новой строки, или /cancel.")
|
||||
|
||||
|
||||
@cashier_router.message(CashierStates.accounts, F.text)
|
||||
async def add_accounts(message: Message, state: FSMContext):
|
||||
accounts = parse_accounts_from_message(message.text)
|
||||
if not accounts or len(accounts) > 1000:
|
||||
await message.answer("Введите от 1 до 1000 счетов в формате 11-22-33-44-55-66-77.")
|
||||
return
|
||||
data = await state.get_data()
|
||||
async with async_session_maker() as session:
|
||||
result = await AccountParticipationService.add_accounts_bulk(session, data["lottery_id"], accounts)
|
||||
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.")
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Обработчики пользовательских сообщений в чате"""
|
||||
from src.utils.errors import public_error
|
||||
from src.core.access import is_admin
|
||||
from src.utils.delivery import background_delivery
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -10,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import asyncio
|
||||
from typing import List, Dict, Optional, Set, Any
|
||||
from collections import deque
|
||||
from src.utils.telegram_messages import copy_preserving_entities
|
||||
import time
|
||||
|
||||
from src.core.chat_services import (
|
||||
@@ -29,9 +33,6 @@ class ChatStates(StatesGroup):
|
||||
in_chat = State() # Пользователь находится в режиме чата
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка является ли пользователь админом"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
def _contains_account_numbers(text: str) -> bool:
|
||||
@@ -98,10 +99,10 @@ async def exit_chat_command(message: Message, state: FSMContext):
|
||||
async def exit_chat_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выйти из режима чата через кнопку"""
|
||||
await callback.answer()
|
||||
await exit_chat(callback.message, state)
|
||||
await exit_chat(callback.message, state, actor=callback.from_user)
|
||||
|
||||
|
||||
async def exit_chat(message: Message, state: FSMContext):
|
||||
async def exit_chat(message: Message, state: FSMContext, actor=None):
|
||||
"""Общая функция выхода из чата"""
|
||||
from src.utils.keyboards import get_main_reply_keyboard
|
||||
from src.core.config import ADMIN_IDS
|
||||
@@ -112,9 +113,9 @@ async def exit_chat(message: Message, state: FSMContext):
|
||||
|
||||
# Получаем информацию о пользователе
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
user = await UserService.get_user_by_telegram_id(session, (actor or message.from_user).id)
|
||||
is_registered = user.is_registered if user else False
|
||||
is_admin_user = message.from_user.id in ADMIN_IDS
|
||||
is_admin_user = await is_admin((actor or message.from_user).id)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||
@@ -171,7 +172,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
|
||||
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
|
||||
# Защита от дубликатов - если сообщение уже обработано, пропускаем
|
||||
if _is_message_processed(message.message_id):
|
||||
if _is_message_processed(message.chat.id, message.message_id):
|
||||
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
||||
return
|
||||
|
||||
@@ -179,7 +180,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
|
||||
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
|
||||
# Пропускаем для account_router (который идет после chat_router)
|
||||
if is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
|
||||
if await is_admin(message.from_user.id) and message.text and not message.text.startswith('/'):
|
||||
if _contains_account_numbers(message.text):
|
||||
logger.info(f"[CHAT] Обнаружены счета от админа, пропускаем - account_router обработает")
|
||||
# Не делаем return, выбрасываем исключение для пропуска в следующий обработчик
|
||||
@@ -187,13 +188,14 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
raise SkipHandler()
|
||||
|
||||
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
|
||||
if message.reply_to_message and is_admin(message.from_user.id):
|
||||
if message.reply_to_message and await is_admin(message.from_user.id):
|
||||
if message.text and message.text.lower().strip() in ['удалить', 'del', '-']:
|
||||
async with async_session_maker() as session:
|
||||
# Ищем сообщение в БД по telegram_message_id
|
||||
msg_to_delete = await ChatMessageService.get_message_by_telegram_id(
|
||||
session,
|
||||
telegram_message_id=message.reply_to_message.message_id
|
||||
telegram_message_id=message.reply_to_message.message_id,
|
||||
chat_id=message.chat.id,
|
||||
)
|
||||
|
||||
if msg_to_delete:
|
||||
@@ -271,7 +273,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
command = message.text.split()[0] if message.text else ''
|
||||
|
||||
# ИЗМЕНЕНИЕ: Если это команда от АДМИНА - не пересылаем (админ сам её видит)
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
# Если это админская команда - пропускаем, она будет обработана другими обработчиками
|
||||
if command in admin_commands:
|
||||
return
|
||||
@@ -290,7 +292,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id,
|
||||
is_admin=is_admin(message.from_user.id)
|
||||
is_admin=await is_admin(message.from_user.id)
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
@@ -314,6 +316,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
# Режим рассылки с планировщиком
|
||||
# Передаем объект user для динамического формирования подписей
|
||||
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
|
||||
await session.commit()
|
||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||
message,
|
||||
sender_user=user,
|
||||
@@ -331,7 +334,7 @@ async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
# Показываем статистику доставки только админам
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
await message.answer(
|
||||
f"✅ Сообщение разослано!\n"
|
||||
f"📤 Доставлено: {success}\n"
|
||||
@@ -370,11 +373,12 @@ BATCH_DELAY = 1.0 # Задержка между пакетами в секун
|
||||
_processed_messages: deque = deque(maxlen=100)
|
||||
|
||||
|
||||
def _is_message_processed(message_id: int) -> bool:
|
||||
def _is_message_processed(chat_id: int, message_id: int) -> bool:
|
||||
"""Проверка, было ли сообщение уже обработано"""
|
||||
if message_id in _processed_messages:
|
||||
key = (chat_id, message_id)
|
||||
if key in _processed_messages:
|
||||
return True
|
||||
_processed_messages.append(message_id)
|
||||
_processed_messages.append(key)
|
||||
return False
|
||||
|
||||
|
||||
@@ -420,7 +424,7 @@ async def broadcast_message_with_scheduler(
|
||||
|
||||
# Если только для админов - фильтруем
|
||||
if admin_only:
|
||||
users = [u for u in users if u.telegram_id in ADMIN_IDS]
|
||||
users = [u for u in users if (u.telegram_id in ADMIN_IDS or u.is_admin)]
|
||||
logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей")
|
||||
|
||||
forwarded_ids = {}
|
||||
@@ -435,7 +439,7 @@ async def broadcast_message_with_scheduler(
|
||||
tasks = []
|
||||
for recipient_user in batch:
|
||||
# Формируем подпись в зависимости от получателя
|
||||
if recipient_user.telegram_id in ADMIN_IDS:
|
||||
if (recipient_user.telegram_id in ADMIN_IDS or recipient_user.is_admin):
|
||||
# Админы видят полную информацию: nickname (карта: XXXX)
|
||||
sender_name = sender_user.nickname if sender_user.nickname else (
|
||||
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
|
||||
@@ -448,7 +452,7 @@ async def broadcast_message_with_scheduler(
|
||||
# Обычные пользователи видят:
|
||||
# - "Админ" если отправитель - админ
|
||||
# - nickname если отправитель - обычный пользователь
|
||||
if sender_user.telegram_id in ADMIN_IDS:
|
||||
if (sender_user.telegram_id in ADMIN_IDS or sender_user.is_admin):
|
||||
sender_info = "Админ"
|
||||
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
|
||||
else:
|
||||
@@ -478,175 +482,38 @@ async def broadcast_message_with_scheduler(
|
||||
return forwarded_ids, success_count, fail_count
|
||||
|
||||
|
||||
@background_delivery
|
||||
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
|
||||
"""
|
||||
Отправить сообщение конкретному пользователю.
|
||||
Возвращает message_id при успехе или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
sent_msg = await message.copy_to(user_telegram_id)
|
||||
sent_msg = await copy_preserving_entities(message, user_telegram_id)
|
||||
return sent_msg.message_id
|
||||
except Exception as e:
|
||||
print(f"Failed to send message to {user_telegram_id}: {e}")
|
||||
print(f"Failed to send message to {user_telegram_id}: {public_error(e)}")
|
||||
return None
|
||||
|
||||
|
||||
@background_delivery
|
||||
async def _copy_with_sender(message: Message, recipient_id: int, sender_info: str):
|
||||
"""Preserve Telegram entities and keep headers within message/caption limits."""
|
||||
try:
|
||||
sent = await copy_preserving_entities(message, recipient_id, sender_info or "Участник")
|
||||
return sent.message_id
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("Chat delivery failed")
|
||||
return None
|
||||
|
||||
|
||||
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
|
||||
"""
|
||||
Отправить сообщение обычному пользователю с информацией об отправителе.
|
||||
Возвращает message_id при успехе или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
# Формируем текст с информацией об отправителе
|
||||
header = f"📨 <b>{sender_info}:</b>\n\n"
|
||||
|
||||
if message.text:
|
||||
# Текстовое сообщение
|
||||
sent_msg = await message.bot.send_message(
|
||||
user_telegram_id,
|
||||
header + message.text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.photo:
|
||||
# Фото
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_photo(
|
||||
user_telegram_id,
|
||||
photo=message.photo[-1].file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.video:
|
||||
# Видео
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_video(
|
||||
user_telegram_id,
|
||||
video=message.video.file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.document:
|
||||
# Документ
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_document(
|
||||
user_telegram_id,
|
||||
document=message.document.file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.animation:
|
||||
# GIF
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_animation(
|
||||
user_telegram_id,
|
||||
animation=message.animation.file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.sticker:
|
||||
# Стикер - сначала отправляем заголовок, потом стикер
|
||||
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
|
||||
elif message.voice:
|
||||
# Голосовое сообщение
|
||||
sent_msg = await message.bot.send_voice(
|
||||
user_telegram_id,
|
||||
voice=message.voice.file_id,
|
||||
caption=header,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.video_note:
|
||||
# Видео-кружок
|
||||
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
|
||||
else:
|
||||
# Неизвестный тип - просто копируем
|
||||
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||
sent_msg = await message.copy_to(user_telegram_id)
|
||||
|
||||
return sent_msg.message_id
|
||||
except Exception as e:
|
||||
print(f"Failed to send message to {user_telegram_id}: {e}")
|
||||
return None
|
||||
return await _copy_with_sender(message, user_telegram_id, sender_info)
|
||||
|
||||
|
||||
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
|
||||
"""
|
||||
Отправить сообщение админу с информацией об отправителе.
|
||||
Возвращает message_id при успехе или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
# Формируем текст с информацией об отправителе
|
||||
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
|
||||
|
||||
if message.text:
|
||||
# Текстовое сообщение
|
||||
sent_msg = await message.bot.send_message(
|
||||
admin_telegram_id,
|
||||
header + message.text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.photo:
|
||||
# Фото
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_photo(
|
||||
admin_telegram_id,
|
||||
photo=message.photo[-1].file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.video:
|
||||
# Видео
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_video(
|
||||
admin_telegram_id,
|
||||
video=message.video.file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.document:
|
||||
# Документ
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_document(
|
||||
admin_telegram_id,
|
||||
document=message.document.file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.animation:
|
||||
# GIF
|
||||
caption = header + (message.caption or "")
|
||||
sent_msg = await message.bot.send_animation(
|
||||
admin_telegram_id,
|
||||
animation=message.animation.file_id,
|
||||
caption=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.sticker:
|
||||
# Стикер - сначала отправляем заголовок, потом стикер
|
||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
|
||||
elif message.voice:
|
||||
# Голосовое сообщение
|
||||
sent_msg = await message.bot.send_voice(
|
||||
admin_telegram_id,
|
||||
voice=message.voice.file_id,
|
||||
caption=header,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message.video_note:
|
||||
# Видео-кружок
|
||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
|
||||
else:
|
||||
# Неизвестный тип - просто копируем
|
||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||
sent_msg = await message.copy_to(admin_telegram_id)
|
||||
|
||||
return sent_msg.message_id
|
||||
except Exception as e:
|
||||
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
|
||||
return None
|
||||
return await _copy_with_sender(message, admin_telegram_id, sender_info)
|
||||
|
||||
|
||||
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
||||
@@ -656,7 +523,7 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
||||
sent_msg = await message.forward(channel_id)
|
||||
return True, sent_msg.message_id
|
||||
except Exception as e:
|
||||
print(f"Failed to forward message to channel {channel_id}: {e}")
|
||||
print(f"Failed to forward message to channel {channel_id}: {public_error(e)}")
|
||||
return False, None
|
||||
|
||||
|
||||
@@ -665,14 +532,14 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
||||
async def handle_photo_message(message: Message, state: FSMContext):
|
||||
"""Обработчик фото"""
|
||||
# Защита от дубликатов
|
||||
if _is_message_processed(message.message_id):
|
||||
if _is_message_processed(message.chat.id, message.message_id):
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id,
|
||||
is_admin=is_admin(message.from_user.id)
|
||||
is_admin=await is_admin(message.from_user.id)
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
@@ -693,6 +560,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
# Рассылаем фото - ВСЕГДА исключаем отправителя
|
||||
await session.commit()
|
||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||
message,
|
||||
sender_user=user,
|
||||
@@ -710,7 +578,7 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Фото разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
@@ -734,14 +602,14 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
||||
async def handle_video_message(message: Message, state: FSMContext):
|
||||
"""Обработчик видео"""
|
||||
# Защита от дубликатов
|
||||
if _is_message_processed(message.message_id):
|
||||
if _is_message_processed(message.chat.id, message.message_id):
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id,
|
||||
is_admin=is_admin(message.from_user.id)
|
||||
is_admin=await is_admin(message.from_user.id)
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
@@ -759,6 +627,7 @@ async def handle_video_message(message: Message, state: FSMContext):
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
# Рассылаем видео
|
||||
await session.commit()
|
||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||
message,
|
||||
sender_user=user,
|
||||
@@ -776,7 +645,7 @@ async def handle_video_message(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Видео разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
@@ -800,14 +669,14 @@ async def handle_video_message(message: Message, state: FSMContext):
|
||||
async def handle_document_message(message: Message, state: FSMContext):
|
||||
"""Обработчик документов"""
|
||||
# Защита от дубликатов
|
||||
if _is_message_processed(message.message_id):
|
||||
if _is_message_processed(message.chat.id, message.message_id):
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id,
|
||||
is_admin=is_admin(message.from_user.id)
|
||||
is_admin=await is_admin(message.from_user.id)
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
@@ -825,6 +694,7 @@ async def handle_document_message(message: Message, state: FSMContext):
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
# Рассылаем документ
|
||||
await session.commit()
|
||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||
message,
|
||||
sender_user=user,
|
||||
@@ -842,7 +712,7 @@ async def handle_document_message(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Документ разослан: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
@@ -866,14 +736,14 @@ async def handle_document_message(message: Message, state: FSMContext):
|
||||
async def handle_animation_message(message: Message, state: FSMContext):
|
||||
"""Обработчик GIF анимаций"""
|
||||
# Защита от дубликатов
|
||||
if _is_message_processed(message.message_id):
|
||||
if _is_message_processed(message.chat.id, message.message_id):
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id,
|
||||
is_admin=is_admin(message.from_user.id)
|
||||
is_admin=await is_admin(message.from_user.id)
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
@@ -891,6 +761,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
# Рассылаем анимацию
|
||||
await session.commit()
|
||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||
message,
|
||||
sender_user=user,
|
||||
@@ -908,7 +779,7 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
@@ -932,14 +803,14 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
||||
async def handle_sticker_message(message: Message, state: FSMContext):
|
||||
"""Обработчик стикеров"""
|
||||
# Защита от дубликатов
|
||||
if _is_message_processed(message.message_id):
|
||||
if _is_message_processed(message.chat.id, message.message_id):
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id,
|
||||
is_admin=is_admin(message.from_user.id)
|
||||
is_admin=await is_admin(message.from_user.id)
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
@@ -957,6 +828,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
# Рассылаем стикер
|
||||
await session.commit()
|
||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||
message,
|
||||
sender_user=user,
|
||||
@@ -973,7 +845,7 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Обработчики справки и помощи пользователям"""
|
||||
from src.core.access import is_admin
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
@@ -7,9 +8,6 @@ from src.core.config import ADMIN_IDS
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка является ли пользователь админом"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
router = Router(name='help_router')
|
||||
@@ -241,7 +239,7 @@ async def help_commands(callback: CallbackQuery):
|
||||
await callback.answer()
|
||||
|
||||
user_id = callback.from_user.id
|
||||
is_user_admin = is_admin(user_id)
|
||||
is_user_admin = await is_admin(user_id)
|
||||
|
||||
text = (
|
||||
"⚙️ <b>Список команд бота</b>\n\n"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Хэндлеры для управления сообщениями администратором
|
||||
"""
|
||||
from src.utils.errors import public_error
|
||||
from src.core.access import is_admin
|
||||
import logging
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
@@ -17,9 +19,6 @@ logger = logging.getLogger(__name__)
|
||||
message_admin_router = Router(name="message_admin")
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка, является ли пользователь администратором"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@message_admin_router.message(CaseInsensitiveCommand("delete"))
|
||||
@@ -28,7 +27,7 @@ async def delete_replied_message(message: Message):
|
||||
Удаление сообщения по команде /delete (регистронезависимо)
|
||||
Работает только если команда является ответом на сообщение бота
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
@@ -48,7 +47,7 @@ async def delete_replied_message(message: Message):
|
||||
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
await message.answer(f"❌ Не удалось удалить сообщение: {str(e)}")
|
||||
await message.answer(f"❌ Не удалось удалить сообщение: {public_error(e)}")
|
||||
|
||||
|
||||
@message_admin_router.callback_query(F.data == "delete_message")
|
||||
@@ -56,7 +55,7 @@ async def delete_message_callback(callback: CallbackQuery):
|
||||
"""
|
||||
Удаление сообщения по нажатию кнопки
|
||||
"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -66,7 +65,7 @@ async def delete_message_callback(callback: CallbackQuery):
|
||||
logger.info(f"Администратор {callback.from_user.id} удалил сообщение через кнопку")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
await callback.answer(f"❌ Ошибка: {public_error(e)}", show_alert=True)
|
||||
|
||||
|
||||
# Функция-фильтр для проверки триггерных слов
|
||||
@@ -93,7 +92,7 @@ async def quick_delete_replied_message(message: Message):
|
||||
|
||||
Удаляет сообщение у всех получателей broadcast рассылки
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
return # Не админ - пропускаем
|
||||
|
||||
try:
|
||||
@@ -115,7 +114,8 @@ async def quick_delete_replied_message(message: Message):
|
||||
|
||||
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
||||
session,
|
||||
telegram_message_id=replied_msg.message_id
|
||||
telegram_message_id=replied_msg.message_id,
|
||||
chat_id=message.chat.id,
|
||||
)
|
||||
|
||||
# Если нашли broadcast сообщение - удаляем у всех получателей
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Обработчики P2P чата между пользователями"""
|
||||
from src.utils.errors import public_error
|
||||
from src.utils.telegram_messages import copy_preserving_entities
|
||||
from html import escape
|
||||
from src.core.access import is_admin
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -25,9 +29,6 @@ class P2PChatStates(StatesGroup):
|
||||
chatting = State() # В процессе переписки с пользователем
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
def format_sender_name(user: User, is_current_user: bool = False, current_user_is_admin: bool = False) -> str:
|
||||
@@ -105,7 +106,7 @@ async def show_chat_menu(message: Message, state: FSMContext):
|
||||
)]
|
||||
]
|
||||
|
||||
if is_admin(message.from_user.id):
|
||||
if await is_admin(message.from_user.id):
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text="📢 Написать всем (broadcast)",
|
||||
callback_data="p2p:broadcast"
|
||||
@@ -126,7 +127,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||||
async with async_session_maker() as session:
|
||||
# Получаем всех зарегистрированных пользователей кроме себя
|
||||
users = await UserService.get_all_users(session)
|
||||
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
|
||||
users = [u for u in users if u.telegram_id != callback.from_user.id and (u.is_registered or u.is_admin or u.telegram_id in ADMIN_IDS)]
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
||||
@@ -136,7 +137,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||||
buttons = []
|
||||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||||
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
||||
if user.club_card_number:
|
||||
if user.club_card_number and await is_admin(callback.from_user.id):
|
||||
display_name += f" (карта: {user.club_card_number})"
|
||||
|
||||
buttons.append([InlineKeyboardButton(
|
||||
@@ -201,10 +202,10 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
||||
# Определяем имя отправителя
|
||||
is_current = msg.sender_id == sender.id
|
||||
user_for_display = sender if is_current else recipient
|
||||
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
|
||||
sender_name = format_sender_name(user_for_display, is_current, await is_admin(sender.telegram_id))
|
||||
|
||||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||
text += f"• {sender_name}: {msg_text}\n"
|
||||
text += f"• {escape(sender_name)}: {escape(msg_text)}\n"
|
||||
text += "\n"
|
||||
|
||||
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
||||
@@ -267,12 +268,12 @@ async def show_conversations(callback: CallbackQuery):
|
||||
callback_data=f"p2p:user:{peer.id}"
|
||||
)])
|
||||
|
||||
text += f"{icon} <b>{peer_name}</b>"
|
||||
text += f"{icon} <b>{escape(peer_name)}</b>"
|
||||
# Показываем номер карты если есть
|
||||
if peer.club_card_number:
|
||||
text += f" (карта: {peer.club_card_number})"
|
||||
if peer.club_card_number and await is_admin(callback.from_user.id):
|
||||
text += f" (карта: {escape(peer.club_card_number)})"
|
||||
text += "\n"
|
||||
text += f" {preview}\n"
|
||||
text += f" {escape(preview)}\n"
|
||||
if unread > 0:
|
||||
text += f" 📨 Непрочитанных: {unread}\n"
|
||||
text += "\n"
|
||||
@@ -384,13 +385,13 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
recipient = await UserService.get_user_by_telegram_id(session, recipient_telegram_id)
|
||||
|
||||
# Формируем подпись сообщения для получателя
|
||||
if sender.is_admin:
|
||||
if await is_admin(sender.telegram_id):
|
||||
sender_name = "АДМИН"
|
||||
else:
|
||||
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
|
||||
sender_name = sender.nickname or (f"@{sender.username}" if sender.username else sender.first_name) or "Участник"
|
||||
|
||||
# Добавляем карту если получатель админ
|
||||
if recipient and recipient.is_admin and sender.club_card_number:
|
||||
if recipient and await is_admin(recipient.telegram_id) and sender.club_card_number:
|
||||
sender_name += f" (карта: {sender.club_card_number})"
|
||||
|
||||
# Определяем тип сообщения
|
||||
@@ -411,35 +412,11 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
file_id = message.document.file_id
|
||||
text = message.caption
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Отправляем сообщение получателю
|
||||
try:
|
||||
if message_type == "text":
|
||||
sent = await message.bot.send_message(
|
||||
recipient_telegram_id,
|
||||
f"<b>{sender_name}</b>\n\n{text}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "photo":
|
||||
sent = await message.bot.send_photo(
|
||||
recipient_telegram_id,
|
||||
photo=file_id,
|
||||
caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "video":
|
||||
sent = await message.bot.send_video(
|
||||
recipient_telegram_id,
|
||||
video=file_id,
|
||||
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "document":
|
||||
sent = await message.bot.send_document(
|
||||
recipient_telegram_id,
|
||||
document=file_id,
|
||||
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
sent = await copy_preserving_entities(message, recipient_telegram_id, sender_name)
|
||||
|
||||
# Сохраняем в БД
|
||||
await P2PMessageService.send_message(
|
||||
@@ -456,4 +433,14 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
await message.answer("✅ Сообщение доставлено")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Не удалось доставить сообщение: {e}")
|
||||
await message.answer(f"❌ Не удалось доставить сообщение: {public_error(e)}")
|
||||
|
||||
|
||||
|
||||
@router.callback_query(F.data == "p2p:broadcast")
|
||||
async def open_broadcast(callback: CallbackQuery, state: FSMContext):
|
||||
if not await is_admin(callback.from_user.id):
|
||||
await callback.answer("Недостаточно прав", show_alert=True)
|
||||
return
|
||||
from src.handlers.admin_panel import admin_broadcast_menu
|
||||
await admin_broadcast_menu(callback, state)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
||||
from src.utils.errors import public_error
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command
|
||||
@@ -70,7 +71,10 @@ async def check_unclaimed_winners(message: Message):
|
||||
for winner in winners:
|
||||
if not winner.is_claimed and winner.is_notified:
|
||||
# Проверяем, прошло ли 24 часа
|
||||
time_passed = now - winner.created_at
|
||||
created = winner.created_at
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
time_passed = now - created
|
||||
if time_passed.total_seconds() > 24 * 3600: # 24 часа
|
||||
unclaimed.append({
|
||||
'winner': winner,
|
||||
@@ -116,195 +120,25 @@ async def check_unclaimed_winners(message: Message):
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
await message.answer(f"❌ Ошибка: {public_error(e)}")
|
||||
|
||||
|
||||
@router.message(CaseInsensitiveCommand("redraw"))
|
||||
@admin_only
|
||||
async def redraw_lottery(message: Message):
|
||||
"""
|
||||
Переиграть розыгрыш для неподтвержденных выигрышей (регистронезависимо)
|
||||
Формат: /redraw <lottery_id>
|
||||
"""
|
||||
|
||||
from src.core.redraw_services import redraw_unclaimed
|
||||
from src.utils.notifications import notify_winners_async
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /redraw <lottery_id>"
|
||||
)
|
||||
if len(parts) != 2 or not parts[1].isdigit():
|
||||
await message.answer("Формат: /redraw ID_РОЗЫГРЫША")
|
||||
return
|
||||
lottery_id = int(parts[1])
|
||||
async with async_session_maker() as session:
|
||||
winners = await redraw_unclaimed(session, lottery_id)
|
||||
if winners:
|
||||
await notify_winners_async(message.bot, session, lottery_id)
|
||||
await message.answer(f"Повторно разыграно призов: {len(winners)}. Учитываются уведомлённые победители, не подтвердившие выигрыш за 24 часа.")
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[1])
|
||||
except ValueError:
|
||||
await message.answer("❌ lottery_id должен быть числом")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
from src.core.models import Lottery
|
||||
|
||||
# Загружаем розыгрыш с участниками
|
||||
lottery_result = await session.execute(
|
||||
select(Lottery)
|
||||
.options(selectinload(Lottery.participations))
|
||||
.where(Lottery.id == lottery_id)
|
||||
)
|
||||
lottery = lottery_result.scalar_one_or_none()
|
||||
|
||||
if not lottery:
|
||||
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
|
||||
return
|
||||
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
# Находим неподтвержденные выигрыши старше 24 часов
|
||||
now = datetime.now(timezone.utc)
|
||||
unclaimed_winners = []
|
||||
|
||||
for winner in winners:
|
||||
if not winner.is_claimed and winner.is_notified:
|
||||
time_passed = now - winner.created_at
|
||||
if time_passed.total_seconds() > 24 * 3600:
|
||||
unclaimed_winners.append(winner)
|
||||
|
||||
if not unclaimed_winners:
|
||||
await message.answer(
|
||||
"✅ Нет неподтвержденных выигрышей старше 24 часов.\n"
|
||||
"Повторный розыгрыш не требуется."
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем всех участников, исключая текущих победителей
|
||||
all_participants = []
|
||||
current_winner_accounts = set()
|
||||
|
||||
for winner in winners:
|
||||
if winner.account_number:
|
||||
current_winner_accounts.add(winner.account_number)
|
||||
|
||||
for p in lottery.participations:
|
||||
if p.account_number and p.account_number not in current_winner_accounts:
|
||||
all_participants.append(p)
|
||||
|
||||
if not all_participants:
|
||||
await message.answer(
|
||||
"❌ Нет доступных участников для повторного розыгрыша.\n"
|
||||
"Все участники уже являются победителями."
|
||||
)
|
||||
return
|
||||
|
||||
# Переигрываем каждое неподтвержденное место
|
||||
redraw_results = []
|
||||
|
||||
for old_winner in unclaimed_winners:
|
||||
if not all_participants:
|
||||
break
|
||||
|
||||
# Выбираем нового победителя
|
||||
new_participant = random.choice(all_participants)
|
||||
all_participants.remove(new_participant)
|
||||
|
||||
# Удаляем старого победителя
|
||||
await session.delete(old_winner)
|
||||
|
||||
# Создаем нового победителя
|
||||
new_winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=None,
|
||||
account_number=new_participant.account_number,
|
||||
account_id=new_participant.account_id,
|
||||
place=old_winner.place,
|
||||
prize=old_winner.prize,
|
||||
is_manual=False,
|
||||
is_notified=False,
|
||||
is_claimed=False
|
||||
)
|
||||
session.add(new_winner)
|
||||
|
||||
redraw_results.append({
|
||||
'place': old_winner.place,
|
||||
'prize': old_winner.prize,
|
||||
'old_account': old_winner.account_number,
|
||||
'new_account': new_participant.account_number
|
||||
})
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Отправляем уведомления новым победителям
|
||||
for result in redraw_results:
|
||||
# Находим нового победителя
|
||||
new_winner_result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.place == result['place'],
|
||||
Winner.account_number == result['new_account']
|
||||
)
|
||||
)
|
||||
)
|
||||
new_winner = new_winner_result.scalar_one_or_none()
|
||||
|
||||
if new_winner:
|
||||
# Отправляем уведомление новому победителю
|
||||
owner = await AccountService.get_account_owner(session, new_winner.account_number)
|
||||
|
||||
if owner and owner.telegram_id:
|
||||
# Создаем токен верификации
|
||||
await WinnerNotificationService.create_verification_token(
|
||||
session,
|
||||
new_winner.id
|
||||
)
|
||||
|
||||
# Формируем сообщение
|
||||
notification_message = (
|
||||
f"🎉 Поздравляем! Ваш счет выиграл!\n\n"
|
||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
f"🏆 Место: {new_winner.place}\n"
|
||||
f"🎁 Приз: {new_winner.prize}\n"
|
||||
f"💳 Счет: {new_winner.account_number}\n\n"
|
||||
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
|
||||
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="✅ Подтвердить получение приза",
|
||||
callback_data=f"confirm_win_{new_winner.id}"
|
||||
)]
|
||||
])
|
||||
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
notification_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
new_winner.is_notified = True
|
||||
await session.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Формируем отчет для админа
|
||||
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
|
||||
text += f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
text += f"📊 Переиграно мест: {len(redraw_results)}\n\n"
|
||||
|
||||
for result in redraw_results:
|
||||
text += f"🏆 {result['place']} место - {result['prize']}\n"
|
||||
text += f" ❌ Было: {result['old_account']}\n"
|
||||
text += f" ✅ Стало: {result['new_account']}\n\n"
|
||||
|
||||
text += "📨 Новым победителям отправлены уведомления"
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_win_"))
|
||||
@@ -341,10 +175,16 @@ async def confirm_winner_callback(callback_query):
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
else:
|
||||
owner = await session.get(User, winner.user_id) if winner.user_id else None
|
||||
if not owner or owner.telegram_id != callback_query.from_user.id:
|
||||
await callback_query.answer("❌ Это приз другого пользователя", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем срок действия (24 часа с момента создания winner)
|
||||
if winner.created_at:
|
||||
time_since_creation = datetime.now(timezone.utc) - winner.created_at
|
||||
created_at = winner.created_at.replace(tzinfo=timezone.utc) if winner.created_at.tzinfo is None else winner.created_at
|
||||
time_since_creation = datetime.now(timezone.utc) - created_at
|
||||
if time_since_creation > timedelta(hours=24):
|
||||
await callback_query.answer(
|
||||
"❌ Срок подтверждения истёк (24 часа). Приз будет разыгран заново.",
|
||||
@@ -353,8 +193,17 @@ async def confirm_winner_callback(callback_query):
|
||||
return
|
||||
|
||||
# Подтверждаем выигрыш
|
||||
winner.is_claimed = True
|
||||
winner.claimed_at = datetime.now(timezone.utc)
|
||||
from src.core.models import Lottery
|
||||
if not await session.scalar(select(Lottery.is_completed).where(Lottery.id == winner.lottery_id)):
|
||||
await callback_query.answer("Розыгрыш ещё не завершён", show_alert=True)
|
||||
return
|
||||
from sqlalchemy import update
|
||||
changed = await session.execute(update(Winner).where(Winner.id == winner_id, Winner.is_claimed.is_(False))
|
||||
.values(is_claimed=True, claimed_at=datetime.now(timezone.utc)))
|
||||
if changed.rowcount != 1:
|
||||
await session.rollback()
|
||||
await callback_query.answer("Приз уже подтвержден", show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
# Получаем данные о розыгрыше и пользователе
|
||||
@@ -419,7 +268,6 @@ async def confirm_winner_callback(callback_query):
|
||||
await bot.send_message(admin_id, admin_text, parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {e}")
|
||||
logging.getLogger(__name__).error(f"Ошибка отправки админу {admin_id}: {public_error(e)}")
|
||||
|
||||
await callback_query.answer("✅ Выигрыш подтвержден!", show_alert=True)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Обработчики для регистрации пользователей"""
|
||||
from src.utils.errors import public_error
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command, StateFilter
|
||||
@@ -19,6 +20,22 @@ logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
async def begin_registration(message: Message, state: FSMContext, actor=None):
|
||||
actor = actor or message.from_user
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_or_create_user(session, actor.id, actor.username, actor.first_name, actor.last_name)
|
||||
if user.is_registered:
|
||||
await message.answer("Вы уже зарегистрированы. /my_accounts — ваши счета.")
|
||||
return
|
||||
await state.clear()
|
||||
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||
await message.answer(
|
||||
"📝 Регистрация\n\nШаг 1 из 3: введите никнейм для чата (от 2 до 20 символов).",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]]),
|
||||
)
|
||||
|
||||
|
||||
# Служебные слова, которые нельзя использовать как никнейм
|
||||
FORBIDDEN_NICKNAMES = [
|
||||
'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро',
|
||||
@@ -56,6 +73,8 @@ def validate_nickname(nickname: str) -> tuple[bool, str]:
|
||||
if nickname.startswith('/'):
|
||||
return False, "❌ Никнейм не может начинаться с '/'"
|
||||
|
||||
if not all(char.isalnum() or char in " _-" for char in nickname):
|
||||
return False, "❌ Используйте буквы, цифры, пробел, дефис или подчёркивание"
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -68,29 +87,12 @@ class RegistrationStates(StatesGroup):
|
||||
|
||||
@router.callback_query(F.data == "start_registration")
|
||||
async def start_registration(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начать процесс регистрации"""
|
||||
logger.info(f"Получен запрос на регистрацию от пользователя {callback.from_user.id}")
|
||||
|
||||
text = (
|
||||
"📝 Регистрация в системе\n\n"
|
||||
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
||||
"Шаг 1 из 3: Придумайте никнейм\n\n"
|
||||
"🎭 Введите ваш никнейм для чата:\n"
|
||||
"• От 2 до 20 символов\n"
|
||||
"• Может содержать буквы, цифры, пробелы\n"
|
||||
"• Это имя будут видеть другие участники"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
||||
])
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||
await callback.answer()
|
||||
await begin_registration(callback.message, state, actor=callback.from_user)
|
||||
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_nickname))
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_nickname), F.text)
|
||||
async def process_nickname(message: Message, state: FSMContext):
|
||||
"""Обработка никнейма"""
|
||||
nickname = message.text.strip()
|
||||
@@ -116,7 +118,7 @@ async def process_nickname(message: Message, state: FSMContext):
|
||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
||||
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_club_card), F.text)
|
||||
async def process_club_card(message: Message, state: FSMContext):
|
||||
"""Обработка номера клубной карты"""
|
||||
club_card_number = message.text.strip()
|
||||
@@ -143,7 +145,7 @@ async def process_club_card(message: Message, state: FSMContext):
|
||||
await state.set_state(RegistrationStates.waiting_for_phone)
|
||||
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone), F.text)
|
||||
async def process_phone(message: Message, state: FSMContext):
|
||||
"""Обработка номера телефона"""
|
||||
phone_input = message.text.strip()
|
||||
@@ -196,7 +198,7 @@ async def process_phone(message: Message, state: FSMContext):
|
||||
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
||||
await message.answer(f"❌ Произошла ошибка: {public_error(e)}")
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ async def cmd_test_start(message: Message):
|
||||
"""Тестовая команда /test_start (регистронезависимо)"""
|
||||
user_id = message.from_user.id
|
||||
first_name = message.from_user.first_name
|
||||
is_admin_user = is_admin(user_id)
|
||||
is_admin_user = await is_admin(user_id)
|
||||
|
||||
welcome_text = f"👋 Привет, {first_name}!\n\n"
|
||||
welcome_text += "🎯 Это тестовая версия команды /start\n\n"
|
||||
@@ -51,7 +51,7 @@ async def cmd_test_start(message: Message):
|
||||
@test_router.message(CaseInsensitiveCommand("test_admin"))
|
||||
async def cmd_test_admin(message: Message):
|
||||
"""Тестовая команда /test_admin (регистронезависимо)"""
|
||||
if not is_admin(message.from_user.id):
|
||||
if not await is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
@@ -88,7 +88,7 @@ async def back_to_main_handler(callback: CallbackQuery):
|
||||
await callback.answer()
|
||||
|
||||
user_id = callback.from_user.id
|
||||
is_admin_user = is_admin(user_id)
|
||||
is_admin_user = await is_admin(user_id)
|
||||
|
||||
text = f"🏠 Главное меню\n\nВаш ID: {user_id}\n"
|
||||
text += f"Статус: {'👑 Администратор' if is_admin_user else '👤 Пользователь'}"
|
||||
@@ -107,4 +107,4 @@ async def back_to_main_handler(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
)
|
||||
)
|
||||
|
||||
49
src/middlewares/access.py
Normal file
49
src/middlewares/access.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Check every staff message/callback, including unfinished FSM dialogs."""
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from src.core.access import current_role, get_role
|
||||
|
||||
|
||||
class AccessMiddleware(BaseMiddleware):
|
||||
def __init__(self, *, allow_cashier=False, public_handlers=()):
|
||||
self.allow_cashier = allow_cashier
|
||||
self.public_handlers = set(public_handlers)
|
||||
|
||||
async def __call__(self, handler, event, data):
|
||||
selected = data.get("handler")
|
||||
if selected and selected.callback.__name__ in self.public_handlers:
|
||||
return await handler(event, data)
|
||||
actor = event.from_user
|
||||
role = await get_role(actor.id) if actor else "user"
|
||||
allowed = {"super_admin", "admin"}
|
||||
if self.allow_cashier:
|
||||
allowed.add("cashier")
|
||||
if role not in allowed:
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer("❌ Недостаточно прав", show_alert=True)
|
||||
else:
|
||||
await event.answer("❌ Недостаточно прав")
|
||||
return
|
||||
token = current_role.set((actor.id, role))
|
||||
try:
|
||||
return await handler(event, data)
|
||||
finally:
|
||||
current_role.reset(token)
|
||||
|
||||
|
||||
class ChatAccessMiddleware(BaseMiddleware):
|
||||
async def __call__(self, handler, event, data):
|
||||
from src.core.access import is_admin
|
||||
from src.core.chat_services import ChatPermissionService
|
||||
from src.core.database import async_session_maker
|
||||
admin = await is_admin(event.from_user.id)
|
||||
async with async_session_maker() as session:
|
||||
allowed, reason = await ChatPermissionService.can_send_message(session, event.from_user.id, is_admin=admin)
|
||||
if not allowed:
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer(reason, show_alert=True)
|
||||
else:
|
||||
await event.answer(reason)
|
||||
return
|
||||
return await handler(event, data)
|
||||
@@ -39,7 +39,6 @@ class ActivityMiddleware(BaseMiddleware):
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Обновляем активность
|
||||
await ActivityService.update_user_activity(session, telegram_id)
|
||||
|
||||
# Проверяем, не был ли пользователь заблокирован за неактивность
|
||||
# Если был - реактивируем
|
||||
|
||||
38
src/middlewares/runtime.py
Normal file
38
src/middlewares/runtime.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Keep sensitive dialogs private and report unexpected failures safely."""
|
||||
import logging
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrivateDialogMiddleware(BaseMiddleware):
|
||||
async def __call__(self, handler, event, data):
|
||||
message = event if isinstance(event, Message) else getattr(event, "message", None)
|
||||
if message and message.chat.type != "private":
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer("Откройте личный диалог с ботом", show_alert=True)
|
||||
return
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
class ErrorMiddleware(BaseMiddleware):
|
||||
async def __call__(self, handler, event, data):
|
||||
try:
|
||||
return await handler(event, data)
|
||||
except TelegramBadRequest as error:
|
||||
if "message is not modified" in str(error):
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer()
|
||||
return
|
||||
logger.exception("Telegram rejected a bot response")
|
||||
except Exception:
|
||||
logger.exception("Update handler failed")
|
||||
message = event.message if isinstance(event, CallbackQuery) else event
|
||||
if message:
|
||||
try:
|
||||
await message.answer("Не удалось выполнить действие. Попробуйте ещё раз или отправьте /cancel.")
|
||||
except Exception:
|
||||
logger.warning("Failed to deliver error response")
|
||||
@@ -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