457 lines
16 KiB
Python
457 lines
16 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_success_text: str = Field(
|
|
default="Доступ выдан. Сохраните ссылку и используйте её для подключения.",
|
|
validation_alias="PAYMENT_SUCCESS_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",
|
|
)
|
|
broadcast_results_link: str = Field(
|
|
default="https://t.me/c/3646494169/67/68",
|
|
validation_alias="BROADCAST_RESULTS_LINK",
|
|
)
|
|
broadcast_results_chat_id_raw: str = Field(
|
|
default="",
|
|
validation_alias="BROADCAST_RESULTS_CHAT_ID",
|
|
)
|
|
broadcast_results_thread_id: int = Field(
|
|
default=0,
|
|
validation_alias="BROADCAST_RESULTS_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
|
|
|
|
@cached_property
|
|
def broadcast_results_chat_id(self) -> int | None:
|
|
if self.broadcast_results_chat_id_raw.strip():
|
|
return int(self.broadcast_results_chat_id_raw.strip())
|
|
|
|
parsed_chat_id, _ = self._parse_private_topic_link(self.broadcast_results_link)
|
|
return parsed_chat_id
|
|
|
|
@cached_property
|
|
def broadcast_results_message_thread_id(self) -> int | None:
|
|
if self.broadcast_results_thread_id > 0:
|
|
return self.broadcast_results_thread_id
|
|
|
|
_, parsed_thread_id = self._parse_private_topic_link(self.broadcast_results_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
|
|
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
|