bugfix
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-08 17:45:08 +09:00
parent 931235ff36
commit 062b782fb7
7 changed files with 275 additions and 27 deletions

View File

@@ -18,7 +18,7 @@ from ..core.database import async_session_maker
from ..core.services import UserService, LotteryService, ParticipationService
from ..core.chat_services import ChatMessageService
from ..core.config import ADMIN_IDS
from ..core.models import User, Lottery, Participation, Account, ChatMessage
from ..core.models import User, Lottery, Participation, Account, ChatMessage, Winner
logger = logging.getLogger(__name__)
@@ -3052,7 +3052,8 @@ async def export_data(callback: CallbackQuery):
text += f"🏆 Победителей: {winners_count}\n\n"
text += f"✅ Данные экспортированы в файл:\n{filename}"
await callback.message.edit_text(
await safe_edit_message(
callback,
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Экспортировать снова", callback_data="admin_export_data")],

View File

@@ -3,7 +3,9 @@ from aiogram import Router, F
from aiogram.types import Message
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from typing import List, Dict, Optional
from typing import List, Dict, Optional, Set
from collections import deque
import time
from src.core.chat_services import (
ChatSettingsService,
@@ -14,6 +16,7 @@ from src.core.chat_services import (
from src.core.services import UserService
from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS
from src.utils.account_utils import parse_accounts_from_message
def is_admin(user_id: int) -> bool:
@@ -21,22 +24,54 @@ def is_admin(user_id: int) -> bool:
return user_id in ADMIN_IDS
def _contains_account_numbers(text: str) -> bool:
"""Проверка содержит ли текст номера счетов"""
if not text:
return False
accounts = parse_accounts_from_message(text)
return len(accounts) > 0
router = Router(name='chat_router')
# Настройки для планировщика рассылки
BATCH_SIZE = 20 # Количество сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
# Защита от дубликатов сообщений (храним последние 100 message_id)
_processed_messages: deque = deque(maxlen=100)
def _is_message_processed(message_id: int) -> bool:
"""Проверка, было ли сообщение уже обработано"""
if message_id in _processed_messages:
return True
_processed_messages.append(message_id)
return False
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех зарегистрированных пользователей для рассылки"""
"""Получить всех пользователей для рассылки (зарегистрированные + админы)"""
users = await UserService.get_all_users(session)
return [u for u in users if u.is_registered] # Используем is_registered вместо is_active
# Рассылаем зарегистрированным пользователям И админам (даже если они не зарегистрированы)
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
async def broadcast_message_with_scheduler(message: Message, exclude_user_id: Optional[int] = None) -> tuple[Dict[str, int], int, int]:
async def broadcast_message_with_scheduler(
message: Message,
exclude_user_id: Optional[int] = None,
admin_only: bool = False,
sender_info: Optional[str] = None
) -> tuple[Dict[str, int], int, int]:
"""
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
Args:
message: Сообщение для рассылки
exclude_user_id: ID пользователя для исключения
admin_only: Рассылать только админам
sender_info: Информация об отправителе (для показа админам)
Возвращает: (forwarded_ids, success_count, fail_count)
"""
async with async_session_maker() as session:
@@ -45,6 +80,10 @@ async def broadcast_message_with_scheduler(message: Message, exclude_user_id: Op
if exclude_user_id:
users = [u for u in users if u.telegram_id != exclude_user_id]
# Если только для админов - фильтруем
if admin_only:
users = [u for u in users if u.telegram_id in ADMIN_IDS]
forwarded_ids = {}
success_count = 0
fail_count = 0
@@ -56,7 +95,11 @@ async def broadcast_message_with_scheduler(message: Message, exclude_user_id: Op
# Отправляем пакет
tasks = []
for user in batch:
tasks.append(_send_message_to_user(message, user.telegram_id))
# Для админов добавляем информацию об отправителе, если сообщение от обычного пользователя
if sender_info and user.telegram_id in ADMIN_IDS:
tasks.append(_send_message_to_admin_with_sender(message, user.telegram_id, sender_info))
else:
tasks.append(_send_message_to_user(message, user.telegram_id))
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -91,6 +134,85 @@ async def _send_message_to_user(message: Message, user_telegram_id: int) -> Opti
return None
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
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
"""Переслать сообщение в канал/группу"""
try:
@@ -107,8 +229,21 @@ async def handle_text_message(message: Message):
"""Обработчик текстовых сообщений"""
import logging
logger = logging.getLogger(__name__)
# Защита от дубликатов - если сообщение уже обработано, пропускаем
if _is_message_processed(message.message_id):
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
return
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
# Это сообщение будет обработано account_router для добавления в розыгрыш
if 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 # Пропускаем - обработает account_router
# БЫСТРОЕ УДАЛЕНИЕ: Если админ отвечает на сообщение словом "удалить"/"del"/"-"
if message.reply_to_message and is_admin(message.from_user.id):
if message.text and message.text.lower().strip() in ['удалить', 'del', '-']:
@@ -229,8 +364,20 @@ async def handle_text_message(message: Message):
# Обрабатываем в зависимости от режима
if settings.mode == 'broadcast':
# Режим рассылки с планировщиком
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
# Формируем информацию об отправителе для админов (если это не админ)
sender_info = None
if not is_admin(message.from_user.id):
sender_name = f"@{user.username}" if user.username else user.first_name
if user.club_card_number:
sender_name += f" (карта: {user.club_card_number})"
sender_info = sender_name
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
sender_info=sender_info
)
# Сохраняем сообщение в историю
await ChatMessageService.save_message(
@@ -277,6 +424,10 @@ async def handle_text_message(message: Message):
@router.message(F.photo)
async def handle_photo_message(message: Message):
"""Обработчик фото"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
@@ -298,11 +449,19 @@ async def handle_photo_message(message: Message):
photo = message.photo[-1]
if settings.mode == 'broadcast':
# Отправляем только админам
# Формируем информацию об отправителе для админов (если это не админ)
sender_info = None
if not is_admin(message.from_user.id):
sender_name = f"@{user.username}" if user.username else user.first_name
if user.club_card_number:
sender_name += f" (карта: {user.club_card_number})"
sender_info = sender_name
# Рассылаем фото - ВСЕГДА исключаем отправителя
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
admin_only=True
sender_info=sender_info
)
await ChatMessageService.save_message(
@@ -317,7 +476,7 @@ async def handle_photo_message(message: Message):
# Показываем статистику только админам
if is_admin(message.from_user.id):
await message.answer(f"✅ Фото отправлено админам: {success}")
await message.answer(f"✅ Фото разослано: {success} получателей")
elif settings.mode == 'forward':
if settings.forward_chat_id:
@@ -339,6 +498,10 @@ async def handle_photo_message(message: Message):
@router.message(F.video)
async def handle_video_message(message: Message):
"""Обработчик видео"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
@@ -357,8 +520,19 @@ async def handle_video_message(message: Message):
return
if settings.mode == 'broadcast':
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
# Формируем информацию об отправителе для админов (если это не админ)
sender_info = None
if not is_admin(message.from_user.id):
sender_name = f"@{user.username}" if user.username else user.first_name
if user.club_card_number:
sender_name += f" (карта: {user.club_card_number})"
sender_info = sender_name
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
sender_info=sender_info
)
await ChatMessageService.save_message(
session,
@@ -394,6 +568,10 @@ async def handle_video_message(message: Message):
@router.message(F.document)
async def handle_document_message(message: Message):
"""Обработчик документов"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
@@ -412,8 +590,19 @@ async def handle_document_message(message: Message):
return
if settings.mode == 'broadcast':
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
# Формируем информацию об отправителе для админов (если это не админ)
sender_info = None
if not is_admin(message.from_user.id):
sender_name = f"@{user.username}" if user.username else user.first_name
if user.club_card_number:
sender_name += f" (карта: {user.club_card_number})"
sender_info = sender_name
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
sender_info=sender_info
)
await ChatMessageService.save_message(
session,
@@ -449,6 +638,10 @@ async def handle_document_message(message: Message):
@router.message(F.animation)
async def handle_animation_message(message: Message):
"""Обработчик GIF анимаций"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
@@ -467,8 +660,19 @@ async def handle_animation_message(message: Message):
return
if settings.mode == 'broadcast':
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
# Формируем информацию об отправителе для админов (если это не админ)
sender_info = None
if not is_admin(message.from_user.id):
sender_name = f"@{user.username}" if user.username else user.first_name
if user.club_card_number:
sender_name += f" (карта: {user.club_card_number})"
sender_info = sender_name
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
sender_info=sender_info
)
await ChatMessageService.save_message(
session,
@@ -504,6 +708,10 @@ async def handle_animation_message(message: Message):
@router.message(F.sticker)
async def handle_sticker_message(message: Message):
"""Обработчик стикеров"""
# Защита от дубликатов
if _is_message_processed(message.message_id):
return
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
@@ -522,8 +730,19 @@ async def handle_sticker_message(message: Message):
return
if settings.mode == 'broadcast':
# НЕ исключаем отправителя - админ должен видеть все сообщения
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=None)
# Формируем информацию об отправителе для админов (если это не админ)
sender_info = None
if not is_admin(message.from_user.id):
sender_name = f"@{user.username}" if user.username else user.first_name
if user.club_card_number:
sender_name += f" (карта: {user.club_card_number})"
sender_info = sender_name
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
message,
exclude_user_id=message.from_user.id,
sender_info=sender_info
)
await ChatMessageService.save_message(
session,