RemnaWave-bot Update
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -202,6 +202,14 @@ def build_panel_keyboard(
|
||||
text="👤 Пользователи",
|
||||
callback_data=_admin_callback("users"),
|
||||
)
|
||||
builder.button(
|
||||
text="🗂 История тикетов",
|
||||
callback_data=_admin_callback("tickets"),
|
||||
)
|
||||
builder.button(
|
||||
text="🎟 Промокоды",
|
||||
callback_data=_admin_callback("promos"),
|
||||
)
|
||||
builder.button(
|
||||
text="🔄 Полная синхронизация",
|
||||
callback_data=_admin_callback("sync_all"),
|
||||
@@ -213,13 +221,13 @@ def build_panel_keyboard(
|
||||
if payment_review_url.strip():
|
||||
builder.button(text="💸 Очередь оплат", url=payment_review_url.strip())
|
||||
if support_ticket_url.strip():
|
||||
builder.button(text="🎫 Тикеты", url=support_ticket_url.strip())
|
||||
builder.button(text="🎫 Чат тикетов", url=support_ticket_url.strip())
|
||||
builder.button(
|
||||
text="↻ Обновить",
|
||||
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
builder.adjust(2, 2, 2, 2)
|
||||
builder.adjust(2, 2, 2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
if section == PANEL_SECTION_SUBSCRIPTION:
|
||||
@@ -248,7 +256,7 @@ def build_panel_keyboard(
|
||||
url=context.referral_share_url,
|
||||
)
|
||||
builder.button(
|
||||
text="🎟 Ввести реферальный код",
|
||||
text="🎟 Ввести код скидки",
|
||||
callback_data=_referral_callback("apply"),
|
||||
)
|
||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
||||
|
||||
130
app/config.py
130
app/config.py
@@ -13,6 +13,28 @@ 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):
|
||||
@@ -78,6 +100,10 @@ class Settings(BaseSettings):
|
||||
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",
|
||||
@@ -125,6 +151,14 @@ class Settings(BaseSettings):
|
||||
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",
|
||||
@@ -237,12 +271,15 @@ class Settings(BaseSettings):
|
||||
|
||||
@cached_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
|
||||
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:
|
||||
@@ -265,6 +302,13 @@ class Settings(BaseSettings):
|
||||
|
||||
@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()
|
||||
@@ -303,3 +347,77 @@ class Settings(BaseSettings):
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ class TelegramUser(Base):
|
||||
language_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
discount_ever_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
@@ -131,6 +132,9 @@ class SupportTicket(Base):
|
||||
support_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="OPEN")
|
||||
support_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
closed_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -143,6 +147,7 @@ class ReferralInvite(Base):
|
||||
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
invited_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
invited_display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
discount_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
|
||||
|
||||
@@ -156,6 +161,34 @@ class ReferralCode(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promo_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
|
||||
plan_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
discount_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
created_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class PromoCodeApplication(Base):
|
||||
__tablename__ = "promo_code_applications"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
promo_code_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("promo_codes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class ReferralBonus(Base):
|
||||
__tablename__ = "referral_bonuses"
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ logger = logging.getLogger(__name__)
|
||||
PAYMENT_ORDERS_TABLE_NAME = "payment_orders"
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username"
|
||||
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX = "ix_payment_orders_provision_username"
|
||||
SUPPORT_TICKETS_TABLE_NAME = "support_tickets"
|
||||
REFERRAL_INVITES_TABLE_NAME = "referral_invites"
|
||||
TELEGRAM_USERS_TABLE_NAME = "telegram_users"
|
||||
|
||||
|
||||
def create_engine_and_session_factory(
|
||||
@@ -121,6 +124,60 @@ async def _normalize_payment_order_provision_username_index(
|
||||
)
|
||||
|
||||
|
||||
async def _get_mysql_column_names(
|
||||
connection: AsyncConnection,
|
||||
*,
|
||||
table_name: str,
|
||||
) -> set[str]:
|
||||
result = await connection.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = :table_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return {str(row[0]) for row in result}
|
||||
|
||||
|
||||
async def _normalize_support_ticket_columns(connection: AsyncConnection) -> None:
|
||||
if connection.dialect.name != "mysql":
|
||||
return
|
||||
|
||||
existing_columns = await _get_mysql_column_names(
|
||||
connection,
|
||||
table_name=SUPPORT_TICKETS_TABLE_NAME,
|
||||
)
|
||||
quoted_table_name = _quote_mysql_identifier(SUPPORT_TICKETS_TABLE_NAME)
|
||||
|
||||
required_columns = {
|
||||
"support_note": "TEXT NULL",
|
||||
"closed_by_telegram_id": "BIGINT NULL",
|
||||
"closed_at": "DATETIME NULL",
|
||||
}
|
||||
|
||||
for column_name, ddl in required_columns.items():
|
||||
if column_name in existing_columns:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Adding missing column %s to %s",
|
||||
column_name,
|
||||
SUPPORT_TICKETS_TABLE_NAME,
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE "
|
||||
f"{quoted_table_name} "
|
||||
"ADD COLUMN "
|
||||
f"{_quote_mysql_identifier(column_name)} {ddl}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def ensure_database_exists(
|
||||
server_url: str,
|
||||
database_name: str,
|
||||
@@ -146,7 +203,60 @@ async def ensure_database_exists(
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _normalize_referral_invites_columns(connection: AsyncConnection) -> None:
|
||||
"""Add discount_used column to referral_invites if missing."""
|
||||
if connection.dialect.name != "mysql":
|
||||
return
|
||||
|
||||
existing_columns = await _get_mysql_column_names(
|
||||
connection,
|
||||
table_name=REFERRAL_INVITES_TABLE_NAME,
|
||||
)
|
||||
quoted_table_name = _quote_mysql_identifier(REFERRAL_INVITES_TABLE_NAME)
|
||||
|
||||
if "discount_used" not in existing_columns:
|
||||
logger.info(
|
||||
"Adding missing column discount_used to %s",
|
||||
REFERRAL_INVITES_TABLE_NAME,
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE "
|
||||
f"{quoted_table_name} "
|
||||
"ADD COLUMN `discount_used` BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _normalize_telegram_users_columns(connection: AsyncConnection) -> None:
|
||||
"""Add discount_ever_used column to telegram_users if missing."""
|
||||
if connection.dialect.name != "mysql":
|
||||
return
|
||||
|
||||
existing_columns = await _get_mysql_column_names(
|
||||
connection,
|
||||
table_name=TELEGRAM_USERS_TABLE_NAME,
|
||||
)
|
||||
quoted_table_name = _quote_mysql_identifier(TELEGRAM_USERS_TABLE_NAME)
|
||||
|
||||
if "discount_ever_used" not in existing_columns:
|
||||
logger.info(
|
||||
"Adding missing column discount_ever_used to %s",
|
||||
TELEGRAM_USERS_TABLE_NAME,
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE "
|
||||
f"{quoted_table_name} "
|
||||
"ADD COLUMN `discount_ever_used` BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def init_db(engine: AsyncEngine) -> None:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
await _normalize_support_ticket_columns(connection)
|
||||
await _normalize_referral_invites_columns(connection)
|
||||
await _normalize_telegram_users_columns(connection)
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import PaymentPlanSettings, Settings
|
||||
from app.config import PAYMENT_PRODUCT_PLAN_SPECS, PaymentPlanSettings, Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import BotSetting
|
||||
|
||||
@@ -34,11 +34,14 @@ class BotConfigSnapshot:
|
||||
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
|
||||
@@ -75,12 +78,15 @@ class BotConfigSnapshot:
|
||||
|
||||
@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
|
||||
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]:
|
||||
@@ -95,6 +101,13 @@ class BotConfigSnapshot:
|
||||
|
||||
@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()
|
||||
@@ -120,6 +133,80 @@ class BotConfigSnapshot:
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||
normalized = " ".join(raw_value.split()).strip()
|
||||
@@ -176,6 +263,7 @@ class StaticBotConfigService:
|
||||
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,
|
||||
@@ -184,6 +272,8 @@ class StaticBotConfigService:
|
||||
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,
|
||||
@@ -233,9 +323,24 @@ class BotConfigService(StaticBotConfigService):
|
||||
label="Тарифы и цены",
|
||||
section="pricing",
|
||||
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
|
||||
description="Каждая пара — это `дни:цена_в_рублях`.",
|
||||
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",
|
||||
@@ -332,6 +437,22 @@ class BotConfigService(StaticBotConfigService):
|
||||
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 внешней группы",
|
||||
@@ -429,6 +550,12 @@ class BotConfigService(StaticBotConfigService):
|
||||
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(
|
||||
@@ -447,6 +574,18 @@ class BotConfigService(StaticBotConfigService):
|
||||
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",
|
||||
@@ -533,11 +672,17 @@ class BotConfigService(StaticBotConfigService):
|
||||
"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",
|
||||
@@ -611,6 +756,20 @@ class BotConfigService(StaticBotConfigService):
|
||||
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)
|
||||
@@ -652,6 +811,12 @@ class BotConfigService(StaticBotConfigService):
|
||||
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
|
||||
|
||||
@@ -711,6 +876,75 @@ class BotConfigService(StaticBotConfigService):
|
||||
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
|
||||
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}"
|
||||
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] = []
|
||||
|
||||
@@ -31,9 +31,31 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppliedDiscount:
|
||||
code: str = ""
|
||||
source: str = ""
|
||||
discount_percent: int = 0
|
||||
plan_code: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
|
||||
def applies_to_plan(self, plan_code: str) -> bool:
|
||||
if not self.code:
|
||||
return False
|
||||
if self.plan_code is None:
|
||||
return True
|
||||
# "all_30" matches any product with 30 days (vpn_white_30, white_30, vpn_30)
|
||||
if self.plan_code.startswith("all_"):
|
||||
suffix = self.plan_code.removeprefix("all") # "_30"
|
||||
return plan_code.endswith(suffix)
|
||||
# Exact match or product-prefix match (e.g. promo plan_code="vpn_white" matches plan "vpn_white_30")
|
||||
return self.plan_code == plan_code or plan_code.startswith(f"{self.plan_code}_")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentPlan:
|
||||
code: str
|
||||
label: str
|
||||
title: str
|
||||
description: str
|
||||
duration_days: int
|
||||
@@ -41,6 +63,9 @@ class PaymentPlan:
|
||||
discount_percent: int
|
||||
original_amount_rub: int
|
||||
applied_referral_code: str = ""
|
||||
discount_code: str = ""
|
||||
discount_source: str = ""
|
||||
discount_scope_plan_code: str | None = None
|
||||
traffic_limit_bytes: int = 0
|
||||
traffic_limit_strategy: str = "NO_RESET"
|
||||
internal_squad_uuids: list[str] | None = None
|
||||
@@ -130,14 +155,14 @@ class PaymentService:
|
||||
|
||||
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
applied_discount = await self._get_applied_discount(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
return [
|
||||
self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
applied_discount=applied_discount,
|
||||
config=config,
|
||||
)
|
||||
for plan in config.payment_plans
|
||||
@@ -154,13 +179,13 @@ class PaymentService:
|
||||
plan_code: str,
|
||||
) -> CreatedPaymentOrder:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
applied_discount = await self._get_applied_discount(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
plan = self._get_plan_by_code(
|
||||
plan_code,
|
||||
applied_referral_code=applied_referral_code,
|
||||
applied_discount=applied_discount,
|
||||
config=config,
|
||||
)
|
||||
if not plan.is_ready:
|
||||
@@ -203,7 +228,7 @@ class PaymentService:
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
invoice_payload=invoice_payload,
|
||||
provision_username=provision_username,
|
||||
error_message=applied_referral_code or None,
|
||||
error_message=plan.discount_code or None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -293,10 +318,15 @@ class PaymentService:
|
||||
|
||||
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
|
||||
order = await self._claim_order_for_approval(order_uuid)
|
||||
config = await self._get_config()
|
||||
is_renewal = False
|
||||
|
||||
try:
|
||||
config = await self._get_config()
|
||||
plan = self._get_plan_by_code(
|
||||
order.plan_code,
|
||||
applied_discount=AppliedDiscount(),
|
||||
config=config,
|
||||
)
|
||||
remote_user: RemnawaveUser | None = None
|
||||
if order.remnawave_user_uuid:
|
||||
try:
|
||||
@@ -324,19 +354,39 @@ class PaymentService:
|
||||
remote_user = self._select_referral_bonus_target(remote_users)
|
||||
|
||||
if remote_user is None:
|
||||
remote_user = await self._create_remnawave_user(order, config=config)
|
||||
remote_user = await self._create_remnawave_user(
|
||||
order,
|
||||
plan=plan,
|
||||
config=config,
|
||||
)
|
||||
else:
|
||||
is_renewal = True
|
||||
remote_user = await self._extend_remnawave_user(
|
||||
order,
|
||||
remote_user,
|
||||
plan=plan,
|
||||
config=config,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await self._restore_review_after_failed_approval(order.order_uuid, str(exc))
|
||||
raise
|
||||
except RemnawaveApiError as exc:
|
||||
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
||||
raise
|
||||
|
||||
await self._save_fulfilled_order(order.order_uuid, remote_user)
|
||||
|
||||
# Burn the applied discount (promo or referral) so it cannot be reused
|
||||
try:
|
||||
await self._sync_service.consume_discount_for_user(order.telegram_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Could not consume discount for telegram_id=%s after order=%s",
|
||||
order.telegram_id,
|
||||
order.order_uuid,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
own_bonus = None
|
||||
try:
|
||||
own_bonus = await self.apply_pending_referral_bonuses(
|
||||
@@ -422,9 +472,14 @@ class PaymentService:
|
||||
"",
|
||||
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
|
||||
]
|
||||
if order.plan.applied_referral_code:
|
||||
if order.plan.discount_code:
|
||||
discount_label = (
|
||||
"Промокод"
|
||||
if order.plan.discount_source == "promo"
|
||||
else "Реферальный код"
|
||||
)
|
||||
lines.append(
|
||||
f"Реферальный код: <code>{order.plan.applied_referral_code}</code> "
|
||||
f"{discount_label}: <code>{order.plan.discount_code}</code> "
|
||||
f"(<b>-{order.plan.discount_percent}%</b>)"
|
||||
)
|
||||
if order.extends_existing_access:
|
||||
@@ -458,14 +513,14 @@ class PaymentService:
|
||||
self,
|
||||
plan_code: str,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
applied_discount: AppliedDiscount,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
for plan in config.payment_plans:
|
||||
if plan.code == plan_code:
|
||||
return self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
applied_discount=applied_discount,
|
||||
config=config,
|
||||
)
|
||||
|
||||
@@ -475,10 +530,15 @@ class PaymentService:
|
||||
self,
|
||||
plan_settings: PaymentPlanSettings,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
applied_discount: AppliedDiscount,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0
|
||||
resolved_discount = (
|
||||
applied_discount
|
||||
if applied_discount.applies_to_plan(plan_settings.code)
|
||||
else AppliedDiscount()
|
||||
)
|
||||
discount_percent = max(resolved_discount.discount_percent, 0)
|
||||
discounted_amount = self._apply_discount(
|
||||
amount_rub=plan_settings.amount_rub,
|
||||
discount_percent=discount_percent,
|
||||
@@ -486,36 +546,105 @@ class PaymentService:
|
||||
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
|
||||
return PaymentPlan(
|
||||
code=plan_settings.code,
|
||||
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
|
||||
description=f"Доступ к VPN на {plan_settings.days} дней",
|
||||
label=plan_settings.title or f"{plan_settings.days} дней",
|
||||
title=(
|
||||
f"{config.bot_brand_name} · {plan_settings.title}"
|
||||
if plan_settings.title
|
||||
else f"{config.bot_brand_name} на {plan_settings.days} дней"
|
||||
),
|
||||
description=plan_settings.description or f"Доступ к VPN на {plan_settings.days} дней",
|
||||
duration_days=plan_settings.days,
|
||||
amount_rub=discounted_amount,
|
||||
discount_percent=discount_percent,
|
||||
original_amount_rub=plan_settings.amount_rub,
|
||||
applied_referral_code=applied_referral_code,
|
||||
applied_referral_code=(
|
||||
resolved_discount.code if resolved_discount.source == "referral" else ""
|
||||
),
|
||||
discount_code=resolved_discount.code,
|
||||
discount_source=resolved_discount.source,
|
||||
discount_scope_plan_code=resolved_discount.plan_code,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
|
||||
internal_squad_uuids=config.payment_internal_squad_uuids,
|
||||
internal_squad_uuids=self._resolve_plan_internal_squad_uuids(
|
||||
plan_settings,
|
||||
config=config,
|
||||
),
|
||||
external_squad_uuid=config.payment_external_squad_uuid_normalized,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_plan_internal_squad_uuids(
|
||||
plan_settings: PaymentPlanSettings,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> list[str]:
|
||||
if not plan_settings.squad_groups:
|
||||
return config.payment_internal_squad_uuids
|
||||
|
||||
values: list[str] = []
|
||||
for group in plan_settings.squad_groups:
|
||||
if group == "vpn":
|
||||
group_values = config.payment_vpn_squad_uuids
|
||||
elif group == "white":
|
||||
group_values = config.payment_white_squad_uuids
|
||||
else:
|
||||
group_values = []
|
||||
|
||||
for squad_uuid in group_values:
|
||||
if squad_uuid not in values:
|
||||
values.append(squad_uuid)
|
||||
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
|
||||
if discount_percent <= 0:
|
||||
return amount_rub
|
||||
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
|
||||
|
||||
async def _get_applied_referral_code(
|
||||
async def get_active_discount_for_user(self, *, telegram_id: int) -> AppliedDiscount:
|
||||
config = await self._get_config()
|
||||
return await self._get_applied_discount(telegram_id, config=config)
|
||||
|
||||
async def _get_applied_discount(
|
||||
self,
|
||||
telegram_id: int,
|
||||
*,
|
||||
config: BotConfigSnapshot | None = None,
|
||||
) -> str:
|
||||
) -> AppliedDiscount:
|
||||
resolved_config = config or await self._get_config()
|
||||
promo_getter = getattr(self._sync_service, "get_active_promo_for_user", None)
|
||||
if callable(promo_getter):
|
||||
promo = await promo_getter(telegram_id)
|
||||
if promo is not None:
|
||||
return AppliedDiscount(
|
||||
code=str(promo.code).strip().upper(),
|
||||
source="promo",
|
||||
discount_percent=max(int(promo.discount_percent), 0),
|
||||
plan_code=promo.plan_code,
|
||||
expires_at=promo.expires_at,
|
||||
)
|
||||
|
||||
if not resolved_config.referral_enabled:
|
||||
return ""
|
||||
return AppliedDiscount()
|
||||
|
||||
summary = await self._sync_service.get_referral_summary(telegram_id)
|
||||
return summary.applied_referral_code.strip().upper()
|
||||
referral_code = summary.applied_referral_code.strip().upper()
|
||||
if not referral_code:
|
||||
return AppliedDiscount()
|
||||
|
||||
# Check if referral discount was already consumed by a previous payment
|
||||
discount_used_checker = getattr(
|
||||
self._sync_service, "is_referral_discount_used", None
|
||||
)
|
||||
if callable(discount_used_checker) and await discount_used_checker(telegram_id):
|
||||
return AppliedDiscount()
|
||||
|
||||
return AppliedDiscount(
|
||||
code=referral_code,
|
||||
source="referral",
|
||||
discount_percent=max(resolved_config.referral_discount_percent, 0),
|
||||
)
|
||||
|
||||
async def apply_pending_referral_bonuses(
|
||||
self,
|
||||
@@ -655,6 +784,7 @@ class PaymentService:
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
*,
|
||||
plan: PaymentPlan,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
|
||||
@@ -665,7 +795,7 @@ class PaymentService:
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
"activeInternalSquads": plan.internal_squad_uuids or [],
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
@@ -678,6 +808,7 @@ class PaymentService:
|
||||
order: PaymentOrder,
|
||||
remote_user: RemnawaveUser,
|
||||
*,
|
||||
plan: PaymentPlan,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
current_expire_at = remote_user.expire_at
|
||||
@@ -699,7 +830,7 @@ class PaymentService:
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
"activeInternalSquads": plan.internal_squad_uuids or [],
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import SupportTicket
|
||||
|
||||
OPEN_SUPPORT_TICKET_STATUS = "OPEN"
|
||||
ANSWERED_SUPPORT_TICKET_STATUS = "ANSWERED"
|
||||
CLOSED_SUPPORT_TICKET_STATUS = "CLOSED"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminSupportTicketListItem:
|
||||
id: int
|
||||
public_id: str
|
||||
telegram_id: int
|
||||
username: str | None
|
||||
display_name: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: datetime | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminSupportTicketsPage:
|
||||
items: list[AdminSupportTicketListItem] = field(default_factory=list)
|
||||
page: int = 1
|
||||
page_size: int = 8
|
||||
total_items: int = 0
|
||||
total_pages: int = 1
|
||||
|
||||
|
||||
class SupportTicketService:
|
||||
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
@@ -33,13 +63,17 @@ class SupportTicketService:
|
||||
support_chat_id=support_chat_id,
|
||||
support_thread_id=support_thread_id,
|
||||
support_message_id=support_message_id,
|
||||
status="OPEN",
|
||||
status=OPEN_SUPPORT_TICKET_STATUS,
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.commit()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
async def get_ticket(self, *, ticket_id: int) -> SupportTicket | None:
|
||||
async with self._session_factory() as session:
|
||||
return await session.get(SupportTicket, ticket_id)
|
||||
|
||||
async def get_ticket_by_support_message(
|
||||
self,
|
||||
*,
|
||||
@@ -54,19 +88,121 @@ class SupportTicketService:
|
||||
)
|
||||
)
|
||||
|
||||
async def mark_answered(self, *, ticket_id: int) -> None:
|
||||
async def mark_answered(self, *, ticket_id: int) -> SupportTicket | None:
|
||||
async with self._session_factory() as session:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
return
|
||||
return None
|
||||
|
||||
if ticket.status != CLOSED_SUPPORT_TICKET_STATUS:
|
||||
ticket.status = ANSWERED_SUPPORT_TICKET_STATUS
|
||||
|
||||
ticket.status = "ANSWERED"
|
||||
ticket.updated_at = utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
async def append_note(
|
||||
self,
|
||||
*,
|
||||
ticket_id: int,
|
||||
note_text: str,
|
||||
status: str | None = None,
|
||||
) -> SupportTicket | None:
|
||||
cleaned_note = note_text.strip()
|
||||
if not cleaned_note:
|
||||
return None
|
||||
|
||||
async with self._session_factory() as session:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
return None
|
||||
|
||||
if ticket.support_note:
|
||||
ticket.support_note = f"{ticket.support_note}\n\n{cleaned_note}"
|
||||
else:
|
||||
ticket.support_note = cleaned_note
|
||||
if status and ticket.status != CLOSED_SUPPORT_TICKET_STATUS:
|
||||
ticket.status = status
|
||||
ticket.updated_at = utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
async def close_ticket(
|
||||
self,
|
||||
*,
|
||||
ticket_id: int,
|
||||
closed_by_telegram_id: int | None,
|
||||
) -> SupportTicket | None:
|
||||
async with self._session_factory() as session:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
return None
|
||||
|
||||
ticket.status = CLOSED_SUPPORT_TICKET_STATUS
|
||||
ticket.closed_by_telegram_id = closed_by_telegram_id
|
||||
ticket.closed_at = utcnow()
|
||||
ticket.updated_at = utcnow()
|
||||
await session.commit()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
async def get_open_tickets_count(self) -> int:
|
||||
async with self._session_factory() as session:
|
||||
total = await session.scalar(
|
||||
select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN")
|
||||
select(func.count(SupportTicket.id)).where(
|
||||
SupportTicket.status != CLOSED_SUPPORT_TICKET_STATUS
|
||||
)
|
||||
)
|
||||
return int(total or 0)
|
||||
|
||||
async def get_admin_tickets_page(
|
||||
self,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int = 8,
|
||||
) -> AdminSupportTicketsPage:
|
||||
resolved_page_size = max(1, min(page_size, 20))
|
||||
|
||||
async with self._session_factory() as session:
|
||||
total_items = int(
|
||||
await session.scalar(select(func.count(SupportTicket.id))) or 0
|
||||
)
|
||||
total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
|
||||
resolved_page = min(max(page, 1), total_pages)
|
||||
|
||||
rows = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(SupportTicket)
|
||||
.order_by(
|
||||
SupportTicket.created_at.desc(),
|
||||
SupportTicket.id.desc(),
|
||||
)
|
||||
.offset((resolved_page - 1) * resolved_page_size)
|
||||
.limit(resolved_page_size)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
return AdminSupportTicketsPage(
|
||||
items=[
|
||||
AdminSupportTicketListItem(
|
||||
id=row.id,
|
||||
public_id=row.public_id,
|
||||
telegram_id=row.telegram_id,
|
||||
username=row.username,
|
||||
display_name=row.display_name,
|
||||
status=row.status,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
closed_at=row.closed_at,
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
page=resolved_page,
|
||||
page_size=resolved_page_size,
|
||||
total_items=total_items,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
@@ -5,15 +5,17 @@ import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import (
|
||||
InternalSquad,
|
||||
PromoCode,
|
||||
PromoCodeApplication,
|
||||
ReferralCode,
|
||||
ReferralInvite,
|
||||
InternalSquad,
|
||||
RemnawaveUser,
|
||||
RemnawaveUserInternalSquad,
|
||||
SubscriptionRequestLog,
|
||||
@@ -36,9 +38,33 @@ class ReferralSummary:
|
||||
total_invited: int = 0
|
||||
referral_code: str = ""
|
||||
applied_referral_code: str = ""
|
||||
applied_promo_code: str = ""
|
||||
applied_promo_discount_percent: int = 0
|
||||
applied_promo_plan_code: str | None = None
|
||||
applied_promo_expires_at: datetime | None = None
|
||||
recent_names: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PromoCodeView:
|
||||
code: str
|
||||
plan_code: str | None
|
||||
discount_percent: int
|
||||
expires_at: datetime | None
|
||||
is_active: bool
|
||||
created_by_telegram_id: int | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expires_at is not None and self.expires_at <= utcnow()
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return self.is_active and not self.is_expired and self.discount_percent > 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminStats:
|
||||
total_telegram_users: int = 0
|
||||
@@ -174,6 +200,124 @@ class SyncService:
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
await session.commit()
|
||||
|
||||
async def create_promo_code(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
plan_code: str | None,
|
||||
discount_percent: int,
|
||||
expires_at: datetime | None,
|
||||
created_by_telegram_id: int,
|
||||
) -> PromoCodeView:
|
||||
normalized_code = self.normalize_discount_code(code)
|
||||
if not normalized_code:
|
||||
raise ValueError("Введите слово для промокода.")
|
||||
if len(normalized_code) > 32:
|
||||
raise ValueError("Промокод должен быть не длиннее 32 символов.")
|
||||
if not all(ch.isalnum() or ch in "_-" for ch in normalized_code):
|
||||
raise ValueError("Промокод может содержать только буквы, цифры, _ и -.")
|
||||
|
||||
resolved_discount = int(discount_percent)
|
||||
if resolved_discount <= 0 or resolved_discount > 100:
|
||||
raise ValueError("Скидка должна быть от 1 до 100%.")
|
||||
|
||||
resolved_plan_code = (plan_code or "").strip() or None
|
||||
if resolved_plan_code and len(resolved_plan_code) > 64:
|
||||
raise ValueError("Код тарифа слишком длинный.")
|
||||
|
||||
resolved_expires_at = self._normalize_datetime(expires_at)
|
||||
if resolved_expires_at is not None and resolved_expires_at <= utcnow():
|
||||
raise ValueError("Срок действия промокода должен быть в будущем.")
|
||||
|
||||
async with self._session_factory() as session:
|
||||
duplicate = await session.scalar(
|
||||
select(PromoCode.id).where(PromoCode.code == normalized_code)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ValueError("Такой промокод уже существует.")
|
||||
|
||||
promo = PromoCode(
|
||||
code=normalized_code,
|
||||
plan_code=resolved_plan_code,
|
||||
discount_percent=resolved_discount,
|
||||
expires_at=resolved_expires_at,
|
||||
is_active=True,
|
||||
created_by_telegram_id=created_by_telegram_id,
|
||||
)
|
||||
session.add(promo)
|
||||
await session.commit()
|
||||
await session.refresh(promo)
|
||||
return self._promo_code_view(promo)
|
||||
|
||||
async def get_admin_promo_codes(self, *, limit: int = 20) -> list[PromoCodeView]:
|
||||
resolved_limit = max(1, min(limit, 100))
|
||||
async with self._session_factory() as session:
|
||||
rows = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(PromoCode)
|
||||
.order_by(PromoCode.created_at.desc(), PromoCode.id.desc())
|
||||
.limit(resolved_limit)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
return [self._promo_code_view(row) for row in rows]
|
||||
|
||||
async def get_active_promo_for_user(self, telegram_id: int) -> PromoCodeView | None:
|
||||
async with self._session_factory() as session:
|
||||
promo = await self._get_active_promo_for_user(session, telegram_id=telegram_id)
|
||||
return self._promo_code_view(promo) if promo is not None else None
|
||||
|
||||
async def consume_discount_for_user(self, telegram_id: int) -> None:
|
||||
"""Burn the applied promo / referral discount so it cannot be reused.
|
||||
|
||||
- For promo codes: deletes the row from ``promo_code_applications``.
|
||||
- For referral invites: sets ``discount_used = True`` so the referral
|
||||
bonus logic (inviter days) is preserved while the invited user
|
||||
no longer receives a price discount.
|
||||
- Sets ``discount_ever_used = True`` on ``telegram_users`` to permanently
|
||||
prevent any future discount code activation.
|
||||
"""
|
||||
async with self._session_factory() as session:
|
||||
# 1. Burn promo-code application
|
||||
await session.execute(
|
||||
delete(PromoCodeApplication).where(
|
||||
PromoCodeApplication.telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Mark referral invite discount as used
|
||||
await session.execute(
|
||||
update(ReferralInvite)
|
||||
.where(
|
||||
ReferralInvite.invited_telegram_id == telegram_id,
|
||||
ReferralInvite.discount_used == False, # noqa: E712
|
||||
)
|
||||
.values(discount_used=True)
|
||||
)
|
||||
|
||||
# 3. Permanently mark the user so no new codes can be activated
|
||||
await session.execute(
|
||||
update(TelegramUser)
|
||||
.where(
|
||||
TelegramUser.telegram_id == telegram_id,
|
||||
TelegramUser.discount_ever_used == False, # noqa: E712
|
||||
)
|
||||
.values(discount_ever_used=True)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
async def is_referral_discount_used(self, telegram_id: int) -> bool:
|
||||
"""Return True if the referral invite discount was already consumed."""
|
||||
async with self._session_factory() as session:
|
||||
discount_used = await session.scalar(
|
||||
select(ReferralInvite.discount_used).where(
|
||||
ReferralInvite.invited_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
return bool(discount_used)
|
||||
|
||||
async def apply_referral_code(
|
||||
self,
|
||||
*,
|
||||
@@ -193,8 +337,28 @@ class SyncService:
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
|
||||
# Block activation if the user has already used a discount
|
||||
if invited.discount_ever_used:
|
||||
raise ValueError(
|
||||
"Вы уже использовали промокод или реферальный код. "
|
||||
"Повторная активация невозможна."
|
||||
)
|
||||
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
|
||||
raw_code = self.normalize_discount_code(referral_code)
|
||||
promo = await self._find_promo_code(session, code=raw_code)
|
||||
if promo is not None:
|
||||
self._ensure_promo_can_be_applied(promo)
|
||||
await self._upsert_promo_application(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
promo_code_id=promo.id,
|
||||
)
|
||||
await session.commit()
|
||||
return promo.code
|
||||
|
||||
existing = await session.scalar(
|
||||
select(ReferralInvite).where(
|
||||
ReferralInvite.invited_telegram_id == telegram_id
|
||||
@@ -217,6 +381,11 @@ class SyncService:
|
||||
if not invite_created:
|
||||
raise ValueError("Не удалось применить реферальный код.")
|
||||
|
||||
await session.execute(
|
||||
delete(PromoCodeApplication).where(
|
||||
PromoCodeApplication.telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return referral_code.strip().upper()
|
||||
|
||||
@@ -247,11 +416,21 @@ class SyncService:
|
||||
)
|
||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||
)
|
||||
applied_promo = await self._get_active_promo_for_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
return ReferralSummary(
|
||||
total_invited=int(total_invited or 0),
|
||||
referral_code=referral_code,
|
||||
applied_referral_code=applied_referral_code or "",
|
||||
applied_promo_code=applied_promo.code if applied_promo is not None else "",
|
||||
applied_promo_discount_percent=(
|
||||
int(applied_promo.discount_percent) if applied_promo is not None else 0
|
||||
),
|
||||
applied_promo_plan_code=applied_promo.plan_code if applied_promo is not None else None,
|
||||
applied_promo_expires_at=applied_promo.expires_at if applied_promo is not None else None,
|
||||
recent_names=recent_names,
|
||||
)
|
||||
|
||||
@@ -623,6 +802,11 @@ class SyncService:
|
||||
remote_user: RemoteRemnawaveUser,
|
||||
owner: TelegramUser | None,
|
||||
) -> RemnawaveUser:
|
||||
owner_id = owner.id if owner is not None else None
|
||||
if owner_id is None and remote_user.telegram_id is not None:
|
||||
with session.no_autoflush:
|
||||
owner_id = await self._find_owner_id(session, remote_user.telegram_id)
|
||||
|
||||
record = await session.scalar(
|
||||
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
|
||||
)
|
||||
@@ -631,8 +815,6 @@ class SyncService:
|
||||
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
|
||||
session.add(record)
|
||||
|
||||
owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
|
||||
|
||||
record.owner_telegram_user_id = owner_id
|
||||
record.rw_id = remote_user.id
|
||||
record.short_uuid = remote_user.short_uuid
|
||||
@@ -781,6 +963,62 @@ class SyncService:
|
||||
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
|
||||
)
|
||||
|
||||
async def _find_promo_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
code: str,
|
||||
) -> PromoCode | None:
|
||||
raw_code = self.normalize_discount_code(code)
|
||||
if not raw_code:
|
||||
return None
|
||||
|
||||
return await session.scalar(
|
||||
select(PromoCode).where(PromoCode.code == raw_code)
|
||||
)
|
||||
|
||||
async def _get_active_promo_for_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
) -> PromoCode | None:
|
||||
promo = await session.scalar(
|
||||
select(PromoCode)
|
||||
.join(
|
||||
PromoCodeApplication,
|
||||
PromoCodeApplication.promo_code_id == PromoCode.id,
|
||||
)
|
||||
.where(PromoCodeApplication.telegram_id == telegram_id)
|
||||
)
|
||||
if promo is None or not self._promo_is_valid(promo):
|
||||
return None
|
||||
return promo
|
||||
|
||||
async def _upsert_promo_application(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
promo_code_id: int,
|
||||
) -> None:
|
||||
application = await session.scalar(
|
||||
select(PromoCodeApplication).where(
|
||||
PromoCodeApplication.telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
if application is None:
|
||||
session.add(
|
||||
PromoCodeApplication(
|
||||
telegram_id=telegram_id,
|
||||
promo_code_id=promo_code_id,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
application.promo_code_id = promo_code_id
|
||||
application.updated_at = utcnow()
|
||||
|
||||
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
|
||||
if telegram_id is None:
|
||||
return None
|
||||
@@ -801,6 +1039,40 @@ class SyncService:
|
||||
def _maybe_str(value: object | None) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def normalize_discount_code(value: str | None) -> str:
|
||||
return "".join((value or "").strip().upper().split())
|
||||
|
||||
@classmethod
|
||||
def _ensure_promo_can_be_applied(cls, promo: PromoCode) -> None:
|
||||
if not promo.is_active:
|
||||
raise ValueError("Промокод отключён.")
|
||||
if cls._promo_is_expired(promo):
|
||||
raise ValueError("Срок действия промокода истёк.")
|
||||
if promo.discount_percent <= 0:
|
||||
raise ValueError("Промокод не даёт скидку.")
|
||||
|
||||
@classmethod
|
||||
def _promo_is_valid(cls, promo: PromoCode) -> bool:
|
||||
return bool(promo.is_active) and not cls._promo_is_expired(promo) and promo.discount_percent > 0
|
||||
|
||||
@staticmethod
|
||||
def _promo_is_expired(promo: PromoCode) -> bool:
|
||||
return promo.expires_at is not None and promo.expires_at <= utcnow()
|
||||
|
||||
@staticmethod
|
||||
def _promo_code_view(promo: PromoCode) -> PromoCodeView:
|
||||
return PromoCodeView(
|
||||
code=promo.code,
|
||||
plan_code=promo.plan_code,
|
||||
discount_percent=int(promo.discount_percent),
|
||||
expires_at=promo.expires_at,
|
||||
is_active=bool(promo.is_active),
|
||||
created_by_telegram_id=promo.created_by_telegram_id,
|
||||
created_at=promo.created_at,
|
||||
updated_at=promo.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
|
||||
view_map: dict[int, CachedUserView] = {}
|
||||
|
||||
@@ -132,6 +132,14 @@ def format_issued_access(
|
||||
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
|
||||
"3. Управлять доступом и продлевать его можно в панели бота.",
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
"💡 <b>О белых списках:</b> подписки с белыми списками предназначены "
|
||||
"для использования в условиях полной блокировки. Если полной блокировки нет — "
|
||||
"рекомендуем пользоваться VPN"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user