RemnaWave-bot Update

This commit is contained in:
2026-05-06 15:41:56 +03:00
parent ce7a1f70b2
commit 2cc2f12a21
22 changed files with 3087 additions and 129 deletions

View File

@@ -4,7 +4,7 @@ from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import PaymentPlanSettings, Settings
from app.config import PAYMENT_PRODUCT_PLAN_SPECS, PaymentPlanSettings, Settings
from app.db.base import utcnow
from app.db.models import BotSetting
@@ -34,11 +34,14 @@ class BotConfigSnapshot:
referral_discount_percent: int
referral_bonus_days: int
payment_plans_raw: str
payment_product_plans_raw: str
payment_transfer_text: str
payment_support_text: str
payment_plan_traffic_limit_gb: int
payment_plan_traffic_reset_period: str
payment_internal_squad_uuids_raw: str
payment_vpn_squad_uuids_raw: str
payment_white_squad_uuids_raw: str
payment_external_squad_uuid: str
payment_username_prefix: str
payment_user_tag: str
@@ -75,12 +78,15 @@ class BotConfigSnapshot:
@property
def payment_internal_squad_uuids(self) -> list[str]:
values: list[str] = []
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
part = raw_part.strip()
if part:
values.append(part)
return values
return self._parse_csv_list(self.payment_internal_squad_uuids_raw)
@property
def payment_vpn_squad_uuids(self) -> list[str]:
return self._parse_csv_list(self.payment_vpn_squad_uuids_raw)
@property
def payment_white_squad_uuids(self) -> list[str]:
return self._parse_csv_list(self.payment_white_squad_uuids_raw)
@property
def notification_days_list(self) -> list[int]:
@@ -95,6 +101,13 @@ class BotConfigSnapshot:
@property
def payment_plans(self) -> list[PaymentPlanSettings]:
product_plans = self._parse_product_plans(
self.payment_product_plans_raw,
default_days=30,
)
if product_plans:
return product_plans
values: list[PaymentPlanSettings] = []
for raw_part in self.payment_plans_raw.split(","):
part = raw_part.strip()
@@ -120,6 +133,80 @@ class BotConfigSnapshot:
return sorted(values, key=lambda item: item.days)
@staticmethod
def _parse_csv_list(raw_value: str) -> list[str]:
values: list[str] = []
for raw_part in raw_value.split(","):
part = raw_part.strip()
if part:
values.append(part)
return values
@staticmethod
def _parse_product_plans(
raw_value: str,
*,
default_days: int,
) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
seen_codes: set[str] = set()
for raw_part in raw_value.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
# Legacy format: code:amount (2 fields)
if len(fragments) == 2:
code, raw_amount = fragments
pairs = [(str(default_days), raw_amount)]
# Standard format: code:days:amount (3 fields)
elif len(fragments) == 3:
code = fragments[0]
pairs = [(fragments[1], fragments[2])]
# Extended format: code:days1:amount1:days2:amount2:... (odd ≥ 5)
elif len(fragments) >= 5 and len(fragments) % 2 == 1:
code = fragments[0]
pairs = [
(fragments[i], fragments[i + 1])
for i in range(1, len(fragments), 2)
]
else:
continue
normalized_code = code.lower()
spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
if spec is None or normalized_code in seen_codes:
continue
title, description, squad_groups = spec
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
continue
days = int(raw_days)
amount_rub = int(raw_amount)
if days <= 0 or amount_rub <= 0:
continue
plan_code = f"{normalized_code}_{days}"
values.append(
PaymentPlanSettings(
code=plan_code,
days=days,
amount_rub=amount_rub,
title=title,
description=description,
squad_groups=squad_groups,
)
)
seen_codes.add(normalized_code)
return values
@staticmethod
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
normalized = " ".join(raw_value.split()).strip()
@@ -176,6 +263,7 @@ class StaticBotConfigService:
referral_discount_percent=self._settings.referral_discount_percent,
referral_bonus_days=self._settings.referral_bonus_days,
payment_plans_raw=self._settings.payment_plans_raw,
payment_product_plans_raw=self._settings.payment_product_plans_raw,
payment_transfer_text=self._settings.payment_transfer_text,
payment_support_text=self._settings.payment_support_text,
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
@@ -184,6 +272,8 @@ class StaticBotConfigService:
notification_days_before="1,3",
notification_check_interval_hours=6,
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
payment_vpn_squad_uuids_raw=self._settings.payment_vpn_squad_uuids_raw,
payment_white_squad_uuids_raw=self._settings.payment_white_squad_uuids_raw,
payment_external_squad_uuid=self._settings.payment_external_squad_uuid,
payment_username_prefix=self._settings.payment_username_prefix,
payment_user_tag=self._settings.payment_user_tag,
@@ -233,9 +323,24 @@ class BotConfigService(StaticBotConfigService):
label="Тарифы и цены",
section="pricing",
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
description="Каждая пара это `дни:цена_в_рублях`.",
description="Каждая пара - это `дни:цена_в_рублях`.",
placeholder="30:250,180:600,365:1000",
),
"payment_product_plans_raw": BotConfigFieldSpec(
key="payment_product_plans_raw",
label="Типы подписок и цены",
section="pricing",
prompt=(
"Введите типы подписок в формате "
"`vpn_white:30:450:180:2200:365:4000,white:30:250,vpn:30:200`.\n"
"Каждый продукт: `код:дни1:цена1:дни2:цена2:...`"
),
description=(
"Если заполнено, заменяет обычные PAYMENT_PLANS. "
"Коды: vpn_white, white, vpn. Формат: `код:дни1:цена1[:дни2:цена2:...]`."
),
placeholder="vpn_white:30:450:180:2200:365:4000,white:30:250:180:1200,vpn:30:200:180:1000",
),
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
key="payment_plan_traffic_limit_gb",
label="Лимит трафика, GB",
@@ -332,6 +437,22 @@ class BotConfigService(StaticBotConfigService):
description="Эти группы назначаются пользователю в Remnawave.",
placeholder="11111111-1111-1111-1111-111111111111",
),
"payment_vpn_squad_uuids_raw": BotConfigFieldSpec(
key="payment_vpn_squad_uuids_raw",
label="UUID VPN-групп",
section="provisioning",
prompt="Введите UUID squad для тарифа VPN через запятую.",
description="Назначается тарифам `vpn` и `vpn_white`.",
placeholder="11111111-1111-1111-1111-111111111111",
),
"payment_white_squad_uuids_raw": BotConfigFieldSpec(
key="payment_white_squad_uuids_raw",
label="UUID групп белых списков",
section="provisioning",
prompt="Введите UUID squad для белых списков через запятую.",
description="Назначается тарифам `white` и `vpn_white`.",
placeholder="22222222-2222-2222-2222-222222222222",
),
"payment_external_squad_uuid": BotConfigFieldSpec(
key="payment_external_squad_uuid",
label="UUID внешней группы",
@@ -429,6 +550,12 @@ class BotConfigService(StaticBotConfigService):
default=self._settings.referral_bonus_days,
),
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
payment_product_plans_raw=str(
overrides.get(
"payment_product_plans_raw",
self._settings.payment_product_plans_raw,
)
),
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
payment_plan_traffic_limit_gb=self._parse_int(
@@ -447,6 +574,18 @@ class BotConfigService(StaticBotConfigService):
self._settings.payment_internal_squad_uuids_raw,
)
),
payment_vpn_squad_uuids_raw=str(
overrides.get(
"payment_vpn_squad_uuids_raw",
self._settings.payment_vpn_squad_uuids_raw,
)
),
payment_white_squad_uuids_raw=str(
overrides.get(
"payment_white_squad_uuids_raw",
self._settings.payment_white_squad_uuids_raw,
)
),
payment_external_squad_uuid=str(
overrides.get(
"payment_external_squad_uuid",
@@ -533,11 +672,17 @@ class BotConfigService(StaticBotConfigService):
"bot_support_ticket_link",
"payment_review_link",
"bot_start_image_path",
"payment_product_plans_raw",
"payment_vpn_squad_uuids_raw",
"payment_white_squad_uuids_raw",
"payment_external_squad_uuid",
"payment_user_tag",
"payment_username_prefix",
"payment_internal_squad_uuids_raw",
"payment_vpn_squad_uuids_raw",
"payment_white_squad_uuids_raw",
"payment_plans_raw",
"payment_product_plans_raw",
"payment_plan_traffic_reset_period",
"bot_brand_name",
"notification_days_before",
@@ -611,6 +756,20 @@ class BotConfigService(StaticBotConfigService):
plans = self._parse_payment_plans(value)
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
if key == "payment_product_plans_raw":
plans = self._parse_product_plans(value)
# Re-serialize: group plans by product code prefix
product_groups: dict[str, list[PaymentPlanSettings]] = {}
for plan in plans:
# Extract product code from plan code (e.g. "vpn_white" from "vpn_white_30")
product_code = "_".join(plan.code.split("_")[:-1]) if "_" in plan.code and plan.code.split("_")[-1].isdigit() else plan.code
product_groups.setdefault(product_code, []).append(plan)
parts = []
for product_code, group_plans in product_groups.items():
pairs = ":".join(f"{p.days}:{p.amount_rub}" for p in group_plans)
parts.append(f"{product_code}:{pairs}")
return ",".join(parts)
if key == "payment_plan_traffic_limit_gb":
amount = self._parse_non_negative_int(value, "Лимит трафика")
return str(amount)
@@ -652,6 +811,12 @@ class BotConfigService(StaticBotConfigService):
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
return ",".join(normalized_parts)
if key in {"payment_vpn_squad_uuids_raw", "payment_white_squad_uuids_raw"}:
normalized_parts = [part.strip() for part in value.split(",") if part.strip()]
if not normalized_parts:
raise ValueError("Нужно указать хотя бы один UUID группы.")
return ",".join(normalized_parts)
if key == "payment_external_squad_uuid":
return value
@@ -711,6 +876,75 @@ class BotConfigService(StaticBotConfigService):
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
return int(raw_value.strip())
@staticmethod
def _parse_product_plans(raw_value: str) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
seen_codes: set[str] = set()
for raw_part in raw_value.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
# Legacy: code:amount (2 fields)
if len(fragments) == 2:
code, raw_amount = fragments
pairs = [("30", raw_amount)]
# Standard: code:days:amount (3 fields)
elif len(fragments) == 3:
code = fragments[0]
pairs = [(fragments[1], fragments[2])]
# Extended: code:days1:amount1:days2:amount2:... (odd ≥ 5)
elif len(fragments) >= 5 and len(fragments) % 2 == 1:
code = fragments[0]
pairs = [
(fragments[i], fragments[i + 1])
for i in range(1, len(fragments), 2)
]
else:
raise ValueError(
"Типы подписок: `код:дни1:цена1[:дни2:цена2:...]`, "
"например `vpn_white:30:450:180:2200,white:30:250,vpn:30:200`."
)
normalized_code = code.lower()
spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
if spec is None:
raise ValueError("Неизвестный код подписки. Доступны: vpn_white, white, vpn.")
if normalized_code in seen_codes:
raise ValueError("Коды подписок не должны повторяться.")
title, description, squad_groups = spec
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
raise ValueError("Дни и цена должны быть целыми положительными числами.")
days = int(raw_days)
amount_rub = int(raw_amount)
if days <= 0 or amount_rub <= 0:
raise ValueError("У подписок дни и цена должны быть больше нуля.")
plan_code = f"{normalized_code}_{days}"
values.append(
PaymentPlanSettings(
code=plan_code,
days=days,
amount_rub=amount_rub,
title=title,
description=description,
squad_groups=squad_groups,
)
)
seen_codes.add(normalized_code)
if not values:
raise ValueError("Нужно указать хотя бы один тип подписки.")
return values
@staticmethod
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []