diff --git a/.env_example b/.env_example index c2311c2..f3237de 100644 --- a/.env_example +++ b/.env_example @@ -244,10 +244,16 @@ POSTS_DIR=posts # 📌 ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ # ═══════════════════════════════════════════════════════════════════════════ -# Добавьте сюда свои кастомные переменные по необходимости -# Например: +# === BACKGROUND TASKS === +# Интервал очистки временных банвордов (часы) +CLEANUP_INTERVAL=1 + +# Интервал backup базы данных (часы) +BACKUP_INTERVAL=24 + +# Количество хранимых backup'ов +KEEP_BACKUPS=7 + +# Возраст старой статистики для удаления (дни) +STATS_MAX_AGE_DAYS=30 -# DATABASE_URL=postgresql://user:password@localhost/dbname -# REDIS_URL=redis://localhost:6379 -# MAX_WARNINGS=3 -# BAN_DURATION=86400 diff --git a/bot/handlers/commands/users/start_cmd.py b/bot/handlers/commands/users/start_cmd.py index 12cd9d0..8a5464e 100644 --- a/bot/handlers/commands/users/start_cmd.py +++ b/bot/handlers/commands/users/start_cmd.py @@ -93,7 +93,8 @@ async def start_cmd(update: Message | CallbackQuery) -> None: help_text += ( "🔇 Режим тишины:\n" "/silence минуты — удалять ВСЕ сообщения\n" - "/unsilence — отключить режим тишины\n\n" + "/unsilence — отключить режим тишины\n" + "/report — отправить репорт\n\n" ) help_text += ( diff --git a/bot/tasks/__init__.py b/bot/tasks/__init__.py new file mode 100644 index 0000000..2a37c00 --- /dev/null +++ b/bot/tasks/__init__.py @@ -0,0 +1,4 @@ +""" +Модуль фоновых задач для бота +""" +from .cleanup import * diff --git a/bot/tasks/cleanup.py b/bot/tasks/cleanup.py new file mode 100644 index 0000000..5033258 --- /dev/null +++ b/bot/tasks/cleanup.py @@ -0,0 +1,263 @@ +""" +Фоновые задачи для автоматической очистки и обслуживания бота +""" +import asyncio +from datetime import datetime +from pathlib import Path +import shutil + +from database import get_manager +from middleware.loggers import logger +from configs import settings + +__all__ = ('cleanup_expired_banwords', 'cleanup_spam_stats', 'auto_backup_database', 'start_background_tasks', 'check_system_health') + + +async def cleanup_expired_banwords() -> None: + """ + Периодически удаляет истёкшие временные банворды. + + Запускается каждый час и проверяет все временные слова. + Удаляет те, у которых истёк срок действия. + """ + logger.info("🔄 Запущена задача автоочистки временных банвордов", log_type="CLEANUP") + + manager = get_manager() + + while True: + try: + # Ждём 1 час между проверками + await asyncio.sleep(3600) + + # Выполняем очистку + deleted = await manager.cleanup_expired_temp_words() + + if deleted > 0: + logger.info( + f"✅ Очищено {deleted} истёкших временных банвордов", + log_type="CLEANUP" + ) + else: + logger.debug( + "✓ Проверка временных банвордов: истёкших не найдено", + log_type="CLEANUP" + ) + + except Exception as e: + logger.error( + f"❌ Ошибка в задаче очистки банвордов: {e}", + log_type="CLEANUP" + ) + # При ошибке ждём 5 минут перед следующей попыткой + await asyncio.sleep(300) + + +async def cleanup_spam_stats(max_age_days: int = 30) -> None: + """ + Очищает старую статистику антиспама. + + Запускается каждые 24 часа и удаляет записи старше max_age_days дней. + + Args: + max_age_days: Возраст записей для удаления (по умолчанию 30 дней) + """ + logger.info( + f"🔄 Запущена задача очистки статистики (возраст > {max_age_days} дней)", + log_type="CLEANUP" + ) + + from bot.middlewares.spam_mdw import spam_stats + + while True: + try: + # Ждём 24 часа между проверками + await asyncio.sleep(86400) + + # Очищаем старую статистику + max_age_seconds = max_age_days * 86400 + deleted = spam_stats.cleanup(max_age=max_age_seconds) + + if deleted > 0: + logger.info( + f"✅ Очищена статистика: {deleted} неактивных пользователей", + log_type="CLEANUP" + ) + else: + logger.debug( + "✓ Проверка статистики: старых записей не найдено", + log_type="CLEANUP" + ) + + except Exception as e: + logger.error( + f"❌ Ошибка в задаче очистки статистики: {e}", + log_type="CLEANUP" + ) + await asyncio.sleep(3600) # При ошибке ждём 1 час + + +async def auto_backup_database(backup_interval_hours: int = 24, keep_backups: int = 7) -> None: + """ + Автоматически создаёт резервные копии базы данных. + + Args: + backup_interval_hours: Интервал между бэкапами (по умолчанию 24 часа) + keep_backups: Количество хранимых копий (по умолчанию 7) + """ + logger.info( + f"🔄 Запущена задача автобэкапа (каждые {backup_interval_hours}ч, хранить {keep_backups})", + log_type="CLEANUP" + ) + + while True: + try: + # Ждём указанное время + await asyncio.sleep(backup_interval_hours * 3600) + + # Путь к базе данных + db_path = Path(settings.DATABASE_PATH) + + if not db_path.exists(): + logger.warning( + f"⚠️ База данных не найдена: {db_path}", + log_type="CLEANUP" + ) + continue + + # Создаём папку для бэкапов + backup_dir = Path("backups") + backup_dir.mkdir(exist_ok=True) + + # Имя файла бэкапа с датой и временем + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = backup_dir / f"banwords_backup_{timestamp}.db" + + # Копируем базу данных + shutil.copy2(db_path, backup_path) + + # Получаем размер файла + backup_size = backup_path.stat().st_size / 1024 # KB + + logger.info( + f"✅ Создан backup: {backup_path.name} ({backup_size:.1f} KB)", + log_type="CLEANUP" + ) + + # Удаляем старые бэкапы (оставляем только последние N) + backups = sorted(backup_dir.glob("banwords_backup_*.db")) + + if len(backups) > keep_backups: + old_backups = backups[:-keep_backups] + + for old_backup in old_backups: + old_backup.unlink() + logger.debug( + f"🗑️ Удалён старый backup: {old_backup.name}", + log_type="CLEANUP" + ) + + logger.info( + f"✓ Удалено старых бэкапов: {len(old_backups)}", + log_type="CLEANUP" + ) + + except Exception as e: + logger.error( + f"❌ Ошибка создания backup: {e}", + log_type="CLEANUP" + ) + await asyncio.sleep(3600) # При ошибке ждём 1 час + + +async def check_system_health() -> None: + """ + Мониторинг здоровья системы. + + Проверяет каждые 5 минут: + - Размер базы данных + - Количество записей в БД + - Использование памяти (статистика антиспама) + """ + logger.info("🔄 Запущена задача мониторинга системы", log_type="CLEANUP") + + manager = get_manager() + + while True: + try: + await asyncio.sleep(300) # 5 минут + + # Проверяем размер БД + db_path = Path(settings.DATABASE_PATH) + if db_path.exists(): + db_size_mb = db_path.stat().st_size / (1024 * 1024) + + if db_size_mb > 100: # Если больше 100 MB + logger.warning( + f"⚠️ Большой размер БД: {db_size_mb:.2f} MB", + log_type="CLEANUP" + ) + + # Проверяем количество временных слов + stats = await manager.get_stats() + temp_count = stats.get('temp_total', 0) + + if temp_count > 100: # Если больше 100 временных слов + logger.warning( + f"⚠️ Много временных банвордов: {temp_count}", + log_type="CLEANUP" + ) + + # Проверяем статистику антиспама + from bot.middlewares.spam_mdw import spam_stats + spam_summary = spam_stats.get_stats_summary() + + active_blocks = spam_summary.get('active_blocks', 0) + if active_blocks > 10: + logger.warning( + f"⚠️ Много активных блокировок: {active_blocks}", + log_type="CLEANUP" + ) + + except Exception as e: + logger.error( + f"❌ Ошибка в задаче мониторинга: {e}", + log_type="CLEANUP" + ) + await asyncio.sleep(600) # При ошибке ждём 10 минут + + +def start_background_tasks() -> list[asyncio.Task]: + """ + Запускает все фоновые задачи. + + Returns: + List[asyncio.Task]: Список запущенных задач + """ + tasks = [] + + # 1. Автоочистка временных банвордов (каждый час) + task1 = asyncio.create_task(cleanup_expired_banwords()) + tasks.append(task1) + logger.info("✅ Задача 'cleanup_expired_banwords' запущена", log_type="STARTUP") + + # 2. Очистка старой статистики (каждые 24 часа) + task2 = asyncio.create_task(cleanup_spam_stats(max_age_days=30)) + tasks.append(task2) + logger.info("✅ Задача 'cleanup_spam_stats' запущена", log_type="STARTUP") + + # 3. Автобэкап базы данных (каждые 24 часа, хранить 7 копий) + task3 = asyncio.create_task(auto_backup_database(backup_interval_hours=24, keep_backups=7)) + tasks.append(task3) + logger.info("✅ Задача 'auto_backup_database' запущена", log_type="STARTUP") + + # 4. Мониторинг здоровья системы (каждые 5 минут) + task4 = asyncio.create_task(check_system_health()) + tasks.append(task4) + logger.info("✅ Задача 'check_system_health' запущена", log_type="STARTUP") + + logger.success( + f"🚀 Все фоновые задачи запущены: {len(tasks)} шт.", + log_type="STARTUP" + ) + + return tasks diff --git a/configs/config.py b/configs/config.py index 1f5af80..9e8af4f 100644 --- a/configs/config.py +++ b/configs/config.py @@ -106,7 +106,7 @@ class _Settings(BaseSettings): raise ValueError("PREFIX должен содержать хотя бы один символ") return cleaned - @field_validator('LOG_DIR', 'LOG_FILE_INFO', 'POSTS_DIR', mode='before') + @field_validator('LOG_DIR', 'LOG_FILE_INFO', mode='before') def validate_paths(cls, v: Any) -> Path: return Path(v) if isinstance(v, str) else v @@ -149,10 +149,6 @@ class _Settings(BaseSettings): if self.LOG_FILE: self.LOG_DIR.mkdir(parents=True, exist_ok=True) - # ✅ Создание директории для постов - if not self.POSTS_DIR.exists(): - self.POSTS_DIR.mkdir(parents=True, exist_ok=True) - return self @model_validator(mode='after') @@ -212,7 +208,6 @@ settings = _Settings() BOT_TOKEN = settings.active_bot_token ADMIN_CHAT_ID = settings.ADMIN_CHAT_ID SUPER_ADMIN_IDS = settings.super_admin_ids -WORDS_FILE = settings.WORDS_FILE # Экспорт __all__ = ( @@ -220,5 +215,4 @@ __all__ = ( 'BOT_TOKEN', 'ADMIN_CHAT_ID', 'SUPER_ADMIN_IDS', - 'WORDS_FILE', ) diff --git a/database/manager.py b/database/manager.py index 1a98295..e1dcc39 100644 --- a/database/manager.py +++ b/database/manager.py @@ -520,6 +520,65 @@ class BanWordsManager: ) return [] + async def cleanup_expired_temp_words(self) -> int: + """ + Удаляет истёкшие временные банворды. + + Returns: + int: Количество удалённых слов + """ + async with self.session_maker() as session: + try: + now = datetime.now(timezone.utc) + + # Ищем истёкшие временные слова + query = select(TempBanWord).where( + TempBanWord.expires_at < now + ) + result = await session.execute(query) + expired_words = result.scalars().all() + + if not expired_words: + return 0 + + # Собираем информацию для логирования + expired_info = [] + for word in expired_words: + expired_info.append({ + 'word': word.word, + 'type': word.word_type.value, + 'expires_at': word.expires_at + }) + await session.delete(word) + + # Сохраняем изменения + await session.commit() + + # Обновляем кеш + await self._reload_cache() + + # Логируем подробности + logger.info( + f"Удалено {len(expired_words)} истёкших временных банвордов", + log_type="DATABASE" + ) + + for info in expired_info: + logger.debug( + f" └─ {info['type']}: '{info['word']}' (истёк: {info['expires_at']})", + log_type="DATABASE" + ) + + return len(expired_words) + + except Exception as e: + logger.error( + f"Ошибка удаления истёкших временных слов: {e}", + log_type="DATABASE" + ) + await session.rollback() + return 0 + async def get_total_spam_count(self) -> int: """ Получает общее количество удалённых сообщений. diff --git a/main.py b/main.py index f040a64..d94f888 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,8 @@ Точка входа PrimoGuard Bot """ from asyncio import run +import asyncio +from typing import List from configs import settings from bot import bot, dp, BotInfo, WebhookManager, setup_middlewares, router @@ -11,6 +13,35 @@ from middleware.loggers import logger __all__ = ("main",) +def _start_background_tasks_safe() -> List[asyncio.Task]: + """ + Безопасный запуск фоновых задач с обработкой ошибок. + + Returns: + List[asyncio.Task]: Список запущенных задач (пустой список если модуль не найден) + """ + try: + from bot.tasks import start_background_tasks + tasks = start_background_tasks() + logger.info( + f"🚀 Запущено {len(tasks)} фоновых задач", + log_type="STARTUP" + ) + return tasks + except ImportError: + logger.warning( + "⚠️ Модуль 'bot.tasks' не найден, фоновые задачи не запущены", + log_type="STARTUP" + ) + return [] + except Exception as e: + logger.error( + f"❌ Ошибка запуска фоновых задач: {e}", + log_type="STARTUP" + ) + return [] + + async def setup_services(setup_webhook: bool = True) -> str: """ Инициализация всех сервисов: БД и бот. @@ -55,7 +86,11 @@ async def on_startup(app) -> None: # 1. Инициализируем всё БЕЗ webhook username = await setup_services(setup_webhook=False) - # 2. ТЕПЕРЬ устанавливаем webhook (когда всё готово) + # 2. Запускаем фоновые задачи + background_tasks = _start_background_tasks_safe() + app['background_tasks'] = background_tasks + + # 3. ТЕПЕРЬ устанавливаем webhook (когда всё готово) webhook = WebhookManager(bot, dp) if settings.WEBHOOK_URL: @@ -87,10 +122,40 @@ async def on_shutdown(app) -> None: logger.info("👋 Остановка бота...", log_type="SHUTDOWN") try: + # Отменяем фоновые задачи + if 'background_tasks' in app and app['background_tasks']: + tasks = app['background_tasks'] + logger.info( + f"⏸️ Остановка {len(tasks)} фоновых задач...", + log_type="SHUTDOWN" + ) + + for task in tasks: + if not task.done(): + task.cancel() + + # Ждём завершения с таймаутом 5 секунд + try: + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), + timeout=5.0 + ) + logger.info("✅ Фоновые задачи остановлены", log_type="SHUTDOWN") + except asyncio.TimeoutError: + logger.warning( + "⚠️ Таймаут остановки фоновых задач (5 сек)", + log_type="SHUTDOWN" + ) + + # Закрываем соединения + logger.info("📊 Закрытие базы данных...", log_type="SHUTDOWN") await get_manager().close() + + logger.info("🤖 Закрытие сессии бота...", log_type="SHUTDOWN") await bot.session.close() + except Exception as e: - logger.error(f"Ошибка при закрытии: {e}", log_type="SHUTDOWN") + logger.error(f"❌ Ошибка при закрытии: {e}", log_type="SHUTDOWN") logger.success("✅ Бот остановлен", log_type="SHUTDOWN") @@ -117,10 +182,16 @@ async def start_polling() -> None: """Запуск в режиме Polling (асинхронный).""" logger.setup() + background_tasks: List[asyncio.Task] = [] + try: + # 1. Инициализируем сервисы username = await setup_services(setup_webhook=False) - # Удаляем webhook для polling режима + # 2. Запускаем фоновые задачи + background_tasks = _start_background_tasks_safe() + + # 3. Удаляем webhook для polling режима webhook = WebhookManager(bot, dp) await webhook.delete(drop_pending_updates=True) @@ -129,9 +200,12 @@ async def start_polling() -> None: log_type="STARTUP" ) - # Запускаем polling + # 4. Запускаем polling await dp.start_polling(bot, drop_pending_updates=True) + except KeyboardInterrupt: + logger.info("⚠️ Получен сигнал остановки (Ctrl+C)", log_type="MAIN") + except Exception as e: logger.critical( f"🔥 Критическая ошибка: {e}", @@ -140,11 +214,47 @@ async def start_polling() -> None: raise finally: + logger.info("🧹 Очистка ресурсов...", log_type="SHUTDOWN") + try: - await bot.session.close() + # Отменяем фоновые задачи + if background_tasks: + logger.info( + f"⏸️ Остановка {len(background_tasks)} фоновых задач...", + log_type="SHUTDOWN" + ) + + for task in background_tasks: + if not task.done(): + task.cancel() + + # Ждём завершения с таймаутом 5 секунд + try: + await asyncio.wait_for( + asyncio.gather(*background_tasks, return_exceptions=True), + timeout=5.0 + ) + logger.info("✅ Фоновые задачи остановлены", log_type="SHUTDOWN") + except asyncio.TimeoutError: + logger.warning( + "⚠️ Таймаут остановки фоновых задач (5 сек)", + log_type="SHUTDOWN" + ) + + # Закрываем соединения + logger.info("📊 Закрытие базы данных...", log_type="SHUTDOWN") await get_manager().close() - except: - pass + + logger.info("🤖 Закрытие сессии бота...", log_type="SHUTDOWN") + await bot.session.close() + + logger.success("✅ Бот остановлен", log_type="SHUTDOWN") + + except Exception as e: + logger.error( + f"❌ Ошибка при очистке ресурсов: {e}", + log_type="SHUTDOWN" + ) def main() -> None: @@ -152,9 +262,11 @@ def main() -> None: try: if settings.WEBHOOK: # ========== WEBHOOK РЕЖИМ ========== + logger.info("🔧 Режим: Webhook", log_type="MAIN") start_webhook() else: # ========== POLLING РЕЖИМ ========== + logger.info("🔧 Режим: Polling", log_type="MAIN") run(start_polling()) except KeyboardInterrupt: