Compare commits

...

2 Commits

Author SHA1 Message Date
74e952698b chore: deleted testing files 2026-08-03 00:01:50 +07:00
1e4cb43ac7 feat(rw): remnawave integration 2026-08-03 00:01:00 +07:00
9 changed files with 534 additions and 18 deletions

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13.0

View File

@@ -22,7 +22,7 @@ def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'invoices', ['id']) op.create_unique_constraint(None, 'invoices', ['id'])
op.add_column('users', sa.Column('balance', sa.FLOAT(precision=2), nullable=False)) op.add_column('users', sa.Column('balance', sa.FLOAT(precision=2), nullable=False, default=0))
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -21,8 +21,17 @@ class Settings(BaseSettings):
pally_shop_id: str = Field() pally_shop_id: str = Field()
pally_token: str = Field() pally_token: str = Field()
remnawave_base_url: str = Field()
remnawave_token: str = Field()
remnawave_default_squads_raw: str = Field(alias="REMNAWAVE_DEFAULT_SQUADS_UUIDS")
minimal_deposit: int = Field() minimal_deposit: int = Field()
@computed_field
@property
def remnawave_default_squads(self) -> list[str]:
return self.remnawave_default_squads_raw.split(",")
@computed_field @computed_field
@property @property
def db_url(self) -> str: def db_url(self) -> str:

3
external/pally.py vendored
View File

@@ -224,8 +224,9 @@ class BillService(BaseService):
super().__init__(session) super().__init__(session)
self._payer_pays_commission = payer_pays_commission self._payer_pays_commission = payer_pays_commission
async def create( # noqa: PLR0917 async def create(
self, self,
*,
amount: float, amount: float,
shop_id: str, shop_id: str,
order_id: str | None = None, order_id: str | None = None,

480
external/rw.py vendored Normal file
View File

@@ -0,0 +1,480 @@
import logging
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import UUID
from remnawave import RemnawaveSDK
from remnawave.models import (
CreateUserRequestDto,
DeleteUserHwidDeviceResponseDto,
HWIDDeleteRequest,
UpdateUserRequestDto,
)
from remnawave.models.hwid import HwidDeviceDto
from config import cfg
logger = logging.getLogger(__name__)
RW_USERNAME_MAX_LENGTH = 36
# ─────────────────────────────────────────────────────────────────
# Dataclass — унифицированное представление пользователя RW
# ─────────────────────────────────────────────────────────────────
@dataclass
class RWUserInfo:
uuid: str
username: str
status: str # "ACTIVE", "DISABLED", "EXPIRED", "ON_HOLD", "LIMITED"
expire_at: datetime
used_traffic_bytes: float
traffic_limit_bytes: int # 0 = unlimited
hwid_device_limit: int | None # None = unlimited
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
description: str | None
short_uuid: str
# ─────────────────────────────────────────────────────────────────
# Внутренние хелперы
# ─────────────────────────────────────────────────────────────────
def _parse_user(user_dto) -> RWUserInfo:
"""Преобразовать UserResponseDto → RWUserInfo."""
active_squads: list[dict] = []
for squad in user_dto.active_internal_squads or []:
active_squads.append({"uuid": str(squad.uuid), "name": squad.name})
# UserStatus может быть enum-объектом или строкой — нормализуем
status_raw = user_dto.status
if hasattr(status_raw, "value"):
status_str = str(status_raw.value).upper()
else:
status_str = str(status_raw).upper()
# Трафик лежит в user_traffic.used_traffic_bytes
used_traffic: float = 0.0
if user_dto.user_traffic is not None:
used_traffic = float(user_dto.user_traffic.used_traffic_bytes or 0)
return RWUserInfo(
uuid=str(user_dto.uuid),
username=user_dto.username,
status=status_str,
expire_at=user_dto.expire_at,
used_traffic_bytes=used_traffic,
traffic_limit_bytes=int(user_dto.traffic_limit_bytes or 0),
hwid_device_limit=user_dto.hwid_device_limit,
active_squads=active_squads,
description=user_dto.description,
short_uuid=user_dto.short_uuid,
)
def _build_rw_username(*, telegram_id: int | None, username: str | None) -> str | None:
if telegram_id is not None:
return f"tg_{telegram_id}"
if not username:
return None
sanitized_username = re.sub(r"[^a-zA-Z0-9_-]+", "_", username).strip("_")
if not sanitized_username:
return None
return f"web_{sanitized_username}"[:RW_USERNAME_MAX_LENGTH]
# ─────────────────────────────────────────────────────────────────
# Публичные функции — обёртки с перехватом ошибок
# ─────────────────────────────────────────────────────────────────
async def get_user_by_telegram_id(
sdk: RemnawaveSDK,
telegram_id: int,
) -> RWUserInfo | None:
"""
Получить первого пользователя Remnawave, привязанного к Telegram ID.
Возвращает None, если пользователь не найден или Remnawave недоступен.
"""
try:
users_list = await sdk.users.get_users_by_telegram_id(str(telegram_id))
if not users_list:
return None
return _parse_user(users_list[0])
except Exception as exc:
logger.warning(
"Failed to get RW user by telegram_id=%s: %s",
telegram_id,
exc,
)
return None
async def get_user_by_username(
sdk: RemnawaveSDK,
username: str,
) -> RWUserInfo | None:
try:
user = await sdk.users.get_user_by_username(username=username)
if user is None:
return None
return _parse_user(user)
except Exception as exc:
logger.warning(
"Failed to get RW user by username=%s: %s",
username,
exc,
)
return None
async def get_all_squads(sdk: RemnawaveSDK) -> list[dict]:
"""
Получить все Internal Squads из Remnawave.
Возвращает список [{"uuid": "...", "name": "..."}].
При ошибке возвращает пустой список.
"""
try:
response = await sdk.internal_squads.get_internal_squads()
result: list[dict] = []
# GetAllInternalSquadsResponseDto имеет поле .internal_squads
for squad in response.internal_squads:
result.append({"uuid": str(squad.uuid), "name": squad.name})
return result
except Exception as exc:
logger.warning("Failed to get all internal squads: %s", exc)
return []
async def add_days(
sdk: RemnawaveSDK,
user_uuid: str,
days: int,
) -> datetime | None:
"""
Добавить N дней к expire_at пользователя.
Возвращает новую дату окончания или None при ошибке.
"""
try:
# Получаем актуальный expire_at — нельзя слепо использовать
# закешированное значение, чтобы не потерять изменения других агентов
user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid)
if user_dto is None or user_dto.expire_at is None:
logger.warning("Cannot add days: user %s has no expire_at", user_uuid)
return None
# Обеспечиваем timezone-aware datetime
current_expire = user_dto.expire_at
if current_expire.tzinfo is None:
current_expire = current_expire.replace(tzinfo=UTC)
new_expire = current_expire + timedelta(days=days)
result = await sdk.users.update_user(
UpdateUserRequestDto(
uuid=UUID(user_uuid),
expire_at=new_expire,
)
)
if result is None:
return None
new_dt = result.expire_at
if new_dt is not None and new_dt.tzinfo is None:
new_dt = new_dt.replace(tzinfo=UTC)
return new_dt
except Exception as exc:
logger.warning("Failed to add %d days for user %s: %s", days, user_uuid, exc)
return None
async def remove_days(
sdk: RemnawaveSDK,
user_uuid: str,
days: int,
) -> datetime | None:
"""
Вычесть N дней из expire_at пользователя.
Возвращает новую дату окончания или None при ошибке.
"""
try:
user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid)
if user_dto is None or user_dto.expire_at is None:
logger.warning("Cannot remove days: user %s has no expire_at", user_uuid)
return None
current_expire = user_dto.expire_at
if current_expire.tzinfo is None:
current_expire = current_expire.replace(tzinfo=UTC)
new_expire = current_expire - timedelta(days=days)
result = await sdk.users.update_user(
UpdateUserRequestDto(
uuid=UUID(user_uuid),
expire_at=new_expire,
)
)
if result is None:
return None
new_dt = result.expire_at
if new_dt is not None and new_dt.tzinfo is None:
new_dt = new_dt.replace(tzinfo=UTC)
return new_dt
except Exception as exc:
logger.warning("Failed to remove %d days for user %s: %s", days, user_uuid, exc)
return None
async def set_hwid_limit(
sdk: RemnawaveSDK,
user_uuid: str,
limit: int,
) -> bool:
"""
Установить лимит HWID-устройств.
limit == 0 → убрать ограничение (None).
Возвращает True при успехе, False при ошибке.
"""
try:
hwid_value: int | None = None if limit == 0 else limit
await sdk.users.update_user(
UpdateUserRequestDto(
uuid=UUID(user_uuid),
hwid_device_limit=hwid_value,
)
)
return True
except Exception as exc:
logger.warning(
"Failed to set HWID limit=%s for user %s: %s",
limit,
user_uuid,
exc,
)
return False
async def set_description(
sdk: RemnawaveSDK,
user_uuid: str,
note: str,
) -> bool:
"""
Установить заметку (description) пользователя в Remnawave.
Возвращает True при успехе, False при ошибке.
"""
try:
await sdk.users.update_user(
UpdateUserRequestDto(
uuid=UUID(user_uuid),
description=note,
)
)
return True
except Exception as exc:
logger.warning(
"Failed to set description for user %s: %s",
user_uuid,
exc,
)
return False
async def reset_traffic(
sdk: RemnawaveSDK,
user_uuid: str,
) -> bool:
"""
Сбросить счётчик использованного трафика пользователя.
Возвращает True при успехе, False при ошибке.
"""
try:
await sdk.users.reset_user_traffic(uuid=user_uuid)
return True
except Exception as exc:
logger.warning("Failed to reset traffic for user %s: %s", user_uuid, exc)
return False
async def disable_user(
sdk: RemnawaveSDK,
user_uuid: str,
) -> bool:
"""
Отключить пользователя (статус → DISABLED).
Возвращает True при успехе, False при ошибке.
"""
try:
await sdk.users.disable_user(uuid=user_uuid)
return True
except Exception as exc:
logger.warning("Failed to disable user %s: %s", user_uuid, exc)
return False
async def enable_user(
sdk: RemnawaveSDK,
user_uuid: str,
) -> bool:
"""
Включить пользователя (статус → ACTIVE).
Возвращает True при успехе, False при ошибке.
"""
try:
await sdk.users.enable_user(uuid=user_uuid)
return True
except Exception as exc:
logger.warning("Failed to enable user %s: %s", user_uuid, exc)
return False
async def update_squads(
sdk: RemnawaveSDK,
user_uuid: str,
squad_uuid_list: list[str],
) -> bool:
"""
Полностью заменить список Internal Squads пользователя.
squad_uuid_list — строковые UUID всех squad'ов (полный новый список).
Возвращает True при успехе, False при ошибке.
"""
try:
await sdk.users.update_user(
UpdateUserRequestDto(
uuid=UUID(user_uuid),
active_internal_squads=[UUID(s) for s in squad_uuid_list],
)
)
return True
except Exception as exc:
logger.warning(
"Failed to update squads for user %s: %s",
user_uuid,
exc,
)
return False
async def create_user(
*,
sdk: RemnawaveSDK,
username: str,
telegram_id: int | None,
expire_at: datetime,
hwid_device_limit: int | None,
squad_uuids: list[str],
) -> RWUserInfo | None:
"""
Создать нового пользователя в Remnawave.
hwid_device_limit=None → без ограничений.
Возвращает RWUserInfo при успехе, None при ошибке.
"""
try:
dto = CreateUserRequestDto(
username=username,
telegram_id=telegram_id,
expire_at=expire_at,
hwid_device_limit=hwid_device_limit,
active_internal_squads=[UUID(s) for s in squad_uuids] if squad_uuids else None,
)
result = await sdk.users.create_user(dto)
if result is None:
return None
return _parse_user(result)
except Exception as exc:
logger.warning("Failed to create user username=%s: %s", username, exc)
return None
async def get_hwid_list(sdk: RemnawaveSDK, user_uuid: str) -> list[HwidDeviceDto] | None:
try:
resp = await sdk.hwid.get_hwid_user(user_uuid)
return resp.devices
except Exception:
logger.exception("failed to fetch hwid list for user=%s", user_uuid)
return
async def delete_hwid(sdk: RemnawaveSDK, user_uuid: str, hwid: str) -> bool:
body = HWIDDeleteRequest(user_uuid=user_uuid, hwid=hwid)
try:
resp = await sdk.hwid.delete_hwid_to_user(body)
return isinstance(resp, DeleteUserHwidDeviceResponseDto)
except Exception:
logger.exception("failed to delete hwid=%s for user=%s", hwid, user_uuid)
return False
async def update_expire_at(sdk: RemnawaveSDK, user_uuid: str, expire_at: datetime):
dto = UpdateUserRequestDto(uuid=user_uuid, expire_at=expire_at) # type: ignore
try:
await sdk.users.update_user(body=dto)
return True
except Exception:
logger.exception("failed to update expire_at=%s for user=%s", str(expire_at), user_uuid)
return False
def get_sdk() -> RemnawaveSDK | None:
if not cfg.remnawave_base_url or not cfg.remnawave_token:
return None
return RemnawaveSDK(base_url=cfg.remnawave_base_url, token=cfg.remnawave_token)
async def sync_subscription_by_telegram_id(
*,
expires_at: datetime,
devices: int,
telegram_id: int | None = None,
username: str | None = None,
) -> bool:
sdk = get_sdk()
if sdk is None:
logger.info("Skipping RW subscription sync: RemnaWave is not configured")
return False
rw_user = None
if telegram_id is not None:
rw_user = await get_user_by_telegram_id(sdk, telegram_id)
if rw_user is None:
rw_username = _build_rw_username(telegram_id=telegram_id, username=username)
if rw_username is None:
logger.warning(
"Skipping RW subscription sync: cannot build username for telegram_id=%s username=%s",
telegram_id,
username,
)
return False
rw_user = await get_user_by_username(sdk, rw_username)
if rw_user is None:
rw_user = await create_user(
sdk=sdk,
username=rw_username,
telegram_id=telegram_id,
expire_at=expires_at,
hwid_device_limit=None if devices == 0 else devices,
squad_uuids=cfg.remnawave_default_squads,
)
if rw_user is None:
logger.warning(
"Failed to create RW user for telegram_id=%s username=%s",
telegram_id,
username,
)
return False
expire_synced = await update_expire_at(sdk, rw_user.uuid, expires_at)
devices_synced = await set_hwid_limit(sdk, rw_user.uuid, devices)
return expire_synced and devices_synced

View File

@@ -8,3 +8,4 @@ asyncpg>=0.31.0
alembic>=1.18.0 alembic>=1.18.0
aiohttp>=3.14.0 aiohttp>=3.14.0
python-multipart==0.0.32 python-multipart==0.0.32
remnawave>=2.6.1

View File

@@ -1,5 +1,5 @@
from datetime import UTC, datetime
import math import math
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -61,12 +61,11 @@ async def checkout(
amount=amount_to_pay, amount=amount_to_pay,
status=InvoiceStatus.ACTIVE, status=InvoiceStatus.ACTIVE,
) )
with open("log.txt", "w") as f:
f.write(
" ".join((str(price), str(amount_to_pay), cfg.pally_shop_id, str(order_entry.id)))
)
bill = await pally.bills.create( bill = await pally.bills.create(
float(amount_to_pay), cfg.pally_shop_id, order_id=str(order_entry.id) amount=float(amount_to_pay),
shop_id=cfg.pally_shop_id,
order_id=str(order_entry.id),
) )
payment_link = bill.link_page_url payment_link = bill.link_page_url
if not payment_link: if not payment_link:

View File

@@ -14,6 +14,7 @@ from core.deps import get_db
from db.models.orders import OrderStatus from db.models.orders import OrderStatus
from db.models.transactions import BalanceTransaction, BalanceTxType from db.models.transactions import BalanceTransaction, BalanceTxType
from external.pally import BillStatus from external.pally import BillStatus
from external.rw import sync_subscription_by_telegram_id
from repositories import AddonsRepository from repositories import AddonsRepository
from repositories.invoices import InvoiceRepository from repositories.invoices import InvoiceRepository
from repositories.orders import OrderRepository from repositories.orders import OrderRepository
@@ -248,7 +249,15 @@ async def pally_callback( # noqa: PLR0911, PLR0912, PLR0915
pricing=pricing, pricing=pricing,
now=now, now=now,
): ):
await apply_order_now(session, user=user, order=order, pricing=pricing, now=now) applied_subscription = await apply_order_now(
session, user=user, order=order, pricing=pricing, now=now
)
await sync_subscription_by_telegram_id(
expires_at=applied_subscription.expires_at,
devices=applied_subscription.devices,
telegram_id=user.telegram_id,
username=user.username,
)
else: else:
if subscription is None: if subscription is None:
logger.critical( logger.critical(

View File

@@ -6,16 +6,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Subscription, SubscriptionAddon, User from db.models import Subscription, SubscriptionAddon, User
from db.models.orders import Order, OrderStatus from db.models.orders import Order, OrderStatus
from db.models.transactions import BalanceTransaction, BalanceTxType from db.models.transactions import BalanceTransaction, BalanceTxType
from external.rw import sync_subscription_by_telegram_id
from schemas.enums import SubscriptionStatus from schemas.enums import SubscriptionStatus
from schemas.plans import PricingPlans from schemas.plans import PricingPlans
def calculate_plan_monthly_price(pricing: PricingPlans, devices: int, addon_ids: list[str]) -> float: def calculate_plan_monthly_price(
pricing: PricingPlans, devices: int, addon_ids: list[str]
) -> float:
addon_prices = {addon.id: addon.price for addon in pricing.addons} addon_prices = {addon.id: addon.price for addon in pricing.addons}
return devices * pricing.device_price + sum(addon_prices[addon_id] for addon_id in addon_ids) return devices * pricing.device_price + sum(addon_prices[addon_id] for addon_id in addon_ids)
def calculate_order_total(pricing: PricingPlans, devices: int, addon_ids: list[str], duration_days: int) -> float: def calculate_order_total(
pricing: PricingPlans, devices: int, addon_ids: list[str], duration_days: int
) -> float:
monthly_price = calculate_plan_monthly_price(pricing, devices, addon_ids) monthly_price = calculate_plan_monthly_price(pricing, devices, addon_ids)
return monthly_price * duration_days / 30 return monthly_price * duration_days / 30
@@ -29,10 +34,10 @@ def get_subscription_addon_ids(subscription: Subscription | None) -> list[str]:
def should_apply_immediately( def should_apply_immediately(
*, *,
subscription: Subscription | None,
order: Order, order: Order,
pricing: PricingPlans, pricing: PricingPlans,
now: datetime, now: datetime,
subscription: Subscription | None = None,
) -> bool: ) -> bool:
if subscription is None or subscription.expires_at <= now: if subscription is None or subscription.expires_at <= now:
return True return True
@@ -66,7 +71,9 @@ async def replace_subscription_addons(
session.add(SubscriptionAddon(subscription_id=subscription_id, addon_id=addon_id)) session.add(SubscriptionAddon(subscription_id=subscription_id, addon_id=addon_id))
async def ensure_subscription(user: User, session: AsyncSession, starts_at: datetime) -> Subscription: async def ensure_subscription(
user: User, session: AsyncSession, starts_at: datetime
) -> Subscription:
subscription = user.subscription subscription = user.subscription
if subscription is not None: if subscription is not None:
return subscription return subscription
@@ -113,7 +120,7 @@ async def apply_order_now(
order: Order, order: Order,
pricing: PricingPlans, pricing: PricingPlans,
now: datetime, now: datetime,
) -> None: ) -> Subscription:
subscription = await ensure_subscription(user, session, now) subscription = await ensure_subscription(user, session, now)
addon_ids = [addon.addon_id for addon in order.addons] addon_ids = [addon.addon_id for addon in order.addons]
current_addons = [] if subscription.devices == 0 else get_subscription_addon_ids(subscription) current_addons = [] if subscription.devices == 0 else get_subscription_addon_ids(subscription)
@@ -142,11 +149,10 @@ async def apply_order_now(
await replace_subscription_addons(session, subscription.id, addon_ids) await replace_subscription_addons(session, subscription.id, addon_ids)
order.applies_at = now order.applies_at = now
order.applied_at = now order.applied_at = now
return subscription
async def queue_order_for_later( async def queue_order_for_later(*, order: Order, subscription: Subscription, now: datetime) -> None:
*, order: Order, subscription: Subscription, now: datetime
) -> None:
order.applies_at = max(subscription.expires_at, now) order.applies_at = max(subscription.expires_at, now)
@@ -157,6 +163,7 @@ async def sync_user_subscription(
) -> None: ) -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
subscription = user.subscription subscription = user.subscription
applied_due_orders = False
if subscription is not None and subscription.expires_at <= now: if subscription is not None and subscription.expires_at <= now:
due_orders = [ due_orders = [
@@ -181,5 +188,14 @@ async def sync_user_subscription(
[addon.addon_id for addon in order.addons], [addon.addon_id for addon in order.addons],
) )
order.applied_at = now order.applied_at = now
applied_due_orders = True
else: else:
subscription.status = SubscriptionStatus.EXPIRED subscription.status = SubscriptionStatus.EXPIRED
if applied_due_orders:
await sync_subscription_by_telegram_id(
expires_at=subscription.expires_at,
devices=subscription.devices,
telegram_id=user.telegram_id,
username=user.username,
)