Add subscription link field to user model

- Add subscription_link column to users table (nullable, unique)
- Add SUBSCRIPTION_URL to config settings
- Update user creation to include subscription link from Remnawave
- Add support subscription ticket creation with formatted message
- Add "update link" button to main menu
- Refactor support subscription formatting to include user details
This commit is contained in:
2026-04-09 21:09:29 +07:00
parent 2bb12f2ff8
commit 60de871e0c
13 changed files with 408 additions and 114 deletions

View File

@@ -0,0 +1,34 @@
"""user -> +subscription_link optional text.
Revision ID: 5985b2df0830
Revises: 756210f564a5
Create Date: 2026-04-09 20:38:15.157762
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "5985b2df0830"
down_revision: Union[str, Sequence[str], None] = "756210f564a5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("subscription_link", sa.TEXT(), nullable=True))
op.create_unique_constraint(None, "users", ["subscription_link"])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "users", type_="unique")
op.drop_column("users", "subscription_link")
# ### end Alembic commands ###

View File

@@ -6,12 +6,13 @@ from dotenv import load_dotenv
load_dotenv(override=True)
class settings(BaseSettings):
class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN")
postgres_url: str = Field(alias="POSTGRES_URL")
proxy: Optional[str] = Field(None, alias="PROXY")
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
remnawave_url: str = Field(alias="REMNAWAVE_URL")
subscription_url: str = Field(alias="SUBSCRIPTION_URL")
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
min_devices: int = Field(3, alias="MIN_DEVICES")
max_devices: int = Field(12, alias="MAX_DEVICES")
@@ -20,4 +21,4 @@ class settings(BaseSettings):
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
settings = settings() # type: ignore
settings = Settings() # type: ignore

View File

@@ -1,4 +1,4 @@
from sqlalchemy import BIGINT
from sqlalchemy import BIGINT, TEXT
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
@@ -11,3 +11,4 @@ class User(Base):
BIGINT, nullable=False, unique=True, primary_key=True
)
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True)

View File

@@ -1,7 +1,9 @@
import logging
from typing import Optional
from aiogram import Router, F
from aiogram.types import CallbackQuery
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import (
devices_selector,
@@ -12,22 +14,26 @@ from keyboards.client import (
from misc.utils import (
calculate_price,
convert_int_to_duration,
format_support_subscription,
)
from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan
from services.user_service import UserServiceException
from states.buy import SubscriptionStorage
from texts import (
CALLBACK_FALLBACK,
CHECKOUT_PAYMENT_CHOICE,
CONTEXT_REINITIATED,
GENERAL_PROCESSING,
NOTHING_PLACEHOLDER,
STANDARD_FALLBACK,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
SUPPORT_MSG_SENT,
)
from config import settings
router = Router()
logger = logging.getLogger(__name__)
@router.callback_query(F.data == "buy")
@@ -160,6 +166,36 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
return
await cb.message.edit_text(
CHECKOUT_PAYMENT_CHOICE.format(code=format_support_subscription(ctx)),
CHECKOUT_PAYMENT_CHOICE,
reply_markup=payment_gateways,
) # FIXME: temp solution that's incredibly stupid
await state.set_state(SubscriptionStorage.checkout)
await state.set_data({"ctx": ctx})
@router.callback_query(F.data == "checkout")
async def checkout_support(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
if not ctx:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
await cb.message.edit_text(GENERAL_PROCESSING)
try:
await deps.user_service.create_subscription_ticket(
session, cb.from_user, ctx, cb.bot
)
await cb.message.edit_text(SUPPORT_MSG_SENT)
except UserServiceException:
await cb.message.edit_text(STANDARD_FALLBACK)
raise
except Exception:
await cb.message.edit_text(STANDARD_FALLBACK)
logger.exception("Exception occured while trying to forward a msg to support")
raise

View File

@@ -4,9 +4,10 @@ from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from misc.utils import format_subscription_link
from schemas.di import DependenciesDTO
from keyboards.client import main_menu
from texts import build_main_menu_text
from keyboards.client import main_menu, return_to_menu
from texts import GENERAL_PROCESSING, build_main_menu_text, FAQ_TEXT
router = Router()
@@ -27,9 +28,14 @@ async def fetch_referal(
return
referal = data[0]
await deps.user_service.add_user(session, user_id=msg.from_user.id, referal=referal)
user = await deps.user_service.add_user(
session, user_id=msg.from_user.id, referal=referal, rw_sdk=deps.rw_sdk
)
await msg.reply(build_main_menu_text(), reply_markup=main_menu)
await msg.reply(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.message(Command(commands=["start", "cancel"]))
@@ -42,13 +48,48 @@ async def standard_start(
):
await state.clear()
await deps.user_service.add_user(session, user_id=msg.from_user.id)
user = await deps.user_service.add_user(
session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk
)
await msg.reply(build_main_menu_text(), reply_markup=main_menu)
await msg.reply(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.callback_query(F.data == "menu:main")
async def main_menu_cb(cb: CallbackQuery, state: FSMContext):
async def main_menu_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await cb.message.edit_text(build_main_menu_text(), reply_markup=main_menu)
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.callback_query(F.data == "update_link")
async def update_link(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING)
user = await deps.user_service.update_user_link(
session, cb.from_user.id, deps.rw_sdk
)
await cb.answer("")
await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.callback_query(F.data == "faq")
async def faq(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(FAQ_TEXT, reply_markup=return_to_menu)

View File

@@ -15,6 +15,7 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
InlineKeyboardButton(text="Наш Канал", url="https://goo.gle"),
InlineKeyboardButton(text="Тех. Поддержка", callback_data="support"),
],
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")],
]
).as_markup()
@@ -90,7 +91,7 @@ def duration_selector(
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="✍️ Поддержка", callback_data="support")],
[InlineKeyboardButton(text="✍️ Перейти в поддержку", callback_data="checkout")],
[_return_to_menu_btn],
]
).as_markup()

View File

@@ -171,16 +171,33 @@ def calculate_price(subscription: SubscriptionPlan):
) * convert_duration_to_int(subscription.duration)
def format_support_subscription(subscription: SubscriptionPlan) -> str:
def format_subscription_link(link: Optional[str] = None):
return f"<code>{link or '...'}</code>"
def format_support_subscription(
subscription: SubscriptionPlan,
full_name: str,
user_id: int,
username: Optional[str] = None,
) -> str:
username = format_username(username)
whitelist_description = "ВЫКЛ"
if subscription.devices >= settings.whitelist_device_threshold:
whitelist_description = "ВКЛ (Бесплатно)"
elif subscription.whitelists:
whitelist_description = "ВКЛ (Платно)"
return (
f"Подписка на {convert_duration_to_int(subscription.duration)} месяцев.\n"
f"Количество устройств: {subscription.devices}\n"
f"Белые списки: {whitelist_description}\n\n"
f"Итого: {calculate_price(subscription)}"
return texts.SUPPORT_SUBSCRIPTION.format(
full_name=full_name,
telegram_id=user_id,
username_display=username,
duration=convert_duration_to_int(subscription.duration),
devices=subscription.devices,
whitelist_description=whitelist_description,
price=calculate_price(subscription),
)
def get_subscription_link(short_uuid: str):
return settings.subscription_url + "/" + short_uuid

View File

@@ -16,7 +16,11 @@ class UserRepository:
return result.scalar_one_or_none()
async def create_user(self, session: AsyncSession, user_data: NewUserDTO) -> User:
user = User(id=user_data.id, referal_id=user_data.referal)
user = User(
id=user_data.id,
referal_id=user_data.referal,
subscription_link=user_data.link,
)
session.add(user)
await session.commit()
@@ -32,3 +36,14 @@ class UserRepository:
await session.commit()
return user
async def update_user_link(
self, session: AsyncSession, user_id: int, link: str
) -> Optional[User]:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None
user.subscription_link = link
await session.commit()
return user

View File

@@ -5,7 +5,8 @@ from typing import Optional
@dataclass
class NewUserDTO:
id: int
referal: int
referal: Optional[int] = None
link: Optional[str] = None
@dataclass

View File

@@ -26,6 +26,7 @@ class RWUserInfo:
hwid_device_limit: Optional[int] # None = unlimited
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
description: Optional[str]
short_uuid: str
# ─────────────────────────────────────────────────────────────────
@@ -61,6 +62,7 @@ def _parse_user(user_dto) -> RWUserInfo:
hwid_device_limit=user_dto.hwid_device_limit,
active_squads=active_squads,
description=user_dto.description,
short_uuid=user_dto.short_uuid,
)

View File

@@ -1,11 +1,14 @@
from typing import Optional
from aiogram import Bot
from aiogram.types import Message, ReactionTypeEmoji
from aiogram.types import User as TelegramUser
from sqlalchemy.ext.asyncio import AsyncSession
from remnawave import RemnawaveSDK
from config import settings
from db.models.tickets import TicketStatus
from db.models.users import User
from schemas.subscriptions import SubscriptionPlan
from schemas.tickets import NewTicketDTO
from schemas.users import NewUserDTO, TicketCreator
from repositories import UserRepository
@@ -84,16 +87,76 @@ class UserService:
)
async def add_user(
self, session: AsyncSession, user_id: int, referal: Optional[int] = None
self,
session: AsyncSession,
user_id: int,
rw_sdk: RemnawaveSDK,
referal: Optional[int] = None,
) -> User:
user = await self.user_repository.get_user_by_id(session, user_id)
if user:
return user
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
link = utils.get_subscription_link(rw_user.short_uuid)
referal = int(referal) if str(referal).isdigit() else None
model = NewUserDTO(id=user_id, referal=referal)
model = NewUserDTO(id=user_id, referal=referal, link=link)
return await self.user_repository.create_user(session, model)
async def create_subscription_ticket(
self,
session: AsyncSession,
user: TelegramUser,
subscription: SubscriptionPlan,
bot: Bot,
):
ticket = await self.ticket_repository.get_available_ticket_by_user_id(
session, user.id
)
if ticket:
try:
await bot.send_message(
chat_id=settings.admin_group_id,
message_thread_id=ticket.thread_id,
text=utils.format_support_subscription(
subscription, user.full_name, user.id, user.username
),
)
return
except Exception:
await self.ticket_repository.update_ticket_status(
session, ticket, TicketStatus.CLOSED
)
raise
creator = TicketCreator(id=user.id, username=user.username)
ticket_dto = NewTicketDTO(
creator_id=user.id,
)
ticket = await self.ticket_repository.create_ticket(
session, ticket_data=ticket_dto
)
thread = await bot.create_forum_topic(
settings.admin_group_id,
self.format_ticket_name(
creator, ticket_id=ticket.id, status=TicketStatus.PENDING
),
)
ticket = await self.ticket_repository.update_ticket_thread_id(
session, ticket, thread.message_thread_id
)
await bot.send_message(
chat_id=settings.admin_group_id,
message_thread_id=ticket.thread_id,
text=utils.format_support_subscription(
subscription, user.full_name, user.id, user.username
),
)
async def support_forward_message(
self, session: AsyncSession, msg: Message, rw: RemnawaveSDK
):
@@ -195,3 +258,14 @@ class UserService:
await msg.bot.close_forum_topic(
settings.admin_group_id, message_thread_id=thread_id
)
async def update_user_link(
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
) -> Optional[User]:
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
link = utils.get_subscription_link(rw_user.short_uuid)
user = await self.user_repository.update_user_link(session, user_id, link)
return user

View File

@@ -4,3 +4,4 @@ from aiogram.fsm.state import StatesGroup, State
class SubscriptionStorage(StatesGroup):
devices = State()
duration = State()
checkout = State()

248
texts.py
View File

@@ -2,6 +2,10 @@ from misc.utils import fetch_quote
from db.models.tickets import TicketStatus
# ============================================================
# PREMIUM EMOJI
# ============================================================
class PremiumEmoji:
def __init__(self, emoji_id: int, emoji: str):
self.emoji_id = emoji_id
@@ -11,9 +15,7 @@ class PremiumEmoji:
return f'<tg-emoji emoji-id="{self.emoji_id}">{self.emoji}</tg-emoji>'
## EMOJIS
# — Иконки бота
MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️")
MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️")
MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
@@ -21,125 +23,193 @@ MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
## MAPPING
# ============================================================
# МАППИНГИ
# ============================================================
TICKET_STATUS_EMOJIS = {
TicketStatus.CLOSED.value: "🔴",
TicketStatus.OPEN.value: "🟢",
TicketStatus.PENDING.value: "🟡",
}
## TEXTS
MAIN_MENU = (
str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_COINS} Личный кабинет</i>"
)
STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
)
TEMP_SUPPORT_FORM = (
"обратитесь в поддержку, прикрепив текст ниже:\n\n" "<code>\n" "{code}\n" "</code>"
)
CHECKOUT_PAYMENT_CHOICE = (
"в поддержку чирканите епта там всё обьяснят\n" + TEMP_SUPPORT_FORM
)
# ============================================================
# СТАТУСЫ ПОДПИСКИ
# ============================================================
CALLBACK_FALLBACK = "❌ Что-то пошло не так"
CONTEXT_REINITIATED = "🔴 Контекст данного взаимодействия сброшен. Данные могут отличаться от указанных вами."
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
SUBSCRIPTION_DEVICE_SELECTOR = (
"выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
)
WHITELISTS_ALREADY_ON = "уже включены!"
SUBSCRIPTION_DURATION_SELECTOR = (
"тут короче сколько время. а ещё цена смотри ОПА {price}"
)
SUPPORT_INIT = f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n<i>❌ Для отмены — /cancel</i>"
SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время."
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
ADMIN_TICKET_WITH_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"📋 <b>ПОДПИСКА REMNAWAVE</b>\n\n"
"{status_icon} <b>Статус:</b> {status}\n"
"📅 <b>Истекает:</b> {expire_date}\n"
"⏳ <b>Осталось:</b> {days_left}\n"
"📊 <b>Трафик:</b> {used_traffic}\n"
"📱 <b>HWID лимит:</b> {hwid_limit}\n"
"🎯 <b>Internal Squads:</b> {squads}\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"🔑 <b>UUID в Remnawave:</b> <code>{remnawave_uuid}</code>"
)
ADMIN_TICKET_WITHOUT_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"⚠️ <b>Аккаунт в Remnawave не найден</b>"
)
ADMIN_STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, обратитесь к логам для подробностей."
)
## FUNCTIONS
def build_main_menu_text() -> str:
return MAIN_MENU.format(quote=fetch_quote())
NO_USERNAME = "нет юзернейма"
# — Иконки статусов
STATUS_ICON_ACTIVE = "🟢"
STATUS_ICON_DISABLED = "🔴"
STATUS_ICON_EXPIRED = "🟡"
STATUS_ICON_LIMITED = "🟠"
STATUS_ICON_UNKNOWN = ""
# Текст статуса подписки
# Тексты статусов
STATUS_ACTIVE = "Активна"
STATUS_DISABLED = "Отключена"
STATUS_EXPIRED = "Истекла"
STATUS_LIMITED = "Превышен лимит трафика"
STATUS_UNKNOWN = "Неизвестен"
# Шаблон для остатка дней: {days} дн.
# ============================================================
# ФОРМАТИРОВАНИЕ ДАННЫХ
# ============================================================
# — Дни до истечения подписки
DAYS_LEFT_POSITIVE = "{days} дн."
# Когда подписка уже истекла
DAYS_LEFT_EXPIRED = "истекла {days} дн. назад"
# Когда подписка истекает сегодня
DAYS_LEFT_TODAY = "истекает сегодня"
# Когда нет username в Telegram
# — Username
NO_USERNAME = "нет username"
# Формат отображения username (с @)
USERNAME_DISPLAY = "@{username}"
# Когда у пользователя нет Internal Squads
# Internal Squads
NO_SQUADS = "нет"
# Когда лимит HWID не установлен (0 или None)
# — HWID лимит
UNLIMITED_HWID = "без ограничений"
# Когда лимит трафика не установлен
# — Трафик
NO_TRAFFIC_LIMIT = "без ограничений"
# Шаблон для отображения трафика с лимитом: {used} / {limit}
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
# Шаблон для отображения трафика без лимита: {used}
TRAFFIC_NO_LIMIT = "{used}"
# — Прочее
NOTHING_PLACEHOLDER = "🍃"
# ============================================================
# ПОЛЬЗОВАТЕЛЬСКИЙ ИНТЕРФЕЙС
# ============================================================
MAIN_MENU = (
str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_COINS} Личный кабинет</i>\n"
+ f"<b>{MALENIA_LINK} Ваша ссылка на подписку: {{link}}</b>"
)
FAQ_TEXT = "faq"
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
)
CALLBACK_FALLBACK = "❌ Что-то пошло не так"
CONTEXT_REINITIATED = (
"🔴 Контекст данного взаимодействия сброшен. "
"Данные могут отличаться от указанных вами."
)
# ============================================================
# ОФОРМЛЕНИЕ ПОДПИСКИ
# ============================================================
SUBSCRIPTION_DEVICE_SELECTOR = (
"выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
)
SUBSCRIPTION_DURATION_SELECTOR = (
"тут короче сколько время. а ещё цена смотри ОПА {price}"
)
WHITELISTS_ALREADY_ON = "уже включены!"
TEMP_SUPPORT_FORM = "обратитесь в поддержку, прикрепив текст ниже:\n\n"
CHECKOUT_PAYMENT_CHOICE = (
"в поддержку чирканите епта там всё обьяснят\n"
+ TEMP_SUPPORT_FORM
)
# ============================================================
# ПОДДЕРЖКА
# ============================================================
SUPPORT_INIT = (
f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n"
"— всё сюда, желательно со скриншотами. "
"Чем детальнее описание, тем быстрее ответ.\n\n"
"<i>❌ Для отмены — /cancel</i>"
)
SUPPORT_MSG_SENT = (
"⏳ Сообщение отправлено. Для выхода — /cancel, "
"вы можете вернуться сюда из главного меню в любое время."
)
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
# ============================================================
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
# ============================================================
# Используется в SUPPORT_SUBSCRIPTION и ADMIN_TICKET_WITH_RW
_SUPPORT_USER_DESCRIPTION = (
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
)
_DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
# ============================================================
# ШАБЛОНЫ — ЗАЯВКИ И ТИКЕТЫ
# ============================================================
SUPPORT_SUBSCRIPTION = (
"<b>🎫 ЗАЯВКА НА ПОКУПКУ:</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n" + _DIVIDER
+ "🗓️ Подписка на <b>{duration}</b> месяцев.\n"
+ "📱 Количество устройств: <b>{devices}</b>\n"
+ "🏳️ Белые списки: <b>{whitelist_description}</b>\n\n"
+ "💰 Итого: <b>{price}₽</b>"
)
ADMIN_TICKET_WITH_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n" + _DIVIDER
+ "📋 <b>ПОДПИСКА REMNAWAVE</b>\n\n"
+ "{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Истекает:</b> {expire_date}\n"
+ "⏳ <b>Осталось:</b> {days_left}\n"
+ "📊 <b>Трафик:</b> {used_traffic}\n"
+ "📱 <b>HWID лимит:</b> {hwid_limit}\n"
+ "🎯 <b>Internal Squads:</b> {squads}\n\n"
+ _DIVIDER
+ "🔑 <b>UUID в Remnawave:</b> <code>{remnawave_uuid}</code>"
)
ADMIN_TICKET_WITHOUT_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
+ _DIVIDER
+ "⚠️ <b>Аккаунт в Remnawave не найден</b>"
)
ADMIN_STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, обратитесь к логам для подробностей."
)
# ============================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================
def build_main_menu_text(link: str) -> str:
return MAIN_MENU.format(quote=fetch_quote(), link=link)