This commit is contained in:
2026-04-22 18:34:13 +03:00
parent 3992121397
commit ce7a1f70b2
60 changed files with 10243 additions and 3443 deletions

View File

@@ -0,0 +1,744 @@
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)