Files
RemnaWaveBOT/app/config.py
2026-05-06 15:41:56 +03:00

424 lines
15 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from functools import cached_property
from urllib.parse import quote_plus, urlparse
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@dataclass(frozen=True, slots=True)
class PaymentPlanSettings:
code: str
days: int
amount_rub: int
title: str = ""
description: str = ""
squad_groups: tuple[str, ...] = ()
PAYMENT_PRODUCT_PLAN_SPECS: dict[str, tuple[str, str, tuple[str, ...]]] = {
"vpn_white": (
"VPN + Белые списки",
"VPN и белые списки в одном тарифе",
("vpn", "white"),
),
"white": (
"Белые списки",
"Доступ только к белым спискам",
("white",),
),
"vpn": (
"VPN",
"Доступ только к VPN без белых списков",
("vpn",),
),
}
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
bot_token: str = Field(validation_alias="BOT_TOKEN")
bot_admin_ids_raw: str = Field(default="", validation_alias="BOT_ADMIN_IDS")
bot_moderator_ids_raw: str = Field(default="", validation_alias="BOT_MODERATOR_IDS")
telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL")
bot_brand_name: str = Field(default="OREOL VPN", validation_alias="BOT_BRAND_NAME")
bot_public_username: str = Field(default="", validation_alias="BOT_PUBLIC_USERNAME")
bot_support_url: str = Field(default="", validation_alias="BOT_SUPPORT_URL")
bot_terms_url: str = Field(default="", validation_alias="BOT_TERMS_URL")
bot_support_ticket_link: str = Field(default="", validation_alias="BOT_SUPPORT_TICKET_LINK")
bot_support_ticket_chat_id_raw: str = Field(
default="",
validation_alias="BOT_SUPPORT_TICKET_CHAT_ID",
)
bot_support_ticket_thread_id: int = Field(
default=0,
validation_alias="BOT_SUPPORT_TICKET_THREAD_ID",
)
bot_start_image_enabled: bool = Field(
default=True,
validation_alias="BOT_START_IMAGE_ENABLED",
)
bot_start_image_path: str = Field(default="", validation_alias="BOT_START_IMAGE_PATH")
remnawave_base_url: str = Field(validation_alias="REMNAWAVE_BASE_URL")
remnawave_api_token: str = Field(default="", validation_alias="REMNAWAVE_API_TOKEN")
remnawave_caddy_api_key: str = Field(default="", validation_alias="REMNAWAVE_CADDY_API_KEY")
remnawave_timeout_seconds: float = Field(
default=20.0,
validation_alias="REMNAWAVE_TIMEOUT_SECONDS",
)
db_host: str = Field(default="127.0.0.1", validation_alias="DB_HOST")
db_port: int = Field(default=3306, validation_alias="DB_PORT")
db_name: str = Field(default="telegabot", validation_alias="DB_NAME")
db_user: str = Field(default="telegabot", validation_alias="DB_USER")
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
db_echo: bool = Field(default=False, validation_alias="DB_ECHO")
create_database_on_start: bool = Field(
default=True,
validation_alias="CREATE_DATABASE_ON_START",
)
create_tables_on_start: bool = Field(
default=True,
validation_alias="CREATE_TABLES_ON_START",
)
sync_subscription_history: bool = Field(
default=True,
validation_alias="SYNC_SUBSCRIPTION_HISTORY",
)
sync_batch_size: int = Field(default=100, validation_alias="SYNC_BATCH_SIZE")
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
payment_plans_raw: str = Field(
default="30:250,180:600,365:1000",
validation_alias="PAYMENT_PLANS",
)
payment_product_plans_raw: str = Field(
default="",
validation_alias="PAYMENT_PRODUCT_PLANS",
)
payment_transfer_text: str = Field(
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
validation_alias="PAYMENT_TRANSFER_TEXT",
)
payment_review_link: str = Field(default="", validation_alias="PAYMENT_REVIEW_LINK")
payment_review_chat_id_raw: str = Field(
default="",
validation_alias="PAYMENT_REVIEW_CHAT_ID",
)
payment_review_thread_id: int = Field(
default=0,
validation_alias="PAYMENT_REVIEW_THREAD_ID",
)
referral_discount_percent: int = Field(
default=5,
validation_alias="REFERRAL_DISCOUNT_PERCENT",
)
referral_bonus_days: int = Field(
default=7,
validation_alias="REFERRAL_BONUS_DAYS",
)
payment_plan_code: str = Field(default="vpn_30d", validation_alias="PAYMENT_PLAN_CODE")
payment_plan_title: str = Field(
default="OREOL VPN на 30 дней",
validation_alias="PAYMENT_PLAN_TITLE",
)
payment_plan_description: str = Field(
default="Доступ к VPN на 30 дней",
validation_alias="PAYMENT_PLAN_DESCRIPTION",
)
payment_plan_price_stars: int = Field(default=500, validation_alias="PAYMENT_PLAN_PRICE_STARS")
payment_plan_duration_days: int = Field(
default=30,
validation_alias="PAYMENT_PLAN_DURATION_DAYS",
)
payment_plan_traffic_limit_gb: int = Field(
default=0,
validation_alias="PAYMENT_PLAN_TRAFFIC_LIMIT_GB",
)
payment_plan_traffic_reset_period: str = Field(
default="NO_RESET",
validation_alias="PAYMENT_PLAN_TRAFFIC_RESET_PERIOD",
)
payment_internal_squad_uuids_raw: str = Field(
default="",
validation_alias="PAYMENT_INTERNAL_SQUAD_UUIDS",
)
payment_vpn_squad_uuids_raw: str = Field(
default="",
validation_alias="PAYMENT_VPN_SQUAD_UUIDS",
)
payment_white_squad_uuids_raw: str = Field(
default="",
validation_alias="PAYMENT_WHITE_SQUAD_UUIDS",
)
payment_external_squad_uuid: str = Field(
default="",
validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID",
)
payment_support_text: str = Field(
default="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
validation_alias="PAYMENT_SUPPORT_TEXT",
)
payment_username_prefix: str = Field(
default="Oreol",
validation_alias="PAYMENT_USERNAME_PREFIX",
)
payment_user_tag: str = Field(default="BOT", validation_alias="PAYMENT_USER_TAG")
@cached_property
def bot_admin_ids(self) -> set[int]:
values: set[int] = set()
for raw_part in self.bot_admin_ids_raw.split(","):
part = raw_part.strip()
if part:
values.add(int(part))
return values
@cached_property
def bot_moderator_ids(self) -> set[int]:
values: set[int] = set()
for raw_part in self.bot_moderator_ids_raw.split(","):
part = raw_part.strip()
if part:
values.add(int(part))
return values
@property
def database_url(self) -> str:
user = quote_plus(self.db_user)
password = quote_plus(self.db_password)
return (
f"mysql+aiomysql://{user}:{password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
)
@property
def database_server_url(self) -> str:
user = quote_plus(self.db_user)
password = quote_plus(self.db_password)
return (
f"mysql+aiomysql://{user}:{password}"
f"@{self.db_host}:{self.db_port}/?charset=utf8mb4"
)
@cached_property
def support_ticket_chat_id(self) -> int | None:
if self.bot_support_ticket_chat_id_raw.strip():
return int(self.bot_support_ticket_chat_id_raw.strip())
parsed_chat_id, _ = self._parse_private_topic_link(self.bot_support_ticket_link)
return parsed_chat_id
@cached_property
def support_ticket_message_thread_id(self) -> int | None:
if self.bot_support_ticket_thread_id > 0:
return self.bot_support_ticket_thread_id
_, parsed_thread_id = self._parse_private_topic_link(self.bot_support_ticket_link)
return parsed_thread_id
@cached_property
def payment_review_chat_id(self) -> int | None:
if self.payment_review_chat_id_raw.strip():
return int(self.payment_review_chat_id_raw.strip())
parsed_chat_id, _ = self._parse_private_topic_link(self.payment_review_link)
return parsed_chat_id
@cached_property
def payment_review_message_thread_id(self) -> int | None:
if self.payment_review_thread_id > 0:
return self.payment_review_thread_id
_, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link)
return parsed_thread_id
@staticmethod
def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]:
cleaned = link.strip()
if not cleaned:
return None, None
parsed = urlparse(cleaned)
if parsed.netloc not in {"t.me", "telegram.me", "www.t.me", "www.telegram.me"}:
return None, None
parts = [part for part in parsed.path.split("/") if part]
if len(parts) < 3 or parts[0] != "c" or not parts[1].isdigit():
return None, None
chat_id = int(f"-100{parts[1]}")
thread_id = int(parts[2]) if len(parts) >= 4 and parts[2].isdigit() else None
return chat_id, thread_id
def is_admin(self, telegram_id: int | None) -> bool:
return telegram_id is not None and telegram_id in self.bot_admin_ids
def is_moderator(self, telegram_id: int | None) -> bool:
return telegram_id is not None and telegram_id in self.bot_moderator_ids
@property
def bot_public_username_normalized(self) -> str:
return self.bot_public_username.strip().removeprefix("@")
@cached_property
def payment_internal_squad_uuids(self) -> list[str]:
return self._parse_csv_list(self.payment_internal_squad_uuids_raw)
@cached_property
def payment_vpn_squad_uuids(self) -> list[str]:
return self._parse_csv_list(self.payment_vpn_squad_uuids_raw)
@cached_property
def payment_white_squad_uuids(self) -> list[str]:
return self._parse_csv_list(self.payment_white_squad_uuids_raw)
@cached_property
def payment_external_squad_uuid_normalized(self) -> str | None:
value = self.payment_external_squad_uuid.strip()
return value or None
@cached_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"
@cached_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
@cached_property
def payment_plans(self) -> list[PaymentPlanSettings]:
product_plans = self._parse_product_plans(
self.payment_product_plans_raw,
default_days=max(self.payment_plan_duration_days, 1),
)
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,
)
)
if values:
return sorted(values, key=lambda item: item.days)
fallback_amount = max(self.payment_plan_price_stars, 0)
fallback_days = max(self.payment_plan_duration_days, 0)
if fallback_amount > 0 and fallback_days > 0:
return [
PaymentPlanSettings(
code=self.payment_plan_code.strip() or f"{fallback_days}d",
days=fallback_days,
amount_rub=fallback_amount,
)
]
return []
@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) → single duration using default_days
if len(fragments) == 2:
code, raw_amount = fragments
pairs = [(str(default_days), raw_amount)]
# Standard format: code:days:amount (3 fields) → single duration
elif len(fragments) == 3:
code = fragments[0]
pairs = [(fragments[1], fragments[2])]
# Extended format: code:days1:amount1:days2:amount2:... (odd number ≥ 5)
elif len(fragments) >= 5 and len(fragments) % 2 == 1:
code = fragments[0]
pairs = [
(fragments[i], fragments[i + 1])
for i in range(1, len(fragments), 2)
]
else:
continue
normalized_code = code.lower()
spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
if spec is None or normalized_code in seen_codes:
continue
title, description, squad_groups = spec
for raw_days, raw_amount in pairs:
if not raw_days.isdigit() or not raw_amount.isdigit():
continue
days = int(raw_days)
amount_rub = int(raw_amount)
if days <= 0 or amount_rub <= 0:
continue
plan_code = f"{normalized_code}_{days}"
values.append(
PaymentPlanSettings(
code=plan_code,
days=days,
amount_rub=amount_rub,
title=title,
description=description,
squad_groups=squad_groups,
)
)
seen_codes.add(normalized_code)
return values