Files
RemnaWaveBOT/app/services/bot_config_service.py
2026-05-20 10:11:47 +03:00

981 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import PAYMENT_PRODUCT_PLAN_SPECS, PaymentPlanSettings, Settings
from app.db.base import utcnow
from app.db.models import BotSetting
@dataclass(frozen=True, slots=True)
class BotConfigFieldSpec:
key: str
label: str
section: str
prompt: str
description: str
placeholder: str = ""
is_secret_like: bool = False
@dataclass(slots=True)
class BotConfigSnapshot:
bot_brand_name: str
bot_public_username: str
bot_support_url: str
bot_terms_url: str
bot_support_ticket_link: str
payment_review_link: str
bot_start_image_enabled: bool
bot_start_image_path: str
referral_enabled: bool
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
notification_enabled: bool = True
notification_days_before: str = "1,3"
notification_check_interval_hours: int = 6
fallback_support_ticket_chat_id: int | None = None
fallback_support_ticket_thread_id: int | None = None
fallback_payment_review_chat_id: int | None = None
fallback_payment_review_thread_id: int | None = None
@property
def bot_public_username_normalized(self) -> str:
return self.bot_public_username.strip().removeprefix("@")
@property
def payment_external_squad_uuid_normalized(self) -> str | None:
value = self.payment_external_squad_uuid.strip()
return value or None
@property
def payment_username_prefix_normalized(self) -> str:
normalized = "".join(
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
)
return normalized[:12] or "Oreol"
@property
def payment_user_tag_normalized(self) -> str | None:
normalized = "".join(
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
)
return normalized[:16] or None
@property
def payment_internal_squad_uuids(self) -> list[str]:
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]:
values: list[int] = []
for raw_part in self.notification_days_before.split(","):
part = raw_part.strip()
if part.isdigit():
day = int(part)
if day > 0:
values.append(day)
return sorted(set(values), reverse=True) if values else [1, 3]
@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()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
continue
days = int(fragments[0])
amount_rub = int(fragments[1])
if days <= 0 or amount_rub <= 0:
continue
values.append(
PaymentPlanSettings(
code=f"{days}d",
days=days,
amount_rub=amount_rub,
)
)
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
use_duration_suffix = len(pairs) > 1
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}" if use_duration_suffix else normalized_code
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()
if not normalized:
return "-"
if len(normalized) <= max_length:
return normalized
return f"{normalized[: max_length - 3]}..."
@property
def support_ticket_chat_id(self) -> int | None:
parsed_chat_id, _ = Settings._parse_private_topic_link(self.bot_support_ticket_link)
return parsed_chat_id if parsed_chat_id is not None else self.fallback_support_ticket_chat_id
@property
def support_ticket_message_thread_id(self) -> int | None:
_, parsed_thread_id = Settings._parse_private_topic_link(self.bot_support_ticket_link)
return (
parsed_thread_id
if parsed_thread_id is not None
else self.fallback_support_ticket_thread_id
)
@property
def payment_review_chat_id(self) -> int | None:
parsed_chat_id, _ = Settings._parse_private_topic_link(self.payment_review_link)
return parsed_chat_id if parsed_chat_id is not None else self.fallback_payment_review_chat_id
@property
def payment_review_message_thread_id(self) -> int | None:
_, parsed_thread_id = Settings._parse_private_topic_link(self.payment_review_link)
return (
parsed_thread_id
if parsed_thread_id is not None
else self.fallback_payment_review_thread_id
)
class StaticBotConfigService:
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def get_snapshot(self) -> BotConfigSnapshot:
return BotConfigSnapshot(
bot_brand_name=self._settings.bot_brand_name,
bot_public_username=self._settings.bot_public_username,
bot_support_url=self._settings.bot_support_url,
bot_terms_url=self._settings.bot_terms_url,
bot_support_ticket_link=self._settings.bot_support_ticket_link,
payment_review_link=self._settings.payment_review_link,
bot_start_image_enabled=self._settings.bot_start_image_enabled,
bot_start_image_path=self._settings.bot_start_image_path,
referral_enabled=True,
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,
payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period,
notification_enabled=True,
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,
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
)
class BotConfigService(StaticBotConfigService):
FIELD_SPECS: dict[str, BotConfigFieldSpec] = {
"bot_brand_name": BotConfigFieldSpec(
key="bot_brand_name",
label="Название бренда",
section="brand",
prompt="Введите новое название бренда.",
description="Используется в главной панели и пользовательских сообщениях.",
placeholder="OREOL VPN",
),
"bot_public_username": BotConfigFieldSpec(
key="bot_public_username",
label="Username бота",
section="brand",
prompt="Введите username бота без @. Для очистки отправьте `-`.",
description="Нужен для генерации реферальных ссылок.",
placeholder="oreol_vpn_bot",
),
"bot_start_image_enabled": BotConfigFieldSpec(
key="bot_start_image_enabled",
label="Стартовая картинка",
section="brand",
prompt="Введите `on` или `off`.",
description="Включает или выключает бренд-картинку в панели и выдаче доступа.",
placeholder="on",
),
"bot_start_image_path": BotConfigFieldSpec(
key="bot_start_image_path",
label="Путь к картинке",
section="brand",
prompt="Введите путь к изображению. Для очистки отправьте `-`.",
description="Можно указать относительный путь от корня проекта.",
placeholder="assets/main.png",
),
"payment_plans_raw": BotConfigFieldSpec(
key="payment_plans_raw",
label="Тарифы и цены",
section="pricing",
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
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",
section="pricing",
prompt="Введите лимит трафика в гигабайтах. `0` = безлимит.",
description="Применяется к тарифам, создаваемым ботом.",
placeholder="0",
),
"payment_plan_traffic_reset_period": BotConfigFieldSpec(
key="payment_plan_traffic_reset_period",
label="Стратегия сброса трафика",
section="pricing",
prompt="Введите стратегию, например `NO_RESET`.",
description="Значение передаётся в Remnawave.",
placeholder="NO_RESET",
),
"referral_enabled": BotConfigFieldSpec(
key="referral_enabled",
label="Реферальная система",
section="referral",
prompt="Введите `on` или `off`.",
description="Включает или выключает ввод рефкода и начисление реферальных бонусов.",
placeholder="on",
),
"referral_discount_percent": BotConfigFieldSpec(
key="referral_discount_percent",
label="Скидка по рефкоду, %",
section="referral",
prompt="Введите размер скидки в процентах от 0 до 100.",
description="Применяется к покупателю перед оплатой.",
placeholder="5",
),
"referral_bonus_days": BotConfigFieldSpec(
key="referral_bonus_days",
label="Бонус рефереру, дней",
section="referral",
prompt="Введите количество бонусных дней. `0` отключает бонус.",
description="Начисляется после подтверждённой оплаты по рефкоду.",
placeholder="7",
),
"bot_support_url": BotConfigFieldSpec(
key="bot_support_url",
label="Ссылка на поддержку",
section="links",
prompt="Введите URL поддержки. Для очистки отправьте `-`.",
description="Кнопка быстрой связи с поддержкой в пользовательской панели.",
placeholder="https://t.me/your_support_account",
),
"bot_terms_url": BotConfigFieldSpec(
key="bot_terms_url",
label="Ссылка на Terms",
section="links",
prompt="Введите URL правил использования. Для очистки отправьте `-`.",
description="Открывается из раздела условий использования.",
placeholder="https://example.com/terms",
),
"bot_support_ticket_link": BotConfigFieldSpec(
key="bot_support_ticket_link",
label="Ссылка на чат тикетов",
section="links",
prompt="Введите приватную ссылку на чат/топик тикетов. Для очистки отправьте `-`.",
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/1234567890/2/3",
),
"payment_review_link": BotConfigFieldSpec(
key="payment_review_link",
label="Ссылка на очередь оплат",
section="links",
prompt="Введите приватную ссылку на чат/топик проверки оплат. Для очистки отправьте `-`.",
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/1234567890/56/57",
),
"payment_transfer_text": BotConfigFieldSpec(
key="payment_transfer_text",
label="Инструкция по оплате",
section="texts",
prompt="Введите текст с реквизитами или инструкцией перевода.",
description="Показывается пользователю после выбора тарифа.",
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN",
),
"payment_support_text": BotConfigFieldSpec(
key="payment_support_text",
label="Текст поддержки по оплате",
section="texts",
prompt="Введите текст, который бот покажет при проблемах с оплатой.",
description="Используется при отклонении оплаты и ошибках выдачи доступа.",
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
),
"payment_internal_squad_uuids_raw": BotConfigFieldSpec(
key="payment_internal_squad_uuids_raw",
label="UUID внутренних групп",
section="provisioning",
prompt="Введите UUID внутренних групп через запятую.",
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 внешней группы",
section="provisioning",
prompt="Введите UUID внешней группы. Для очистки отправьте `-`.",
description="Необязательный внешний squad для новых пользователей.",
placeholder="11111111-1111-1111-1111-111111111111",
),
"payment_username_prefix": BotConfigFieldSpec(
key="payment_username_prefix",
label="Префикс логина",
section="provisioning",
prompt="Введите префикс логина для новых пользователей Remnawave.",
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
placeholder="Oreol",
),
"payment_user_tag": BotConfigFieldSpec(
key="payment_user_tag",
label="Tag пользователей",
section="provisioning",
prompt="Введите tag для создаваемых пользователей. Для очистки отправьте `-`.",
description="Опциональный tag в Remnawave.",
placeholder="BOT",
),
"notification_enabled": BotConfigFieldSpec(
key="notification_enabled",
label="Уведомления об истечении",
section="notifications",
prompt="Введите `on` или `off`.",
description="Включает или выключает уведомления о скором истечении подписки.",
placeholder="on",
),
"notification_days_before": BotConfigFieldSpec(
key="notification_days_before",
label="Дни до истечения",
section="notifications",
prompt="Введите дни через запятую, например `1,3`.",
description="За сколько дней до истечения отправлять уведомления.",
placeholder="1,3",
),
"notification_check_interval_hours": BotConfigFieldSpec(
key="notification_check_interval_hours",
label="Интервал проверки, ч",
section="notifications",
prompt="Введите интервал проверки в часах.",
description="Как часто бот проверяет подписки на истечение.",
placeholder="6",
),
}
SECTION_LABELS: dict[str, str] = {
"brand": "Бренд",
"pricing": "Тарифы",
"referral": "Рефералка",
"links": "Ссылки",
"texts": "Тексты",
"provisioning": "Provisioning",
"notifications": "Уведомления",
}
def __init__(
self,
*,
settings: Settings,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
super().__init__(settings)
self._session_factory = session_factory
async def get_snapshot(self) -> BotConfigSnapshot:
overrides = await self._get_overrides()
return BotConfigSnapshot(
bot_brand_name=str(overrides.get("bot_brand_name", self._settings.bot_brand_name)),
bot_public_username=str(overrides.get("bot_public_username", self._settings.bot_public_username)),
bot_support_url=str(overrides.get("bot_support_url", self._settings.bot_support_url)),
bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)),
bot_support_ticket_link=str(overrides.get("bot_support_ticket_link", self._settings.bot_support_ticket_link)),
payment_review_link=str(overrides.get("payment_review_link", self._settings.payment_review_link)),
bot_start_image_enabled=self._parse_bool(
overrides.get("bot_start_image_enabled"),
default=self._settings.bot_start_image_enabled,
),
bot_start_image_path=str(overrides.get("bot_start_image_path", self._settings.bot_start_image_path)),
referral_enabled=self._parse_bool(
overrides.get("referral_enabled"),
default=True,
),
referral_discount_percent=self._parse_int(
overrides.get("referral_discount_percent"),
default=self._settings.referral_discount_percent,
),
referral_bonus_days=self._parse_int(
overrides.get("referral_bonus_days"),
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(
overrides.get("payment_plan_traffic_limit_gb"),
default=self._settings.payment_plan_traffic_limit_gb,
),
payment_plan_traffic_reset_period=str(
overrides.get(
"payment_plan_traffic_reset_period",
self._settings.payment_plan_traffic_reset_period,
)
),
payment_internal_squad_uuids_raw=str(
overrides.get(
"payment_internal_squad_uuids_raw",
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",
self._settings.payment_external_squad_uuid,
)
),
payment_username_prefix=str(
overrides.get("payment_username_prefix", self._settings.payment_username_prefix)
),
payment_user_tag=str(overrides.get("payment_user_tag", self._settings.payment_user_tag)),
notification_enabled=self._parse_bool(
overrides.get("notification_enabled"),
default=True,
),
notification_days_before=str(overrides.get("notification_days_before", "1,3")),
notification_check_interval_hours=self._parse_int(
overrides.get("notification_check_interval_hours"),
default=6,
),
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
)
async def update_setting(
self,
*,
key: str,
raw_value: str,
updated_by_telegram_id: int,
) -> str:
spec = self.FIELD_SPECS.get(key)
if spec is None:
raise ValueError("Неизвестная настройка.")
normalized_value = self._normalize_value(key, raw_value)
async with self._session_factory() as session:
record = await session.scalar(
select(BotSetting).where(BotSetting.key == key)
)
if record is None:
record = BotSetting(key=key)
session.add(record)
record.value = normalized_value
record.updated_by_telegram_id = updated_by_telegram_id
record.updated_at = utcnow()
await session.commit()
return normalized_value
@classmethod
def get_spec(cls, key: str) -> BotConfigFieldSpec | None:
return cls.FIELD_SPECS.get(key)
@classmethod
def get_specs_for_section(cls, section: str) -> list[BotConfigFieldSpec]:
return [spec for spec in cls.FIELD_SPECS.values() if spec.section == section]
@classmethod
def get_sections(cls) -> list[tuple[str, str]]:
return list(cls.SECTION_LABELS.items())
@classmethod
def get_section_label(cls, section: str) -> str:
return cls.SECTION_LABELS.get(section, section)
@classmethod
def format_value(cls, *, snapshot: BotConfigSnapshot, key: str) -> str:
value = getattr(snapshot, key, "")
if key in {"bot_start_image_enabled", "referral_enabled", "notification_enabled"}:
return "on" if bool(value) else "off"
if key == "bot_public_username":
normalized = snapshot.bot_public_username_normalized
return f"@{normalized}" if normalized else "-"
if key in {
"bot_support_url",
"bot_terms_url",
"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",
}:
return cls._compact_text(str(value))
if key == "payment_plan_traffic_limit_gb":
amount = int(value or 0)
return "0 GB (unlimited)" if amount <= 0 else f"{amount} GB"
if key == "referral_discount_percent":
return f"{max(int(value or 0), 0)}%"
if key == "referral_bonus_days":
return f"{max(int(value or 0), 0)} days"
if key == "notification_check_interval_hours":
return f"{max(int(value or 6), 1)} ч"
if key in {"payment_transfer_text", "payment_support_text"}:
return cls._compact_text(str(value), max_length=120)
return cls._compact_text(str(value))
@staticmethod
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
normalized = " ".join(raw_value.split()).strip()
if not normalized:
return "-"
if len(normalized) <= max_length:
return normalized
return f"{normalized[: max_length - 3]}..."
async def _get_overrides(self) -> dict[str, str]:
async with self._session_factory() as session:
rows = await session.execute(select(BotSetting))
return {
row.key: row.value
for row in rows.scalars().all()
}
def _normalize_value(self, key: str, raw_value: str) -> str:
value = raw_value.strip()
if key in {
"bot_support_url",
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
"bot_start_image_path",
"payment_external_squad_uuid",
"payment_user_tag",
"bot_public_username",
} and value == "-":
return ""
if key == "bot_brand_name":
if not value:
raise ValueError("Название бренда не может быть пустым.")
return value[:80]
if key == "bot_public_username":
return value.removeprefix("@")
if key == "bot_start_image_enabled":
return "true" if self._parse_bool_token(value) else "false"
if key == "bot_start_image_path":
return value
if key == "payment_plans_raw":
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)
if key == "payment_plan_traffic_reset_period":
if not value:
raise ValueError("Стратегия сброса трафика не может быть пустой.")
return value.upper()
if key == "referral_enabled":
return "true" if self._parse_bool_token(value) else "false"
if key == "referral_discount_percent":
amount = self._parse_non_negative_int(value, "Размер скидки")
if amount > 100:
raise ValueError("Размер скидки должен быть в диапазоне от 0 до 100.")
return str(amount)
if key == "referral_bonus_days":
amount = self._parse_non_negative_int(value, "Размер бонуса")
return str(amount)
if key in {
"bot_support_url",
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
}:
return value
if key in {"payment_transfer_text", "payment_support_text"}:
if not value:
raise ValueError("Текст не может быть пустым.")
return value[:4000]
if key == "payment_internal_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 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
if key == "payment_username_prefix":
if not value:
raise ValueError("Префикс логина не может быть пустым.")
return value[:32]
if key == "payment_user_tag":
return value
if key == "notification_enabled":
return "true" if self._parse_bool_token(value) else "false"
if key == "notification_days_before":
parts = [p.strip() for p in value.split(",") if p.strip().isdigit() and int(p.strip()) > 0]
if not parts:
raise ValueError("Нужно указать хотя бы один день, например `1,3`.")
return ",".join(parts)
if key == "notification_check_interval_hours":
amount = self._parse_non_negative_int(value, "Интервал проверки")
if amount < 1:
raise ValueError("Интервал должен быть не менее 1 часа.")
return str(amount)
raise ValueError("Настройка пока не поддерживается.")
@staticmethod
def _parse_int(raw_value: object | None, *, default: int) -> int:
try:
return int(str(raw_value).strip())
except (TypeError, ValueError):
return default
@staticmethod
def _parse_bool(raw_value: object | None, *, default: bool) -> bool:
if raw_value is None:
return default
try:
return BotConfigService._parse_bool_token(str(raw_value))
except ValueError:
return default
@staticmethod
def _parse_bool_token(raw_value: str) -> bool:
normalized = raw_value.strip().lower()
if normalized in {"1", "true", "on", "yes", "y", "да", "вкл"}:
return True
if normalized in {"0", "false", "off", "no", "n", "нет", "выкл"}:
return False
raise ValueError("Введите `on` или `off`.")
@staticmethod
def _parse_non_negative_int(raw_value: str, label: str) -> int:
if not raw_value.strip().isdigit():
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
use_duration_suffix = len(pairs) > 1
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}" if use_duration_suffix else normalized_code
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] = []
for raw_part in raw_value.split(","):
part = raw_part.strip()
if not part:
continue
fragments = [fragment.strip() for fragment in part.split(":")]
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
raise ValueError(
"Тарифы должны быть в формате `дни:цена`, например `30:250,180:600`."
)
days = int(fragments[0])
amount_rub = int(fragments[1])
if days <= 0 or amount_rub <= 0:
raise ValueError("У тарифов дни и цена должны быть больше нуля.")
values.append(
PaymentPlanSettings(
code=f"{days}d",
days=days,
amount_rub=amount_rub,
)
)
if not values:
raise ValueError("Нужно указать хотя бы один тариф.")
return sorted(values, key=lambda item: item.days)