Первый коммит

This commit is contained in:
2026-02-17 11:24:55 +07:00
commit a06448ca4b
109 changed files with 21165 additions and 0 deletions

39
database/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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 {}