diff --git a/.env.example b/.env.example
index c5071757..e013568e 100644
--- a/.env.example
+++ b/.env.example
@@ -88,6 +88,9 @@ SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для увед
TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все)
TRAFFIC_IGNORED_NODES= # Исключить эти ноды
+# Исключить пользователей (UUID через запятую)
+TRAFFIC_EXCLUDED_USER_UUIDS= # Служебные/тунельные пользователи
+
# Производительность
TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей
TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API
diff --git a/app/config.py b/app/config.py
index 2489c969..c87757f5 100644
--- a/app/config.py
+++ b/app/config.py
@@ -256,6 +256,7 @@ class Settings(BaseSettings):
# Фильтрация по серверам (UUID нод через запятую)
TRAFFIC_MONITORED_NODES: str = "" # Только эти ноды (пусто = все)
TRAFFIC_IGNORED_NODES: str = "" # Исключить эти ноды
+ TRAFFIC_EXCLUDED_USER_UUIDS: str = "" # Исключить пользователей (UUID через запятую)
# Параллельность и кулдаун
TRAFFIC_CHECK_BATCH_SIZE: int = 1000 # Размер батча для получения пользователей
@@ -854,13 +855,31 @@ class Settings(BaseSettings):
"""Возвращает список UUID нод для мониторинга (пусто = все)"""
if not self.TRAFFIC_MONITORED_NODES:
return []
- return [n.strip() for n in self.TRAFFIC_MONITORED_NODES.split(",") if n.strip()]
+ # Убираем комментарии (все после #)
+ value = self.TRAFFIC_MONITORED_NODES.split("#")[0].strip()
+ if not value:
+ return []
+ return [n.strip() for n in value.split(",") if n.strip()]
def get_traffic_ignored_nodes(self) -> List[str]:
"""Возвращает список UUID нод для исключения из мониторинга"""
if not self.TRAFFIC_IGNORED_NODES:
return []
- return [n.strip() for n in self.TRAFFIC_IGNORED_NODES.split(",") if n.strip()]
+ # Убираем комментарии (все после #)
+ value = self.TRAFFIC_IGNORED_NODES.split("#")[0].strip()
+ if not value:
+ return []
+ return [n.strip() for n in value.split(",") if n.strip()]
+
+ def get_traffic_excluded_user_uuids(self) -> List[str]:
+ """Возвращает список UUID пользователей для исключения из мониторинга (например, тунельные/служебные)"""
+ if not self.TRAFFIC_EXCLUDED_USER_UUIDS:
+ return []
+ # Убираем комментарии (все после #)
+ value = self.TRAFFIC_EXCLUDED_USER_UUIDS.split("#")[0].strip()
+ if not value:
+ return []
+ return [uuid.strip().lower() for uuid in value.split(",") if uuid.strip()]
def get_traffic_daily_check_time(self) -> Optional[time]:
"""Возвращает время суточной проверки трафика"""
diff --git a/app/middlewares/display_name_restriction.py b/app/middlewares/display_name_restriction.py
index 0060737e..7cebaae9 100644
--- a/app/middlewares/display_name_restriction.py
+++ b/app/middlewares/display_name_restriction.py
@@ -137,13 +137,28 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware):
if any(pattern.search(lower_value) for pattern in LINK_PATTERNS):
return True
- if DOMAIN_OBFUSCATION_PATTERN.search(lower_value):
- return True
+ # Проверяем обфусцированные ссылки типа "t . m e" или "т м е"
+ # Но НЕ блокируем если это часть обычного слова/имени
+ domain_match = DOMAIN_OBFUSCATION_PATTERN.search(lower_value)
+ if domain_match:
+ # Проверяем контекст: если "tme" внутри слова (с буквами с обеих сторон) - пропускаем
+ start_pos = domain_match.start()
+ end_pos = domain_match.end()
+
+ # Проверяем символ ДО и ПОСЛЕ совпадения
+ has_letter_before = start_pos > 0 and lower_value[start_pos - 1].isalpha()
+ has_letter_after = end_pos < len(lower_value) and lower_value[end_pos].isalpha()
+
+ # Если с ОБЕИХ сторон буквы - скорее всего это просто имя/фамилия
+ if not (has_letter_before and has_letter_after):
+ return True
normalized = self._normalize_text(lower_value)
collapsed = COLLAPSE_PATTERN.sub("", normalized)
- if "tme" in collapsed:
+ # Проверяем "tme" с контекстом (ловим t.me ссылки, но не случайные совпадения в именах)
+ # Ищем tme в начале, конце, или с пробелами/спецсимволами вокруг
+ if re.search(r"(?:^|[^a-zа-яё])tme(?:[^a-zа-яё]|$)", collapsed, re.IGNORECASE):
return True
banned_keywords = settings.get_display_name_banned_keywords()
diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py
index 9b583128..d2b5ff25 100644
--- a/app/services/traffic_monitoring_service.py
+++ b/app/services/traffic_monitoring_service.py
@@ -97,6 +97,9 @@ class TrafficMonitoringServiceV2:
def get_ignored_nodes(self) -> List[str]:
return settings.get_traffic_ignored_nodes()
+ def get_excluded_user_uuids(self) -> List[str]:
+ return settings.get_traffic_excluded_user_uuids()
+
def get_daily_check_time(self) -> Optional[time]:
return settings.get_traffic_daily_check_time()
@@ -133,7 +136,8 @@ class TrafficMonitoringServiceV2:
"""Загружает snapshot трафика из Redis"""
try:
snapshot_data = await cache.get(TRAFFIC_SNAPSHOT_KEY)
- if snapshot_data and isinstance(snapshot_data, dict):
+ # ВАЖНО: пустой словарь {} - это валидный snapshot!
+ if snapshot_data is not None and isinstance(snapshot_data, dict):
# Конвертируем обратно в float
result = {uuid: float(bytes_val) for uuid, bytes_val in snapshot_data.items()}
logger.debug(f"📦 Snapshot загружен из Redis: {len(result)} пользователей")
@@ -176,6 +180,23 @@ class TrafficMonitoringServiceV2:
logger.error(f"❌ Ошибка получения времени уведомления: {e}")
return None
+ # ============== Работа с нодами ==============
+
+ async def _load_nodes_cache(self):
+ """Загружает названия нод в кеш"""
+ try:
+ nodes = await self.remnawave_service.get_all_nodes()
+ self._nodes_cache = {node['uuid']: node['name'] for node in nodes if node.get('uuid') and node.get('name')}
+ logger.debug(f"📋 Загружено {len(self._nodes_cache)} нод в кеш")
+ except Exception as e:
+ logger.error(f"❌ Ошибка загрузки нод в кеш: {e}")
+
+ def get_node_name(self, node_uuid: Optional[str]) -> Optional[str]:
+ """Возвращает название ноды по UUID из кеша"""
+ if not node_uuid:
+ return None
+ return self._nodes_cache.get(node_uuid)
+
# ============== Фильтрация по нодам ==============
def should_monitor_node(self, node_uuid: Optional[str]) -> bool:
@@ -274,13 +295,13 @@ class TrafficMonitoringServiceV2:
async def has_snapshot(self) -> bool:
"""Проверяет, есть ли сохранённый snapshot (Redis + fallback на память)"""
- # Проверяем Redis
+ # Проверяем Redis (пустой словарь {} - это тоже валидный snapshot!)
snapshot = await self._load_snapshot_from_redis()
- if snapshot:
+ if snapshot is not None:
return True
# Fallback на память
- return bool(self._memory_snapshot) and self._memory_snapshot_time is not None
+ return self._memory_snapshot_time is not None
async def get_snapshot_age_minutes(self) -> float:
"""Возвращает возраст snapshot в минутах (Redis + fallback на память)"""
@@ -328,9 +349,9 @@ class TrafficMonitoringServiceV2:
Если в Redis уже есть snapshot — использует его (персистентность).
Возвращает количество пользователей в snapshot.
"""
- # Проверяем есть ли snapshot в Redis
+ # Проверяем есть ли snapshot в Redis (пустой {} тоже валидный snapshot!)
existing_snapshot = await self._load_snapshot_from_redis()
- if existing_snapshot:
+ if existing_snapshot is not None:
age = await self.get_snapshot_age_minutes()
logger.info(
f"📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, "
@@ -382,6 +403,24 @@ class TrafficMonitoringServiceV2:
start_time = datetime.utcnow()
is_first_run = not await self.has_snapshot()
+ # Загружаем кеш нод для красивых названий в уведомлениях
+ await self._load_nodes_cache()
+
+ # Логируем фильтры
+ monitored_nodes = self.get_monitored_nodes()
+ ignored_nodes = self.get_ignored_nodes()
+ excluded_user_uuids = self.get_excluded_user_uuids()
+
+ if monitored_nodes:
+ logger.info(f"🔍 Мониторим только ноды: {monitored_nodes}")
+ elif ignored_nodes:
+ logger.info(f"🚫 Игнорируем ноды: {ignored_nodes}")
+ else:
+ logger.info(f"📊 Мониторим все ноды")
+
+ if excluded_user_uuids:
+ logger.info(f"🚫 Исключены пользователи: {excluded_user_uuids}")
+
if is_first_run:
logger.info("🚀 Первый запуск быстрой проверки — создаём snapshot...")
else:
@@ -396,7 +435,7 @@ class TrafficMonitoringServiceV2:
# Загружаем предыдущий snapshot (из Redis или памяти)
previous_snapshot = await self._get_current_snapshot()
- logger.debug(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей")
+ logger.info(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})")
checked_users = 0
users_with_delta = 0
@@ -420,6 +459,7 @@ class TrafficMonitoringServiceV2:
# Пользователя не было в предыдущем snapshot — пропускаем (новый пользователь)
if user.uuid not in previous_snapshot:
+ logger.debug(f"Пользователь {user.uuid[:8]} не найден в предыдущем snapshot, пропускаем")
continue
# Получаем предыдущее значение
@@ -437,15 +477,22 @@ class TrafficMonitoringServiceV2:
if delta_bytes < threshold_bytes:
continue
- logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ)")
+ logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ, previous={previous_bytes / (1024**3):.2f} ГБ, current={current_bytes / (1024**3):.2f} ГБ)")
+
+ # Проверяем исключённых пользователей (служебные/тунельные)
+ if user.uuid.lower() in excluded_user_uuids:
+ logger.info(f"⏭️ Пропускаем {user.uuid[:8]}... - пользователь в списке исключений (служебный/тунельный)")
+ continue
# Проверяем фильтр по нодам
last_node_uuid = user_traffic.last_connected_node_uuid
if not self.should_monitor_node(last_node_uuid):
+ logger.warning(f"⏭️ Пропускаем {user.uuid[:8]} - нода {last_node_uuid or 'неизвестна'} не в списке мониторинга")
continue
# Создаём violation
delta_gb = round(delta_bytes / (1024 ** 3), 2)
+ node_name = self.get_node_name(last_node_uuid)
violation = TrafficViolation(
user_uuid=user.uuid,
telegram_id=user.telegram_id,
@@ -454,7 +501,7 @@ class TrafficMonitoringServiceV2:
used_traffic_gb=delta_gb, # Это дельта, не общий трафик!
threshold_gb=self.get_fast_check_threshold_gb(),
last_node_uuid=last_node_uuid,
- last_node_name=None,
+ last_node_name=node_name,
check_type="fast"
)
violations.append(violation)
@@ -464,6 +511,7 @@ class TrafficMonitoringServiceV2:
# Обновляем snapshot (в Redis с fallback на память)
await self._save_snapshot(new_snapshot)
+ logger.info(f"💾 Новый snapshot сохранён: {len(new_snapshot)} пользователей")
elapsed = (datetime.utcnow() - start_time).total_seconds()
@@ -495,6 +543,9 @@ class TrafficMonitoringServiceV2:
logger.info("🚀 Запуск суточной проверки трафика...")
start_time = datetime.utcnow()
+ # Загружаем кеш нод для красивых названий в уведомлениях
+ await self._load_nodes_cache()
+
violations: List[TrafficViolation] = []
threshold_bytes = self.get_daily_threshold_gb() * (1024 ** 3)
@@ -537,6 +588,7 @@ class TrafficMonitoringServiceV2:
return None
used_gb = round(total_bytes / (1024 ** 3), 2)
+ node_name = self.get_node_name(last_node_uuid)
return TrafficViolation(
user_uuid=user.uuid,
telegram_id=user.telegram_id,
@@ -545,7 +597,7 @@ class TrafficMonitoringServiceV2:
used_traffic_gb=used_gb,
threshold_gb=self.get_daily_threshold_gb(),
last_node_uuid=last_node_uuid,
- last_node_name=None,
+ last_node_name=node_name,
check_type="daily"
)
@@ -594,7 +646,7 @@ class TrafficMonitoringServiceV2:
for i, violation in enumerate(violations):
try:
if not await self.should_send_notification(violation.user_uuid):
- logger.debug(f"⏭️ Кулдаун для {violation.user_uuid}, пропускаем")
+ logger.info(f"⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)")
continue
# Получаем информацию о пользователе из БД
@@ -632,8 +684,13 @@ class TrafficMonitoringServiceV2:
f"🚨 Превышение: {violation.used_traffic_gb - violation.threshold_gb:.2f} ГБ\n"
)
- if violation.last_node_uuid:
- message += f"\n🖥 Последняя нода: {violation.last_node_uuid}"
+ # Показываем название ноды и UUID
+ if violation.last_node_name:
+ message += f"\n🖥 Сервер: {violation.last_node_name}"
+ if violation.last_node_uuid:
+ message += f"\n {violation.last_node_uuid}"
+ elif violation.last_node_uuid:
+ message += f"\n🖥 Сервер: {violation.last_node_uuid}"
message += f"\n\n⏰ {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')} UTC"