From 062b782fb7f5d8aee5a97236a2a3969fd243f201 Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Sun, 8 Feb 2026 17:45:08 +0900 Subject: [PATCH] bugfix --- .env.prod | 6 +- export_20260208_174031.json | 9 ++ export_20260208_174208.json | 9 ++ export_20260208_174221.json | 9 ++ main.py | 9 +- src/handlers/admin_panel.py | 5 +- src/handlers/chat_handlers.py | 255 +++++++++++++++++++++++++++++++--- 7 files changed, 275 insertions(+), 27 deletions(-) create mode 100644 export_20260208_174031.json create mode 100644 export_20260208_174208.json create mode 100644 export_20260208_174221.json diff --git a/.env.prod b/.env.prod index 5a61e40..86afbde 100644 --- a/.env.prod +++ b/.env.prod @@ -6,16 +6,16 @@ BOT_TOKEN=8300330445:AAFyxAqtmWsgtSPa_nb-lH3Q4ovmn9Ei6rA # PostgreSQL настройки для внешней БД # Замените на данные вашего внешнего PostgreSQL сервера -POSTGRES_HOST=localhost +POSTGRES_HOST=192.168.0.102 POSTGRES_PORT=5432 -POSTGRES_DB=bot_db +POSTGRES_DB=lottery_bot POSTGRES_USER=trevor POSTGRES_PASSWORD=Cl0ud_1985! # Database URL для бота # Формат: postgresql+asyncpg://user:password@host:port/database # Для внешнего сервера укажите его 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 администраторов (через запятую) ADMIN_IDS=556399210,6639865742 diff --git a/export_20260208_174031.json b/export_20260208_174031.json new file mode 100644 index 0000000..a4b5941 --- /dev/null +++ b/export_20260208_174031.json @@ -0,0 +1,9 @@ +{ + "export_date": "2026-02-08T17:40:31.898764", + "statistics": { + "users": 3, + "lotteries": 1, + "participations": 1, + "winners": 0 + } +} \ No newline at end of file diff --git a/export_20260208_174208.json b/export_20260208_174208.json new file mode 100644 index 0000000..cb8ae81 --- /dev/null +++ b/export_20260208_174208.json @@ -0,0 +1,9 @@ +{ + "export_date": "2026-02-08T17:42:08.014799", + "statistics": { + "users": 3, + "lotteries": 1, + "participations": 1, + "winners": 0 + } +} \ No newline at end of file diff --git a/export_20260208_174221.json b/export_20260208_174221.json new file mode 100644 index 0000000..be48977 --- /dev/null +++ b/export_20260208_174221.json @@ -0,0 +1,9 @@ +{ + "export_date": "2026-02-08T17:42:21.844218", + "statistics": { + "users": 3, + "lotteries": 1, + "participations": 1, + "winners": 0 + } +} \ No newline at end of file diff --git a/main.py b/main.py index edaa25c..4b141e7 100644 --- a/main.py +++ b/main.py @@ -129,12 +129,13 @@ async def main(): dp.include_router(redraw_router) # Повторные розыгрыши dp.include_router(p2p_chat_router) # P2P чат между пользователями - # 3. Chat router для broadcast (ловит все необработанные сообщения) - dp.include_router(chat_router) # Пользовательский чат (broadcast всем) - - # 4. Account router ПОСЛЕДНИМ (обнаружение счетов для админов) + # 3. Account router ПЕРЕД chat_router (обнаружение счетов для админов) dp.include_router(account_router) # Пользовательские счета + обнаружение для админов + # 4. Chat router для broadcast (ловит все необработанные сообщения) + # chat_router пропускает сообщения со счетами от админов + dp.include_router(chat_router) # Пользовательский чат (broadcast всем) + # Запускаем polling try: logger.info("Бот запущен") diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index bf02edb..fb6058b 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -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")], diff --git a/src/handlers/chat_handlers.py b/src/handlers/chat_handlers.py index 2c6954a..74997a2 100644 --- a/src/handlers/chat_handlers.py +++ b/src/handlers/chat_handlers.py @@ -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"📨 Сообщение от {sender_info}:\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,