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

@@ -6,16 +6,16 @@ BOT_TOKEN=8300330445:AAFyxAqtmWsgtSPa_nb-lH3Q4ovmn9Ei6rA
# PostgreSQL настройки для внешней БД # PostgreSQL настройки для внешней БД
# Замените на данные вашего внешнего PostgreSQL сервера # Замените на данные вашего внешнего PostgreSQL сервера
POSTGRES_HOST=localhost POSTGRES_HOST=192.168.0.102
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=bot_db POSTGRES_DB=lottery_bot
POSTGRES_USER=trevor POSTGRES_USER=trevor
POSTGRES_PASSWORD=Cl0ud_1985! POSTGRES_PASSWORD=Cl0ud_1985!
# Database URL для бота # Database URL для бота
# Формат: postgresql+asyncpg://user:password@host:port/database # Формат: postgresql+asyncpg://user:password@host:port/database
# Для внешнего сервера укажите его IP или домен вместо localhost # Для внешнего сервера укажите его IP или домен вместо localhost
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@localhost:5432/bot_db DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/lottery_bot
# ID администраторов (через запятую) # ID администраторов (через запятую)
ADMIN_IDS=556399210,6639865742 ADMIN_IDS=556399210,6639865742

View File

@@ -0,0 +1,9 @@
{
"export_date": "2026-02-08T17:40:31.898764",
"statistics": {
"users": 3,
"lotteries": 1,
"participations": 1,
"winners": 0
}
}

View File

@@ -0,0 +1,9 @@
{
"export_date": "2026-02-08T17:42:08.014799",
"statistics": {
"users": 3,
"lotteries": 1,
"participations": 1,
"winners": 0
}
}

View File

@@ -0,0 +1,9 @@
{
"export_date": "2026-02-08T17:42:21.844218",
"statistics": {
"users": 3,
"lotteries": 1,
"participations": 1,
"winners": 0
}
}

View File

@@ -129,12 +129,13 @@ async def main():
dp.include_router(redraw_router) # Повторные розыгрыши dp.include_router(redraw_router) # Повторные розыгрыши
dp.include_router(p2p_chat_router) # P2P чат между пользователями dp.include_router(p2p_chat_router) # P2P чат между пользователями
# 3. Chat router для broadcast (ловит все необработанные сообщения) # 3. Account router ПЕРЕД chat_router (обнаружение счетов для админов)
dp.include_router(chat_router) # Пользовательский чат (broadcast всем)
# 4. Account router ПОСЛЕДНИМ (обнаружение счетов для админов)
dp.include_router(account_router) # Пользовательские счета + обнаружение для админов dp.include_router(account_router) # Пользовательские счета + обнаружение для админов
# 4. Chat router для broadcast (ловит все необработанные сообщения)
# chat_router пропускает сообщения со счетами от админов
dp.include_router(chat_router) # Пользовательский чат (broadcast всем)
# Запускаем polling # Запускаем polling
try: try:
logger.info("Бот запущен") logger.info("Бот запущен")

View File

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

View File

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