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: