745 lines
32 KiB
Python
745 lines
32 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
|
||
from app.config import 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_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_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]:
|
||
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
|
||
|
||
@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]:
|
||
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 _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_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_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_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_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_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_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_external_squad_uuid",
|
||
"payment_user_tag",
|
||
"payment_username_prefix",
|
||
"payment_internal_squad_uuids_raw",
|
||
"payment_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_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 == "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_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)
|