Первый коммит
This commit is contained in:
39
database/__init__.py
Normal file
39
database/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Database модуль для работы с банвордами в SQLite.
|
||||
|
||||
Использует SQLAlchemy ORM для async работы с БД.
|
||||
|
||||
Структура:
|
||||
- models.py: Модели таблиц (BanWord, TempBanWord, WhitelistWord, Admin, Setting, SpamStat)
|
||||
- database.py: Подключение к БД через SQLAlchemy
|
||||
- repository.py: CRUD операции через ORM
|
||||
- manager.py: Высокоуровневый API для handlers/middleware
|
||||
|
||||
Usage:
|
||||
from database import get_manager, BanWordType
|
||||
|
||||
# Инициализация
|
||||
manager = get_manager()
|
||||
await manager.init()
|
||||
|
||||
# Добавление банворда
|
||||
await manager.add_banword("спам", BanWordType.SUBSTRING, added_by=123)
|
||||
|
||||
# Проверка (из кэша - быстро)
|
||||
words = manager.get_banwords_cached(BanWordType.SUBSTRING)
|
||||
if "спам" in text and "спам" in words:
|
||||
await manager.log_spam(...)
|
||||
|
||||
# Режим тишины
|
||||
await manager.set_silence_mode(minutes=30)
|
||||
if await manager.is_silence_active():
|
||||
# Удаляем всё
|
||||
"""
|
||||
|
||||
from .models import *
|
||||
|
||||
from .database import *
|
||||
|
||||
from .repository import *
|
||||
|
||||
from .manager import *
|
||||
115
database/database.py
Normal file
115
database/database.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Управление SQLAlchemy движком и сессиями.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncSession,
|
||||
AsyncEngine
|
||||
)
|
||||
|
||||
from middleware.loggers import logger
|
||||
from .models import Base
|
||||
|
||||
__all__ = ("Database", "get_db")
|
||||
|
||||
|
||||
class Database:
|
||||
"""
|
||||
Менеджер SQLAlchemy базы данных.
|
||||
|
||||
Attributes:
|
||||
engine: Async движок SQLAlchemy
|
||||
session_factory: Фабрика сессий
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = "banwords.db"):
|
||||
"""
|
||||
Args:
|
||||
db_path: Путь к SQLite файлу
|
||||
"""
|
||||
# Создаём директорию если не существует
|
||||
db_file = Path(db_path)
|
||||
db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# SQLite URL для async
|
||||
db_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
# Создаём async движок
|
||||
self.engine: AsyncEngine = create_async_engine(
|
||||
db_url,
|
||||
echo=False, # Логирование SQL запросов (False для прода)
|
||||
future=True,
|
||||
pool_pre_ping=True, # Проверка соединения
|
||||
)
|
||||
|
||||
# Фабрика сессий
|
||||
self.session_factory = async_sessionmaker(
|
||||
self.engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"SQLAlchemy инициализирован: {db_path}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Создаёт все таблицы в БД"""
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info(
|
||||
"Таблицы базы данных созданы",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка создания таблиц: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает соединения с БД"""
|
||||
await self.engine.dispose()
|
||||
logger.info("База данных закрыта", log_type="DATABASE")
|
||||
|
||||
def get_session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Создаёт новую сессию (контекстный менеджер).
|
||||
|
||||
Usage:
|
||||
async with db.get_session() as session:
|
||||
result = await session.execute(select(BanWord))
|
||||
words = result.scalars().all()
|
||||
|
||||
Yields:
|
||||
AsyncSession: Сессия для работы с БД
|
||||
"""
|
||||
return self.session_factory()
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
_db_instance: Database | None = None
|
||||
|
||||
|
||||
def get_db(db_path: str = "banwords.db") -> Database:
|
||||
"""
|
||||
Возвращает глобальный экземпляр Database (Singleton).
|
||||
|
||||
Args:
|
||||
db_path: Путь к БД (используется только при первом вызове)
|
||||
|
||||
Returns:
|
||||
Database: Экземпляр базы данных
|
||||
"""
|
||||
global _db_instance
|
||||
if _db_instance is None:
|
||||
_db_instance = Database(db_path)
|
||||
return _db_instance
|
||||
582
database/manager.py
Normal file
582
database/manager.py
Normal file
@@ -0,0 +1,582 @@
|
||||
"""
|
||||
Высокоуровневый менеджер для работы с банвордами.
|
||||
Упрощает использование repository в handlers и middleware.
|
||||
"""
|
||||
from typing import Set, Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from middleware.loggers import logger
|
||||
from .database import Database, get_db
|
||||
from .repository import BanWordsRepository
|
||||
from .models import BanWordType, SpamStat, SpamLog
|
||||
|
||||
from sqlalchemy import select, delete, func, desc
|
||||
|
||||
__all__ = ("BanWordsManager", "get_manager")
|
||||
|
||||
|
||||
class BanWordsManager:
|
||||
"""
|
||||
Менеджер для удобной работы с банвордами.
|
||||
|
||||
Предоставляет упрощённый API для handlers и middleware.
|
||||
|
||||
Attributes:
|
||||
db: Экземпляр Database
|
||||
repo: Repository для CRUD операций
|
||||
"""
|
||||
|
||||
def __init__(self, db: Optional[Database] = None):
|
||||
"""
|
||||
Args:
|
||||
db: Экземпляр Database (если None, берётся глобальный)
|
||||
"""
|
||||
self.db = db or get_db()
|
||||
self.repo = BanWordsRepository(self.db)
|
||||
|
||||
# Кэш для часто используемых данных
|
||||
self._cache_banwords: Optional[dict] = None
|
||||
self._cache_whitelist: Optional[Set[str]] = None
|
||||
self._cache_admins: Optional[Set[int]] = None
|
||||
self._cache_updated_at: Optional[datetime] = None
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Инициализирует базу данных и загружает кэш"""
|
||||
await self.db.init()
|
||||
await self.refresh_cache()
|
||||
logger.info("BanWordsManager инициализирован", log_type="DATABASE")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает соединение с БД"""
|
||||
await self.db.close()
|
||||
|
||||
# === CACHE MANAGEMENT ===
|
||||
|
||||
async def refresh_cache(self) -> None:
|
||||
"""Обновляет кэш из БД"""
|
||||
try:
|
||||
self._cache_banwords = await self.repo.get_all_banwords()
|
||||
temp_banwords = await self.repo.get_all_temp_banwords()
|
||||
|
||||
# Объединяем постоянные и временные банворды
|
||||
for word_type, words in temp_banwords.items():
|
||||
if word_type in self._cache_banwords:
|
||||
self._cache_banwords[word_type] |= words
|
||||
|
||||
self._cache_whitelist = await self.repo.get_whitelist()
|
||||
self._cache_admins = await self.repo.get_admins()
|
||||
self._cache_updated_at = datetime.now()
|
||||
|
||||
logger.debug("Кэш банвордов обновлён", log_type="DATABASE")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления кэша: {e}", log_type="DATABASE")
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Сбрасывает кэш (требует refresh_cache)"""
|
||||
self._cache_banwords = None
|
||||
self._cache_whitelist = None
|
||||
self._cache_admins = None
|
||||
self._cache_updated_at = None
|
||||
|
||||
# === BANWORDS (с кэшем) ===
|
||||
|
||||
async def add_banword(
|
||||
self,
|
||||
word: str,
|
||||
word_type: BanWordType,
|
||||
added_by: Optional[int] = None,
|
||||
reason: Optional[str] = None,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Добавляет банворд и обновляет кэш.
|
||||
|
||||
Args:
|
||||
word: Слово
|
||||
word_type: Тип
|
||||
added_by: ID админа
|
||||
reason: Причина
|
||||
refresh_cache: Обновить кэш после добавления
|
||||
|
||||
Returns:
|
||||
bool: True если добавлен
|
||||
"""
|
||||
result = await self.repo.add_banword(word, word_type, added_by, reason)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
async def remove_banword(
|
||||
self,
|
||||
word: str,
|
||||
word_type: BanWordType,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""Удаляет банворд и обновляет кэш"""
|
||||
result = await self.repo.remove_banword(word, word_type)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
def get_banwords_cached(self, word_type: BanWordType) -> Set[str]:
|
||||
"""
|
||||
Получает банворды из кэша (быстро).
|
||||
|
||||
Args:
|
||||
word_type: Тип банвордов
|
||||
|
||||
Returns:
|
||||
Set[str]: Набор слов из кэша
|
||||
"""
|
||||
if self._cache_banwords is None:
|
||||
logger.warning("Кэш не инициализирован", log_type="DATABASE")
|
||||
return set()
|
||||
|
||||
return self._cache_banwords.get(word_type, set())
|
||||
|
||||
async def get_banwords(self, word_type: BanWordType) -> Set[str]:
|
||||
"""Получает банворды напрямую из БД (без кэша)"""
|
||||
return await self.repo.get_banwords(word_type)
|
||||
|
||||
# === TEMPORARY BANWORDS ===
|
||||
|
||||
async def add_temp_banword(
|
||||
self,
|
||||
word: str,
|
||||
word_type: BanWordType,
|
||||
minutes: int,
|
||||
added_by: Optional[int] = None,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""Добавляет временный банворд"""
|
||||
result = await self.repo.add_temp_banword(
|
||||
word, word_type, minutes, added_by
|
||||
)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
async def remove_temp_banword(
|
||||
self,
|
||||
word: str,
|
||||
word_type: BanWordType,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""Удаляет временный банворд"""
|
||||
result = await self.repo.remove_temp_banword(word, word_type)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup_expired(self) -> int:
|
||||
"""
|
||||
Очищает истёкшие временные банворды.
|
||||
Вызывается периодически (например, раз в минуту).
|
||||
|
||||
Returns:
|
||||
int: Количество удалённых записей
|
||||
"""
|
||||
deleted = await self.repo.cleanup_expired_temp_banwords()
|
||||
|
||||
if deleted > 0:
|
||||
await self.refresh_cache()
|
||||
|
||||
return deleted
|
||||
|
||||
# === WHITELIST ===
|
||||
|
||||
async def add_whitelist(
|
||||
self,
|
||||
word: str,
|
||||
added_by: Optional[int] = None,
|
||||
reason: Optional[str] = None,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""Добавляет слово в белый список"""
|
||||
result = await self.repo.add_whitelist(word, added_by, reason)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
async def remove_whitelist(
|
||||
self,
|
||||
word: str,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""Удаляет слово из белого списка"""
|
||||
result = await self.repo.remove_whitelist(word)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
def get_whitelist_cached(self) -> Set[str]:
|
||||
"""Получает белый список из кэша"""
|
||||
if self._cache_whitelist is None:
|
||||
logger.warning("Кэш whitelist не инициализирован", log_type="DATABASE")
|
||||
return set()
|
||||
|
||||
return self._cache_whitelist
|
||||
|
||||
def is_whitelisted(self, text: str) -> bool:
|
||||
"""
|
||||
Проверяет, содержит ли текст слово из белого списка.
|
||||
|
||||
Args:
|
||||
text: Текст для проверки (lowercase)
|
||||
|
||||
Returns:
|
||||
bool: True если найдено исключение
|
||||
"""
|
||||
whitelist = self.get_whitelist_cached()
|
||||
return any(word in text for word in whitelist)
|
||||
|
||||
# === ADMINS ===
|
||||
|
||||
async def add_admin(
|
||||
self,
|
||||
user_id: int,
|
||||
added_by: Optional[int] = None,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""Добавляет администратора"""
|
||||
result = await self.repo.add_admin(user_id, added_by)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
async def remove_admin(
|
||||
self,
|
||||
user_id: int,
|
||||
refresh_cache: bool = True
|
||||
) -> bool:
|
||||
"""Удаляет администратора"""
|
||||
result = await self.repo.remove_admin(user_id)
|
||||
|
||||
if result and refresh_cache:
|
||||
await self.refresh_cache()
|
||||
|
||||
return result
|
||||
|
||||
def get_admins_cached(self) -> Set[int]:
|
||||
"""Получает список админов из кэша"""
|
||||
if self._cache_admins is None:
|
||||
logger.warning("Кэш админов не инициализирован", log_type="DATABASE")
|
||||
return set()
|
||||
|
||||
return self._cache_admins
|
||||
|
||||
def is_admin_cached(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверяет, является ли пользователь админом (из кэша).
|
||||
|
||||
Args:
|
||||
user_id: Telegram ID
|
||||
|
||||
Returns:
|
||||
bool: True если админ
|
||||
"""
|
||||
return user_id in self.get_admins_cached()
|
||||
|
||||
async def is_admin(self, user_id: int) -> bool:
|
||||
"""Проверяет админа напрямую из БД"""
|
||||
return await self.repo.is_admin(user_id)
|
||||
|
||||
# === SETTINGS (режимы silence/conflict) ===
|
||||
|
||||
async def set_silence_mode(self, minutes: int) -> datetime:
|
||||
"""
|
||||
Включает режим тишины на указанное время.
|
||||
|
||||
Args:
|
||||
minutes: Длительность в минутах
|
||||
|
||||
Returns:
|
||||
datetime: Время окончания режима
|
||||
"""
|
||||
expires_at = datetime.now().timestamp() + (minutes * 60)
|
||||
await self.repo.set_setting("silence_until", str(expires_at))
|
||||
|
||||
logger.info(
|
||||
f"Режим тишины активирован на {minutes} мин",
|
||||
log_type="SILENCE"
|
||||
)
|
||||
|
||||
return datetime.fromtimestamp(expires_at)
|
||||
|
||||
async def disable_silence_mode(self) -> None:
|
||||
"""Отключает режим тишины"""
|
||||
await self.repo.delete_setting("silence_until")
|
||||
logger.info("Режим тишины отключён", log_type="SILENCE")
|
||||
|
||||
async def is_silence_active(self) -> bool:
|
||||
"""Проверяет, активен ли режим тишины"""
|
||||
silence_until_str = await self.repo.get_setting("silence_until")
|
||||
|
||||
if not silence_until_str:
|
||||
return False
|
||||
|
||||
try:
|
||||
silence_until = float(silence_until_str)
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
if now >= silence_until:
|
||||
# Время истекло - удаляем настройку
|
||||
await self.disable_silence_mode()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
async def set_conflict_mode(self, minutes: int) -> datetime:
|
||||
"""
|
||||
Включает режим антиконфликта на указанное время.
|
||||
|
||||
Args:
|
||||
minutes: Длительность в минутах
|
||||
|
||||
Returns:
|
||||
datetime: Время окончания режима
|
||||
"""
|
||||
expires_at = datetime.now().timestamp() + (minutes * 60)
|
||||
await self.repo.set_setting("conflict_until", str(expires_at))
|
||||
|
||||
logger.info(
|
||||
f"Режим антиконфликта активирован на {minutes} мин",
|
||||
log_type="CONFLICT"
|
||||
)
|
||||
|
||||
return datetime.fromtimestamp(expires_at)
|
||||
|
||||
async def disable_conflict_mode(self) -> None:
|
||||
"""Отключает режим антиконфликта"""
|
||||
await self.repo.delete_setting("conflict_until")
|
||||
logger.info("Режим антиконфликта отключён", log_type="CONFLICT")
|
||||
|
||||
async def is_conflict_active(self) -> bool:
|
||||
"""Проверяет, активен ли режим антиконфликта"""
|
||||
conflict_until_str = await self.repo.get_setting("conflict_until")
|
||||
|
||||
if not conflict_until_str:
|
||||
return False
|
||||
|
||||
try:
|
||||
conflict_until = float(conflict_until_str)
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
if now >= conflict_until:
|
||||
# Время истекло
|
||||
await self.disable_conflict_mode()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
# === STATISTICS ===
|
||||
|
||||
async def log_spam(
|
||||
self,
|
||||
user_id: int,
|
||||
username: str,
|
||||
chat_id: int,
|
||||
message_text: str,
|
||||
matched_word: str,
|
||||
match_type: str
|
||||
) -> None:
|
||||
"""Логирует удаление спам-сообщения"""
|
||||
await self.repo.log_spam_deletion(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
chat_id=chat_id,
|
||||
message_text=message_text,
|
||||
matched_word=matched_word,
|
||||
match_type=match_type
|
||||
)
|
||||
|
||||
async def get_spam_stats(
|
||||
self,
|
||||
limit: int = 100,
|
||||
user_id: Optional[int] = None
|
||||
) -> List[SpamStat]:
|
||||
"""Получает статистику удалений"""
|
||||
return await self.repo.get_spam_stats(limit, user_id)
|
||||
|
||||
async def get_user_spam_count(self, user_id: int) -> int:
|
||||
"""Получает количество удалённых сообщений пользователя"""
|
||||
return await self.repo.get_user_spam_count(user_id)
|
||||
|
||||
async def get_top_spammers(self, limit: int = 10) -> List[tuple[int, int]]:
|
||||
"""Получает топ спамеров"""
|
||||
return await self.repo.get_top_spammers(limit)
|
||||
|
||||
# === INFO ===
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
"""Получает общую статистику"""
|
||||
db_stats = await self.repo.get_stats()
|
||||
|
||||
# Добавляем информацию о кэше
|
||||
cache_info = {
|
||||
'cache_active': self._cache_banwords is not None,
|
||||
'cache_updated_at': self._cache_updated_at.isoformat() if self._cache_updated_at else None
|
||||
}
|
||||
|
||||
return {**db_stats, **cache_info}
|
||||
|
||||
async def get_all_words_list(self) -> dict:
|
||||
"""
|
||||
Получает все слова для команды /listwords.
|
||||
|
||||
Returns:
|
||||
dict: Словарь со всеми категориями слов
|
||||
"""
|
||||
banwords = await self.repo.get_all_banwords()
|
||||
temp_banwords = await self.repo.get_all_temp_banwords()
|
||||
whitelist = await self.repo.get_whitelist()
|
||||
admins = await self.repo.get_admins()
|
||||
|
||||
return {
|
||||
'substring': banwords.get(BanWordType.SUBSTRING, set()),
|
||||
'lemma': banwords.get(BanWordType.LEMMA, set()),
|
||||
'part': banwords.get(BanWordType.PART, set()),
|
||||
'conflict_substring': banwords.get(BanWordType.CONFLICT_SUBSTRING, set()),
|
||||
'conflict_lemma': banwords.get(BanWordType.CONFLICT_LEMMA, set()),
|
||||
'temp_substring': temp_banwords.get(BanWordType.SUBSTRING, set()),
|
||||
'temp_lemma': temp_banwords.get(BanWordType.LEMMA, set()),
|
||||
'whitelist': whitelist,
|
||||
'admins': admins
|
||||
}
|
||||
|
||||
async def get_top_words(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Получает топ N самых часто срабатывающих слов.
|
||||
|
||||
Args:
|
||||
limit: Количество слов в топе
|
||||
|
||||
Returns:
|
||||
List[Dict]: Список словарей с данными:
|
||||
- word: слово
|
||||
- count: количество срабатываний
|
||||
- type: тип проверки
|
||||
"""
|
||||
async with self.session_maker() as session:
|
||||
try:
|
||||
# Группируем по matched_word и считаем количество
|
||||
query = select(
|
||||
SpamLog.matched_word,
|
||||
SpamLog.match_type,
|
||||
func.count(SpamLog.id).label('count')
|
||||
).where(
|
||||
SpamLog.matched_word.isnot(None)
|
||||
).group_by(
|
||||
SpamLog.matched_word,
|
||||
SpamLog.match_type
|
||||
).order_by(
|
||||
desc('count')
|
||||
).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Форматируем результат
|
||||
top_words = []
|
||||
for row in rows:
|
||||
top_words.append({
|
||||
'word': row.matched_word,
|
||||
'type': row.match_type,
|
||||
'count': row.count
|
||||
})
|
||||
|
||||
logger.debug(
|
||||
f"Получен топ-{limit} слов: {len(top_words)} записей",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
|
||||
return top_words
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения топ-слов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return []
|
||||
|
||||
async def get_total_spam_count(self) -> int:
|
||||
"""
|
||||
Получает общее количество удалённых сообщений.
|
||||
|
||||
Returns:
|
||||
int: Количество записей в SpamLog
|
||||
"""
|
||||
async with self.session_maker() as session:
|
||||
try:
|
||||
query = select(func.count(SpamLog.id))
|
||||
result = await session.execute(query)
|
||||
count = result.scalar_one()
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка подсчёта спам-логов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return 0
|
||||
|
||||
async def reset_spam_stats(self) -> bool:
|
||||
"""
|
||||
Очищает всю статистику спама.
|
||||
|
||||
Returns:
|
||||
bool: True если успешно
|
||||
"""
|
||||
async with self.session_maker() as session:
|
||||
try:
|
||||
# Удаляем все записи
|
||||
await session.execute(delete(SpamLog))
|
||||
await session.commit()
|
||||
|
||||
logger.info("Статистика спама сброшена", log_type="DATABASE")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка сброса статистики: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера
|
||||
_manager_instance: Optional[BanWordsManager] = None
|
||||
|
||||
|
||||
def get_manager() -> BanWordsManager:
|
||||
"""
|
||||
Возвращает глобальный экземпляр BanWordsManager (Singleton).
|
||||
|
||||
Returns:
|
||||
BanWordsManager: Менеджер банвордов
|
||||
"""
|
||||
global _manager_instance
|
||||
if _manager_instance is None:
|
||||
_manager_instance = BanWordsManager()
|
||||
return _manager_instance
|
||||
51
database/migrate.py
Normal file
51
database/migrate.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Создайте файл database/migrate.py
|
||||
|
||||
"""
|
||||
Миграция: добавление полей matched_word и match_type в SpamLog
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from .manager import get_manager
|
||||
|
||||
|
||||
async def migrate():
|
||||
"""Добавляет поля matched_word и match_type если их нет"""
|
||||
manager = get_manager()
|
||||
await manager.init()
|
||||
|
||||
async with manager.session_maker() as session:
|
||||
try:
|
||||
# Проверяем наличие колонок
|
||||
result = await session.execute(
|
||||
text("PRAGMA table_info(spam_logs)")
|
||||
)
|
||||
columns = [row[1] for row in result.fetchall()]
|
||||
|
||||
if 'matched_word' not in columns:
|
||||
print("Добавляем колонку matched_word...")
|
||||
await session.execute(
|
||||
text("ALTER TABLE spam_logs ADD COLUMN matched_word VARCHAR(255)")
|
||||
)
|
||||
await session.commit()
|
||||
print("✅ Колонка matched_word добавлена")
|
||||
|
||||
if 'match_type' not in columns:
|
||||
print("Добавляем колонку match_type...")
|
||||
await session.execute(
|
||||
text("ALTER TABLE spam_logs ADD COLUMN match_type VARCHAR(50)")
|
||||
)
|
||||
await session.commit()
|
||||
print("✅ Колонка match_type добавлена")
|
||||
|
||||
print("✅ Миграция завершена успешно!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка миграции: {e}")
|
||||
await session.rollback()
|
||||
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(migrate())
|
||||
254
database/models.py
Normal file
254
database/models.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
SQLAlchemy модели для банвордов.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum as PyEnum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, Text, Enum, BigInteger
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
__all__ = (
|
||||
"Base",
|
||||
"BanWordType",
|
||||
"SpamMode",
|
||||
"BanWord",
|
||||
"TempBanWord",
|
||||
"WhitelistWord",
|
||||
"Admin",
|
||||
"Setting",
|
||||
"SpamStat",
|
||||
"SpamLog",
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Базовый класс для всех моделей"""
|
||||
pass
|
||||
|
||||
|
||||
class BanWordType(str, PyEnum):
|
||||
"""Типы банвордов"""
|
||||
SUBSTRING = "substring"
|
||||
LEMMA = "lemma"
|
||||
PART = "part"
|
||||
CONFLICT_SUBSTRING = "conflict_substring"
|
||||
CONFLICT_LEMMA = "conflict_lemma"
|
||||
|
||||
|
||||
class SpamMode(str, PyEnum):
|
||||
"""Режимы работы спам-фильтра"""
|
||||
NORMAL = "normal"
|
||||
SILENCE = "silence"
|
||||
CONFLICT = "conflict"
|
||||
|
||||
|
||||
class BanWord(Base):
|
||||
"""
|
||||
Постоянные банворды.
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID
|
||||
word: Само слово (lowercase)
|
||||
type: Тип банворда
|
||||
added_by: Telegram ID добавившего админа
|
||||
added_at: Дата добавления
|
||||
reason: Причина добавления
|
||||
"""
|
||||
__tablename__ = "banwords"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
type: Mapped[BanWordType] = mapped_column(
|
||||
Enum(BanWordType, native_enum=False),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
nullable=False
|
||||
)
|
||||
reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BanWord(word='{self.word}', type={self.type})>"
|
||||
|
||||
|
||||
class TempBanWord(Base):
|
||||
"""
|
||||
Временные банворды (с автоудалением).
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID
|
||||
word: Само слово
|
||||
type: Тип банворда
|
||||
added_by: ID админа
|
||||
added_at: Дата добавления
|
||||
expires_at: Дата истечения
|
||||
"""
|
||||
__tablename__ = "temp_banwords"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
type: Mapped[BanWordType] = mapped_column(
|
||||
Enum(BanWordType, native_enum=False),
|
||||
nullable=False
|
||||
)
|
||||
added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверяет, истёк ли срок"""
|
||||
return datetime.now() >= self.expires_at
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TempBanWord(word='{self.word}', expires={self.expires_at})>"
|
||||
|
||||
|
||||
class WhitelistWord(Base):
|
||||
"""
|
||||
Белый список (исключения из проверки).
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID
|
||||
word: Слово-исключение
|
||||
added_by: ID админа
|
||||
added_at: Дата добавления
|
||||
reason: Причина (например, "ложное срабатывание")
|
||||
"""
|
||||
__tablename__ = "whitelist"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
word: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||
added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
nullable=False
|
||||
)
|
||||
reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WhitelistWord(word='{self.word}')>"
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
"""
|
||||
Дополнительные администраторы бота.
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID записи
|
||||
user_id: Telegram ID пользователя (уникальный)
|
||||
added_by: ID суперадмина, который добавил
|
||||
added_at: Дата добавления
|
||||
permissions: JSON со списком прав (для будущего)
|
||||
"""
|
||||
__tablename__ = "admins"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, unique=True, index=True)
|
||||
added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
nullable=False
|
||||
)
|
||||
permissions: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
default="[]",
|
||||
nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Admin(user_id={self.user_id})>"
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""
|
||||
Настройки и состояния бота.
|
||||
|
||||
Attributes:
|
||||
key: Ключ настройки (primary key)
|
||||
value: Значение (JSON string)
|
||||
updated_at: Дата обновления
|
||||
|
||||
Examples:
|
||||
- silence_until: datetime ISO string
|
||||
- conflict_until: datetime ISO string
|
||||
- spam_mode: "normal"/"silence"/"conflict"
|
||||
"""
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
onupdate=datetime.now,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Setting(key='{self.key}', value='{self.value}')>"
|
||||
|
||||
|
||||
class SpamStat(Base):
|
||||
"""
|
||||
Статистика удалённых спам-сообщений.
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID
|
||||
user_id: Telegram ID отправителя
|
||||
username: Username отправителя
|
||||
chat_id: ID чата
|
||||
message_text: Текст сообщения (до 500 символов)
|
||||
matched_word: Слово, по которому сработал фильтр
|
||||
match_type: Тип проверки (substring/lemma/part)
|
||||
deleted_at: Дата удаления
|
||||
"""
|
||||
__tablename__ = "spam_stats"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
chat_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
message_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
matched_word: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
match_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
deleted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SpamStat(user_id={self.user_id}, word='{self.matched_word}')>"
|
||||
|
||||
|
||||
class SpamLog(Base):
|
||||
"""Модель для логирования срабатываний спам-фильтра"""
|
||||
__tablename__ = "spam_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(255), nullable=True)
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
message_text: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
matched_word: Mapped[str] = mapped_column(String(255), nullable=True) # <-- Должно быть!
|
||||
match_type: Mapped[str] = mapped_column(String(50), nullable=True) # <-- Должно быть!
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
798
database/repository.py
Normal file
798
database/repository.py
Normal file
@@ -0,0 +1,798 @@
|
||||
"""
|
||||
Repository для работы с банвордами через SQLAlchemy ORM.
|
||||
"""
|
||||
from typing import Set, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select, delete, func, and_
|
||||
|
||||
from middleware.loggers import logger
|
||||
from .database import Database
|
||||
from .models import (
|
||||
BanWord,
|
||||
TempBanWord,
|
||||
WhitelistWord,
|
||||
Admin,
|
||||
Setting,
|
||||
SpamStat,
|
||||
BanWordType
|
||||
)
|
||||
|
||||
__all__ = ("BanWordsRepository",)
|
||||
|
||||
|
||||
class BanWordsRepository:
|
||||
"""
|
||||
Repository для CRUD операций с банвордами.
|
||||
|
||||
Все методы работают через SQLAlchemy ORM.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Database):
|
||||
"""
|
||||
Args:
|
||||
db: Экземпляр Database
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
# === BANWORDS ===
|
||||
|
||||
async def add_banword(
|
||||
self,
|
||||
word: str,
|
||||
word_type: BanWordType,
|
||||
added_by: Optional[int] = None,
|
||||
reason: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Добавляет постоянный банворд.
|
||||
|
||||
Args:
|
||||
word: Слово для блокировки
|
||||
word_type: Тип банворда
|
||||
added_by: ID админа, который добавил
|
||||
reason: Причина добавления
|
||||
|
||||
Returns:
|
||||
bool: True если добавлен, False если уже существует
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
# Проверяем, существует ли уже
|
||||
existing = await session.execute(
|
||||
select(BanWord).where(
|
||||
and_(
|
||||
BanWord.word == word.lower(),
|
||||
BanWord.type == word_type
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False
|
||||
|
||||
# Добавляем новый
|
||||
banword = BanWord(
|
||||
word=word.lower(),
|
||||
type=word_type,
|
||||
added_by=added_by,
|
||||
reason=reason
|
||||
)
|
||||
session.add(banword)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Добавлен банворд: '{word}' ({word_type.value})",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка добавления банворда: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def remove_banword(self, word: str, word_type: BanWordType) -> bool:
|
||||
"""
|
||||
Удаляет банворд.
|
||||
|
||||
Args:
|
||||
word: Слово
|
||||
word_type: Тип
|
||||
|
||||
Returns:
|
||||
bool: True если удалён
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
delete(BanWord).where(
|
||||
and_(
|
||||
BanWord.word == word.lower(),
|
||||
BanWord.type == word_type
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = result.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
f"Удалён банворд: '{word}' ({word_type.value})",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка удаления банворда: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_banwords(self, word_type: BanWordType) -> Set[str]:
|
||||
"""
|
||||
Получает все банворды определённого типа.
|
||||
|
||||
Args:
|
||||
word_type: Тип банвордов
|
||||
|
||||
Returns:
|
||||
Set[str]: Набор слов
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(BanWord.word).where(BanWord.type == word_type)
|
||||
)
|
||||
return set(result.scalars().all())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения банвордов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return set()
|
||||
|
||||
async def get_all_banwords(self) -> dict[BanWordType, Set[str]]:
|
||||
"""
|
||||
Получает все банворды, сгруппированные по типам.
|
||||
|
||||
Returns:
|
||||
dict: {BanWordType: Set[str]}
|
||||
"""
|
||||
result = {
|
||||
BanWordType.SUBSTRING: set(),
|
||||
BanWordType.LEMMA: set(),
|
||||
BanWordType.PART: set(),
|
||||
BanWordType.CONFLICT_SUBSTRING: set(),
|
||||
BanWordType.CONFLICT_LEMMA: set(),
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
banwords = await session.execute(select(BanWord))
|
||||
for banword in banwords.scalars():
|
||||
result[banword.type].add(banword.word)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения всех банвордов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def search_banwords(self, query: str, limit: int = 50) -> List[BanWord]:
|
||||
"""
|
||||
Поиск банвордов по частичному совпадению.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
limit: Максимум результатов
|
||||
|
||||
Returns:
|
||||
List[BanWord]: Найденные банворды
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(BanWord)
|
||||
.where(BanWord.word.contains(query.lower()))
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка поиска банвордов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return []
|
||||
|
||||
# === TEMPORARY BANWORDS ===
|
||||
|
||||
async def add_temp_banword(
|
||||
self,
|
||||
word: str,
|
||||
word_type: BanWordType,
|
||||
minutes: int,
|
||||
added_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Добавляет временный банворд.
|
||||
|
||||
Args:
|
||||
word: Слово
|
||||
word_type: Тип
|
||||
minutes: Длительность в минутах
|
||||
added_by: ID админа
|
||||
|
||||
Returns:
|
||||
bool: True если добавлен
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
# Проверяем существование
|
||||
existing = await session.execute(
|
||||
select(TempBanWord).where(
|
||||
and_(
|
||||
TempBanWord.word == word.lower(),
|
||||
TempBanWord.type == word_type
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False
|
||||
|
||||
# Добавляем
|
||||
expires_at = datetime.now() + timedelta(minutes=minutes)
|
||||
temp_banword = TempBanWord(
|
||||
word=word.lower(),
|
||||
type=word_type,
|
||||
added_by=added_by,
|
||||
expires_at=expires_at
|
||||
)
|
||||
session.add(temp_banword)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Добавлен временный банворд: '{word}' на {minutes} мин",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка добавления временного банворда: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def remove_temp_banword(self, word: str, word_type: BanWordType) -> bool:
|
||||
"""Удаляет временный банворд досрочно"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
delete(TempBanWord).where(
|
||||
and_(
|
||||
TempBanWord.word == word.lower(),
|
||||
TempBanWord.type == word_type
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = result.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
f"Удалён временный банворд: '{word}'",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка удаления временного банворда: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_temp_banwords(self, word_type: BanWordType) -> Set[str]:
|
||||
"""
|
||||
Получает активные (не истёкшие) временные банворды.
|
||||
|
||||
Args:
|
||||
word_type: Тип банвордов
|
||||
|
||||
Returns:
|
||||
Set[str]: Набор активных временных слов
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(TempBanWord.word).where(
|
||||
and_(
|
||||
TempBanWord.type == word_type,
|
||||
TempBanWord.expires_at > datetime.now()
|
||||
)
|
||||
)
|
||||
)
|
||||
return set(result.scalars().all())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения временных банвордов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return set()
|
||||
|
||||
async def get_all_temp_banwords(self) -> dict[BanWordType, Set[str]]:
|
||||
"""Получает все активные временные банворды по типам"""
|
||||
result = {
|
||||
BanWordType.SUBSTRING: set(),
|
||||
BanWordType.LEMMA: set(),
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
temp_banwords = await session.execute(
|
||||
select(TempBanWord).where(
|
||||
TempBanWord.expires_at > datetime.now()
|
||||
)
|
||||
)
|
||||
for temp_banword in temp_banwords.scalars():
|
||||
if temp_banword.type in result:
|
||||
result[temp_banword.type].add(temp_banword.word)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения всех временных банвордов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup_expired_temp_banwords(self) -> int:
|
||||
"""
|
||||
Удаляет истёкшие временные банворды.
|
||||
|
||||
Returns:
|
||||
int: Количество удалённых записей
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
delete(TempBanWord).where(
|
||||
TempBanWord.expires_at <= datetime.now()
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = result.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(
|
||||
f"Удалено {deleted} истёкших временных банвордов",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка очистки временных банвордов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return 0
|
||||
|
||||
# === WHITELIST ===
|
||||
|
||||
async def add_whitelist(
|
||||
self,
|
||||
word: str,
|
||||
added_by: Optional[int] = None,
|
||||
reason: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Добавляет слово в белый список (исключение)"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
# Проверяем существование
|
||||
existing = await session.execute(
|
||||
select(WhitelistWord).where(
|
||||
WhitelistWord.word == word.lower()
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False
|
||||
|
||||
# Добавляем
|
||||
whitelist_word = WhitelistWord(
|
||||
word=word.lower(),
|
||||
added_by=added_by,
|
||||
reason=reason
|
||||
)
|
||||
session.add(whitelist_word)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Добавлено исключение: '{word}'",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка добавления исключения: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def remove_whitelist(self, word: str) -> bool:
|
||||
"""Удаляет слово из белого списка"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
delete(WhitelistWord).where(
|
||||
WhitelistWord.word == word.lower()
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = result.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
f"Удалено исключение: '{word}'",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка удаления исключения: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_whitelist(self) -> Set[str]:
|
||||
"""Получает все слова из белого списка"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(select(WhitelistWord.word))
|
||||
return set(result.scalars().all())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения whitelist: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return set()
|
||||
|
||||
# === ADMINS ===
|
||||
|
||||
async def add_admin(
|
||||
self,
|
||||
user_id: int,
|
||||
added_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""Добавляет администратора"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
# Проверяем существование
|
||||
existing = await session.execute(
|
||||
select(Admin).where(Admin.user_id == user_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False
|
||||
|
||||
# Добавляем
|
||||
admin = Admin(user_id=user_id, added_by=added_by)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Добавлен админ: {user_id}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка добавления админа: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def remove_admin(self, user_id: int) -> bool:
|
||||
"""Удаляет администратора"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
delete(Admin).where(Admin.user_id == user_id)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = result.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
f"Удалён админ: {user_id}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка удаления админа: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_admins(self) -> Set[int]:
|
||||
"""Получает всех администраторов"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(select(Admin.user_id))
|
||||
return set(result.scalars().all())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения админов: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return set()
|
||||
|
||||
async def is_admin(self, user_id: int) -> bool:
|
||||
"""Проверяет, является ли пользователь админом"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(Admin).where(Admin.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки админа: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
# === SETTINGS ===
|
||||
|
||||
async def set_setting(self, key: str, value: str) -> None:
|
||||
"""
|
||||
Сохраняет настройку (или обновляет существующую).
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
value: Значение (строка или JSON)
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
# Проверяем существование
|
||||
existing = await session.execute(
|
||||
select(Setting).where(Setting.key == key)
|
||||
)
|
||||
setting = existing.scalar_one_or_none()
|
||||
|
||||
if setting:
|
||||
# Обновляем существующую
|
||||
setting.value = value
|
||||
setting.updated_at = datetime.now()
|
||||
else:
|
||||
# Создаём новую
|
||||
setting = Setting(key=key, value=value)
|
||||
session.add(setting)
|
||||
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка сохранения настройки: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
|
||||
async def get_setting(
|
||||
self,
|
||||
key: str,
|
||||
default: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Получает значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
default: Значение по умолчанию
|
||||
|
||||
Returns:
|
||||
Optional[str]: Значение или default
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting.value).where(Setting.key == key)
|
||||
)
|
||||
value = result.scalar_one_or_none()
|
||||
return value if value is not None else default
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения настройки: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return default
|
||||
|
||||
async def delete_setting(self, key: str) -> bool:
|
||||
"""Удаляет настройку"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
delete(Setting).where(Setting.key == key)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка удаления настройки: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
# === STATISTICS ===
|
||||
|
||||
async def log_spam_deletion(
|
||||
self,
|
||||
user_id: int,
|
||||
username: str,
|
||||
chat_id: int,
|
||||
message_text: str,
|
||||
matched_word: str,
|
||||
match_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Записывает статистику удалённого спам-сообщения.
|
||||
|
||||
Args:
|
||||
user_id: Telegram ID отправителя
|
||||
username: Username отправителя
|
||||
chat_id: ID чата
|
||||
message_text: Текст сообщения (обрезается до 500 символов)
|
||||
matched_word: Слово, по которому сработал фильтр
|
||||
match_type: Тип проверки (substring/lemma/part/silence/conflict)
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
spam_stat = SpamStat(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
chat_id=chat_id,
|
||||
message_text=message_text[:500],
|
||||
matched_word=matched_word,
|
||||
match_type=match_type
|
||||
)
|
||||
session.add(spam_stat)
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка логирования статистики: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
|
||||
async def get_spam_stats(
|
||||
self,
|
||||
limit: int = 100,
|
||||
user_id: Optional[int] = None
|
||||
) -> List[SpamStat]:
|
||||
"""
|
||||
Получает последнюю статистику удалений.
|
||||
|
||||
Args:
|
||||
limit: Максимум записей
|
||||
user_id: Фильтр по пользователю (опционально)
|
||||
|
||||
Returns:
|
||||
List[SpamStat]: Список записей статистики
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
query = select(SpamStat).order_by(SpamStat.deleted_at.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.where(SpamStat.user_id == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения статистики: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return []
|
||||
|
||||
async def get_user_spam_count(self, user_id: int) -> int:
|
||||
"""Получает количество удалённых сообщений пользователя"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(func.count(SpamStat.id)).where(
|
||||
SpamStat.user_id == user_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка подсчёта спама: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return 0
|
||||
|
||||
async def get_top_spammers(self, limit: int = 10) -> List[tuple[int, int]]:
|
||||
"""
|
||||
Получает топ спамеров.
|
||||
|
||||
Args:
|
||||
limit: Количество записей
|
||||
|
||||
Returns:
|
||||
List[tuple[int, int]]: [(user_id, count), ...]
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
SpamStat.user_id,
|
||||
func.count(SpamStat.id).label('count')
|
||||
)
|
||||
.group_by(SpamStat.user_id)
|
||||
.order_by(func.count(SpamStat.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [(row.user_id, row.count) for row in result]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения топ спамеров: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return []
|
||||
|
||||
# === GENERAL ===
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
"""Получает общую статистику БД"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
banwords_count = await session.execute(
|
||||
select(func.count(BanWord.id))
|
||||
)
|
||||
temp_banwords_count = await session.execute(
|
||||
select(func.count(TempBanWord.id))
|
||||
)
|
||||
whitelist_count = await session.execute(
|
||||
select(func.count(WhitelistWord.id))
|
||||
)
|
||||
admins_count = await session.execute(
|
||||
select(func.count(Admin.id))
|
||||
)
|
||||
spam_stats_count = await session.execute(
|
||||
select(func.count(SpamStat.id))
|
||||
)
|
||||
|
||||
return {
|
||||
'banwords': banwords_count.scalar_one(),
|
||||
'temp_banwords': temp_banwords_count.scalar_one(),
|
||||
'whitelist': whitelist_count.scalar_one(),
|
||||
'admins': admins_count.scalar_one(),
|
||||
'spam_deletions': spam_stats_count.scalar_one(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения статистики: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return {}
|
||||
Reference in New Issue
Block a user