улучшенния в коде и исправление ошибок

This commit is contained in:
2026-02-17 11:47:40 +07:00
parent 1951a3c30c
commit 3adb131742
7 changed files with 460 additions and 21 deletions

View File

@@ -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

View File

@@ -93,7 +93,8 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
help_text += ( help_text += (
"🔇 <b>Режим тишины:</b>\n" "🔇 <b>Режим тишины:</b>\n"
"/silence <code>минуты</code> — удалять ВСЕ сообщения\n" "/silence <code>минуты</code> — удалять ВСЕ сообщения\n"
"/unsilence — отключить режим тишины\n\n" "/unsilence — отключить режим тишины\n"
"/report — отправить репорт\n\n"
) )
help_text += ( help_text += (

4
bot/tasks/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Модуль фоновых задач для бота
"""
from .cleanup import *

263
bot/tasks/cleanup.py Normal file
View File

@@ -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

View File

@@ -106,7 +106,7 @@ class _Settings(BaseSettings):
raise ValueError("PREFIX должен содержать хотя бы один символ") raise ValueError("PREFIX должен содержать хотя бы один символ")
return cleaned 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: def validate_paths(cls, v: Any) -> Path:
return Path(v) if isinstance(v, str) else v return Path(v) if isinstance(v, str) else v
@@ -149,10 +149,6 @@ class _Settings(BaseSettings):
if self.LOG_FILE: if self.LOG_FILE:
self.LOG_DIR.mkdir(parents=True, exist_ok=True) 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 return self
@model_validator(mode='after') @model_validator(mode='after')
@@ -212,7 +208,6 @@ settings = _Settings()
BOT_TOKEN = settings.active_bot_token BOT_TOKEN = settings.active_bot_token
ADMIN_CHAT_ID = settings.ADMIN_CHAT_ID ADMIN_CHAT_ID = settings.ADMIN_CHAT_ID
SUPER_ADMIN_IDS = settings.super_admin_ids SUPER_ADMIN_IDS = settings.super_admin_ids
WORDS_FILE = settings.WORDS_FILE
# Экспорт # Экспорт
__all__ = ( __all__ = (
@@ -220,5 +215,4 @@ __all__ = (
'BOT_TOKEN', 'BOT_TOKEN',
'ADMIN_CHAT_ID', 'ADMIN_CHAT_ID',
'SUPER_ADMIN_IDS', 'SUPER_ADMIN_IDS',
'WORDS_FILE',
) )

View File

@@ -520,6 +520,65 @@ class BanWordsManager:
) )
return [] 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: async def get_total_spam_count(self) -> int:
""" """
Получает общее количество удалённых сообщений. Получает общее количество удалённых сообщений.

126
main.py
View File

@@ -2,6 +2,8 @@
Точка входа PrimoGuard Bot Точка входа PrimoGuard Bot
""" """
from asyncio import run from asyncio import run
import asyncio
from typing import List
from configs import settings from configs import settings
from bot import bot, dp, BotInfo, WebhookManager, setup_middlewares, router from bot import bot, dp, BotInfo, WebhookManager, setup_middlewares, router
@@ -11,6 +13,35 @@ from middleware.loggers import logger
__all__ = ("main",) __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: async def setup_services(setup_webhook: bool = True) -> str:
""" """
Инициализация всех сервисов: БД и бот. Инициализация всех сервисов: БД и бот.
@@ -55,7 +86,11 @@ async def on_startup(app) -> None:
# 1. Инициализируем всё БЕЗ webhook # 1. Инициализируем всё БЕЗ webhook
username = await setup_services(setup_webhook=False) 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) webhook = WebhookManager(bot, dp)
if settings.WEBHOOK_URL: if settings.WEBHOOK_URL:
@@ -87,10 +122,40 @@ async def on_shutdown(app) -> None:
logger.info("👋 Остановка бота...", log_type="SHUTDOWN") logger.info("👋 Остановка бота...", log_type="SHUTDOWN")
try: 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() await get_manager().close()
logger.info("🤖 Закрытие сессии бота...", log_type="SHUTDOWN")
await bot.session.close() await bot.session.close()
except Exception as e: except Exception as e:
logger.error(f"Ошибка при закрытии: {e}", log_type="SHUTDOWN") logger.error(f"Ошибка при закрытии: {e}", log_type="SHUTDOWN")
logger.success("✅ Бот остановлен", log_type="SHUTDOWN") logger.success("✅ Бот остановлен", log_type="SHUTDOWN")
@@ -117,10 +182,16 @@ async def start_polling() -> None:
"""Запуск в режиме Polling (асинхронный).""" """Запуск в режиме Polling (асинхронный)."""
logger.setup() logger.setup()
background_tasks: List[asyncio.Task] = []
try: try:
# 1. Инициализируем сервисы
username = await setup_services(setup_webhook=False) username = await setup_services(setup_webhook=False)
# Удаляем webhook для polling режима # 2. Запускаем фоновые задачи
background_tasks = _start_background_tasks_safe()
# 3. Удаляем webhook для polling режима
webhook = WebhookManager(bot, dp) webhook = WebhookManager(bot, dp)
await webhook.delete(drop_pending_updates=True) await webhook.delete(drop_pending_updates=True)
@@ -129,9 +200,12 @@ async def start_polling() -> None:
log_type="STARTUP" log_type="STARTUP"
) )
# Запускаем polling # 4. Запускаем polling
await dp.start_polling(bot, drop_pending_updates=True) await dp.start_polling(bot, drop_pending_updates=True)
except KeyboardInterrupt:
logger.info("⚠️ Получен сигнал остановки (Ctrl+C)", log_type="MAIN")
except Exception as e: except Exception as e:
logger.critical( logger.critical(
f"🔥 Критическая ошибка: {e}", f"🔥 Критическая ошибка: {e}",
@@ -140,11 +214,47 @@ async def start_polling() -> None:
raise raise
finally: finally:
logger.info("🧹 Очистка ресурсов...", log_type="SHUTDOWN")
try: 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() 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: def main() -> None:
@@ -152,9 +262,11 @@ def main() -> None:
try: try:
if settings.WEBHOOK: if settings.WEBHOOK:
# ========== WEBHOOK РЕЖИМ ========== # ========== WEBHOOK РЕЖИМ ==========
logger.info("🔧 Режим: Webhook", log_type="MAIN")
start_webhook() start_webhook()
else: else:
# ========== POLLING РЕЖИМ ========== # ========== POLLING РЕЖИМ ==========
logger.info("🔧 Режим: Polling", log_type="MAIN")
run(start_polling()) run(start_polling())
except KeyboardInterrupt: except KeyboardInterrupt: