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///`.", 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///`.", 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="Итоговый логин строится как `--`.", 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)