Notification photo cache, bulk traffic, blocked preload filter / Fix discount datetime aware
This commit is contained in:
@@ -510,7 +510,7 @@ async def handle_sync_cluster(
|
|||||||
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
|
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
|
||||||
|
|
||||||
if only_remnawave:
|
if only_remnawave:
|
||||||
batch_size = 50
|
batch_size = 250
|
||||||
total_keys = len(keys_to_sync)
|
total_keys = len(keys_to_sync)
|
||||||
processed_count = 0
|
processed_count = 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from aiogram import F, Router
|
from aiogram import F, Router
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
@@ -46,7 +46,9 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
|||||||
|
|
||||||
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
|
if last_time.tzinfo is None:
|
||||||
|
last_time = last_time.replace(tzinfo=timezone.utc)
|
||||||
if now - last_time > timedelta(hours=discount_active_hours):
|
if now - last_time > timedelta(hours=discount_active_hours):
|
||||||
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
|
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
|
||||||
return
|
return
|
||||||
@@ -133,7 +135,9 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
|||||||
|
|
||||||
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
discount_active_hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
|
if last_time.tzinfo is None:
|
||||||
|
last_time = last_time.replace(tzinfo=timezone.utc)
|
||||||
if now - last_time > timedelta(hours=discount_active_hours):
|
if now - last_time > timedelta(hours=discount_active_hours):
|
||||||
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
|
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any, Optional
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from aiogram import Bot, Router
|
from aiogram import Bot, Router
|
||||||
from sqlalchemy import select, text, update
|
from sqlalchemy import exists, or_, select, text, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
@@ -44,6 +44,7 @@ from database import (
|
|||||||
update_key_tariff,
|
update_key_tariff,
|
||||||
)
|
)
|
||||||
from database.models import Key, Tariff, User
|
from database.models import Key, Tariff, User
|
||||||
|
from database.models.users import BlockedUser, ManualBan
|
||||||
from database.tariffs import (
|
from database.tariffs import (
|
||||||
check_tariff_exists,
|
check_tariff_exists,
|
||||||
get_tariff_by_id,
|
get_tariff_by_id,
|
||||||
@@ -112,7 +113,14 @@ async def preload_notification_data(session: AsyncSession) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
.outerjoin(Tariff, Key.tariff_id == Tariff.id)
|
.outerjoin(Tariff, Key.tariff_id == Tariff.id)
|
||||||
.outerjoin(User, Key.user_id == User.id)
|
.outerjoin(User, Key.user_id == User.id)
|
||||||
.where(Key.is_frozen.is_(False))
|
.where(
|
||||||
|
Key.is_frozen.is_(False),
|
||||||
|
~exists().where(BlockedUser.tg_id == Key.tg_id),
|
||||||
|
~exists().where(
|
||||||
|
ManualBan.tg_id == Key.tg_id,
|
||||||
|
or_(ManualBan.until.is_(None), ManualBan.until > datetime.utcnow()),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -589,10 +597,43 @@ async def notify_expiring_keys(
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_blocked_expired_keys(session: AsyncSession, current_time: int) -> list:
|
||||||
|
"""Получает истекшие ключи заблокированных/забаненных пользователей."""
|
||||||
|
stmt = (
|
||||||
|
select(Key)
|
||||||
|
.where(
|
||||||
|
Key.is_frozen.is_(False),
|
||||||
|
Key.expiry_time.isnot(None),
|
||||||
|
Key.expiry_time < current_time,
|
||||||
|
or_(
|
||||||
|
exists().where(BlockedUser.tg_id == Key.tg_id),
|
||||||
|
exists().where(
|
||||||
|
ManualBan.tg_id == Key.tg_id,
|
||||||
|
or_(ManualBan.until.is_(None), ManualBan.until > datetime.utcnow()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def handle_expired_keys(ctx: NotificationContext, keys: list):
|
async def handle_expired_keys(ctx: NotificationContext, keys: list):
|
||||||
logger.info("Начало обработки истекших ключей.")
|
logger.info("Начало обработки истекших ключей.")
|
||||||
|
|
||||||
expired_keys = [key for key in keys if key.expiry_time and key.expiry_time < ctx.current_time]
|
expired_keys = [key for key in keys if key.expiry_time and key.expiry_time < ctx.current_time]
|
||||||
|
|
||||||
|
try:
|
||||||
|
blocked_expired = await _get_blocked_expired_keys(ctx.session, ctx.current_time)
|
||||||
|
if blocked_expired:
|
||||||
|
logger.info(f"Дополнительно найдено {len(blocked_expired)} истекших ключей заблокированных пользователей.")
|
||||||
|
existing_ids = {key.client_id for key in expired_keys}
|
||||||
|
for bk in blocked_expired:
|
||||||
|
if bk.client_id not in existing_ids:
|
||||||
|
expired_keys.append(bk)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Ошибка получения ключей заблокированных пользователей: {error}")
|
||||||
|
|
||||||
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
||||||
|
|
||||||
tg_ids = [key.tg_id for key in expired_keys]
|
tg_ids = [key.tg_id for key in expired_keys]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from collections import deque
|
from collections import OrderedDict, deque
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@@ -25,6 +25,40 @@ from services.tariffs.tariff_display import get_key_tariff_display
|
|||||||
|
|
||||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
_photo_cache: OrderedDict[str, str] = OrderedDict()
|
||||||
|
_photo_cache_lock = asyncio.Lock()
|
||||||
|
_PHOTO_CACHE_MAX = 64
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cached_file_id(photo_path: str) -> str | None:
|
||||||
|
async with _photo_cache_lock:
|
||||||
|
fid = _photo_cache.get(photo_path)
|
||||||
|
if fid:
|
||||||
|
_photo_cache.move_to_end(photo_path)
|
||||||
|
return fid
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_cached_file_id(photo_path: str, file_id: str) -> None:
|
||||||
|
async with _photo_cache_lock:
|
||||||
|
if photo_path not in _photo_cache:
|
||||||
|
_photo_cache[photo_path] = file_id
|
||||||
|
if len(_photo_cache) > _PHOTO_CACHE_MAX:
|
||||||
|
_photo_cache.popitem(last=False)
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPORTED_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp", ".gif")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_photo_file(photo_path: str) -> str | None:
|
||||||
|
if os.path.isfile(photo_path):
|
||||||
|
return photo_path
|
||||||
|
base_name = os.path.splitext(photo_path)[0]
|
||||||
|
for ext in _SUPPORTED_EXTENSIONS:
|
||||||
|
candidate = base_name + ext
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class NotificationRateLimiter:
|
class NotificationRateLimiter:
|
||||||
def __init__(self, max_rate: int = 35, window: float = 1.0) -> None:
|
def __init__(self, max_rate: int = 35, window: float = 1.0) -> None:
|
||||||
@@ -80,15 +114,25 @@ class FastNotificationSender:
|
|||||||
|
|
||||||
if msg.photo:
|
if msg.photo:
|
||||||
photo_path = os.path.join("img", msg.photo)
|
photo_path = os.path.join("img", msg.photo)
|
||||||
if os.path.isfile(photo_path):
|
cached_id = await _get_cached_file_id(photo_path)
|
||||||
async with aiofiles.open(photo_path, "rb") as f:
|
|
||||||
image_data = await f.read()
|
if cached_id:
|
||||||
buffered_photo = BufferedInputFile(image_data, filename=msg.photo)
|
|
||||||
await self.bot.send_photo(
|
await self.bot.send_photo(
|
||||||
chat_id=msg.tg_id, photo=buffered_photo, caption=msg.text, reply_markup=msg.keyboard
|
chat_id=msg.tg_id, photo=cached_id, caption=msg.text, reply_markup=msg.keyboard
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.bot.send_message(chat_id=msg.tg_id, text=msg.text, reply_markup=msg.keyboard)
|
actual_path = _find_photo_file(photo_path)
|
||||||
|
if actual_path:
|
||||||
|
async with aiofiles.open(actual_path, "rb") as f:
|
||||||
|
image_data = await f.read()
|
||||||
|
buffered_photo = BufferedInputFile(image_data, filename=os.path.basename(actual_path))
|
||||||
|
result = await self.bot.send_photo(
|
||||||
|
chat_id=msg.tg_id, photo=buffered_photo, caption=msg.text, reply_markup=msg.keyboard
|
||||||
|
)
|
||||||
|
if result and hasattr(result, "photo") and result.photo:
|
||||||
|
await _set_cached_file_id(photo_path, result.photo[-1].file_id)
|
||||||
|
else:
|
||||||
|
await self.bot.send_message(chat_id=msg.tg_id, text=msg.text, reply_markup=msg.keyboard)
|
||||||
else:
|
else:
|
||||||
await self.bot.send_message(chat_id=msg.tg_id, text=msg.text, reply_markup=msg.keyboard)
|
await self.bot.send_message(chat_id=msg.tg_id, text=msg.text, reply_markup=msg.keyboard)
|
||||||
return True
|
return True
|
||||||
@@ -259,8 +303,13 @@ async def send_notification(
|
|||||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||||
|
|
||||||
photo_path = os.path.join("img", image_filename)
|
photo_path = os.path.join("img", image_filename)
|
||||||
if os.path.isfile(photo_path):
|
cached_id = await _get_cached_file_id(photo_path)
|
||||||
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
|
if cached_id:
|
||||||
|
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard, cached_id)
|
||||||
|
|
||||||
|
actual_path = _find_photo_file(photo_path)
|
||||||
|
if actual_path:
|
||||||
|
return await _send_photo_notification(bot, tg_id, actual_path, image_filename, caption, keyboard)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Файл с изображением не найден: {photo_path}")
|
logger.warning(f"Файл с изображением не найден: {photo_path}")
|
||||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||||
@@ -274,12 +323,20 @@ async def _send_photo_notification(
|
|||||||
image_filename: str,
|
image_filename: str,
|
||||||
caption: str,
|
caption: str,
|
||||||
keyboard: InlineKeyboardMarkup | None = None,
|
keyboard: InlineKeyboardMarkup | None = None,
|
||||||
|
cached_file_id: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
|
if cached_file_id:
|
||||||
|
await bot.send_photo(tg_id, cached_file_id, caption=caption, reply_markup=keyboard)
|
||||||
|
return True
|
||||||
async with aiofiles.open(photo_path, "rb") as image_file:
|
async with aiofiles.open(photo_path, "rb") as image_file:
|
||||||
image_data = await image_file.read()
|
image_data = await image_file.read()
|
||||||
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
|
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
|
||||||
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
result = await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
||||||
|
if result and hasattr(result, "photo") and result.photo:
|
||||||
|
await _set_cached_file_id(
|
||||||
|
os.path.join("img", image_filename), result.photo[-1].file_id
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except (TelegramForbiddenError, TelegramBadRequest):
|
except (TelegramForbiddenError, TelegramBadRequest):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from database.models import Key, User
|
|||||||
from database.tariffs import get_tariffs
|
from database.tariffs import get_tariffs
|
||||||
from handlers.buttons import CONNECT_DEVICE, MAIN_MENU, SUPPORT, TRIAL_BONUS
|
from handlers.buttons import CONNECT_DEVICE, MAIN_MENU, SUPPORT, TRIAL_BONUS
|
||||||
from handlers.keys.utils import build_key_callback
|
from handlers.keys.utils import build_key_callback
|
||||||
|
from panels.remnawave_runtime import fetch_all_remnawave_traffic
|
||||||
from handlers.notifications.notify_utils import send_messages_with_limit
|
from handlers.notifications.notify_utils import send_messages_with_limit
|
||||||
from handlers.texts import (
|
from handlers.texts import (
|
||||||
TRIAL_INACTIVE_BONUS_MSG,
|
TRIAL_INACTIVE_BONUS_MSG,
|
||||||
@@ -153,50 +154,53 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
|
|||||||
remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
remnawave_webapp_enabled = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP))
|
||||||
open_in_browser = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER))
|
open_in_browser = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER))
|
||||||
|
|
||||||
|
candidate_keys = []
|
||||||
|
for key in keys:
|
||||||
|
if key.tariff_id not in trial_tariff_ids:
|
||||||
|
continue
|
||||||
|
if key.created_at is None or key.notified:
|
||||||
|
continue
|
||||||
|
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(key.created_at / 1000)).astimezone(moscow_tz)
|
||||||
|
if current_dt < created_at_dt + timedelta(hours=inactive_traffic_hours):
|
||||||
|
continue
|
||||||
|
if key.expiry_time:
|
||||||
|
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(key.expiry_time / 1000)).astimezone(moscow_tz)
|
||||||
|
if current_dt > expiry_dt:
|
||||||
|
continue
|
||||||
|
candidate_keys.append(key)
|
||||||
|
|
||||||
|
if not candidate_keys:
|
||||||
|
logger.info("Нет кандидатов для проверки нулевого трафика.")
|
||||||
|
return
|
||||||
|
|
||||||
|
needed_uuids = {key.client_id for key in candidate_keys if key.client_id}
|
||||||
|
logger.info(f"[Zero Traffic] Кандидатов: {len(candidate_keys)}, уникальных UUID: {len(needed_uuids)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
traffic_map = await fetch_all_remnawave_traffic(session, needed_uuids=needed_uuids)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"[Zero Traffic] Ошибка bulk-получения трафика: {error}")
|
||||||
|
return
|
||||||
|
|
||||||
messages = []
|
messages = []
|
||||||
keys_to_mark_notified = []
|
keys_to_mark_notified = []
|
||||||
|
|
||||||
for key in keys:
|
for key in candidate_keys:
|
||||||
tg_id = key.tg_id
|
tg_id = key.tg_id
|
||||||
email = key.email
|
email = key.email
|
||||||
created_at = key.created_at
|
|
||||||
client_id = key.client_id
|
client_id = key.client_id
|
||||||
expiry_time = key.expiry_time
|
|
||||||
notified = key.notified
|
|
||||||
tariff_id = key.tariff_id
|
|
||||||
|
|
||||||
if tariff_id not in trial_tariff_ids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if created_at is None or notified:
|
|
||||||
continue
|
|
||||||
|
|
||||||
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
|
|
||||||
if current_dt < created_at_dt + timedelta(hours=inactive_traffic_hours):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if expiry_time:
|
|
||||||
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
|
|
||||||
if current_dt > expiry_dt:
|
|
||||||
continue
|
|
||||||
|
|
||||||
keys_to_mark_notified.append(client_id)
|
keys_to_mark_notified.append(client_id)
|
||||||
|
|
||||||
try:
|
used_bytes = traffic_map.get(client_id)
|
||||||
traffic_data = await get_user_traffic(session, tg_id, email)
|
if used_bytes is None:
|
||||||
except Exception as error:
|
logger.warning(f"[Zero Traffic] UUID {client_id} ({email}) не найден в bulk-данных, пропуск")
|
||||||
logger.error(f"Ошибка получения трафика для {email}: {error}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if traffic_data.get("status") != "success":
|
if used_bytes > 0:
|
||||||
logger.warning(f"Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
total_traffic = sum(
|
if used_bytes == 0:
|
||||||
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
|
|
||||||
)
|
|
||||||
|
|
||||||
if total_traffic == 0:
|
|
||||||
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
|||||||
@@ -834,7 +834,7 @@ async def handle_addons_confirm(callback: CallbackQuery, state: FSMContext, sess
|
|||||||
server_id = record["server_id"]
|
server_id = record["server_id"]
|
||||||
|
|
||||||
selected_traffic_gb_for_effective = (
|
selected_traffic_gb_for_effective = (
|
||||||
int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_option else 0
|
int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_option else None
|
||||||
)
|
)
|
||||||
|
|
||||||
current_subgroup = None
|
current_subgroup = None
|
||||||
|
|||||||
@@ -204,6 +204,51 @@ async def invalidate_remnawave_profile(
|
|||||||
await invalidate_remnawave_profile_cache(client_id=client_id)
|
await invalidate_remnawave_profile_cache(client_id=client_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_all_remnawave_traffic(
|
||||||
|
session: AsyncSession,
|
||||||
|
needed_uuids: set[str] | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Bulk-запрос: возвращает {uuid: usedTrafficBytes} для всех (или нужных) юзеров Remnawave."""
|
||||||
|
servers = await get_servers(session)
|
||||||
|
api_url = None
|
||||||
|
for cluster in servers.values():
|
||||||
|
for srv in cluster:
|
||||||
|
if srv.get("panel_type") == "remnawave":
|
||||||
|
api_url = srv.get("api_url")
|
||||||
|
break
|
||||||
|
if api_url:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not api_url:
|
||||||
|
logger.warning("[Bulk Traffic] Нет доступных Remnawave-серверов")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
api = RemnawaveAPI(api_url)
|
||||||
|
try:
|
||||||
|
all_users = await api.get_all_users_time(
|
||||||
|
username=REMNAWAVE_LOGIN,
|
||||||
|
password=REMNAWAVE_PASSWORD,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await api.aclose()
|
||||||
|
|
||||||
|
if not all_users:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for user in all_users:
|
||||||
|
uuid = user.get("uuid")
|
||||||
|
if not uuid:
|
||||||
|
continue
|
||||||
|
if needed_uuids and uuid not in needed_uuids:
|
||||||
|
continue
|
||||||
|
traffic = user.get("userTraffic") or {}
|
||||||
|
result[uuid] = traffic.get("usedTrafficBytes", 0)
|
||||||
|
|
||||||
|
logger.info(f"[Bulk Traffic] Получено {len(result)} профилей трафика из {len(all_users)} юзеров Remnawave")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def with_remnawave_api(
|
async def with_remnawave_api(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
server_ref: str,
|
server_ref: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user