feat: enhance subscription management with discount calculations and update support messages

This commit is contained in:
2026-04-22 17:41:44 +07:00
parent 97c187bafb
commit e3e204d7af
7 changed files with 95 additions and 328 deletions

View File

@@ -1,9 +1,9 @@
import logging
import math
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.client import (
@@ -14,22 +14,20 @@ from keyboards.client import (
)
from misc.utils import (
calculate_price,
convert_duration_to_int,
convert_int_to_duration,
get_discount,
)
from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan
from services.user_service import UserServiceError
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,
)
router = Router()
@@ -103,8 +101,12 @@ async def select_duration_init(cb: CallbackQuery, state: FSMContext):
return
try:
discount = get_discount(convert_duration_to_int(ctx.duration))
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
SUBSCRIPTION_DURATION_SELECTOR.format(
price=math.ceil(calculate_price(ctx) * (1 - discount / 100)),
discount=math.floor(discount),
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
)
except Exception as exc:
@@ -132,8 +134,12 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
duration = int(cb_data[1]) if cb_data[1].isdigit() else 1
ctx.duration = convert_int_to_duration(duration)
try:
discount = get_discount(convert_duration_to_int(ctx.duration))
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
SUBSCRIPTION_DURATION_SELECTOR.format(
price=math.ceil(calculate_price(ctx) * (1 - discount / 100)),
discount=math.floor(discount),
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
)
except Exception as exc:
@@ -164,28 +170,3 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
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: SubscriptionPlan | None = 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 UserServiceError:
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.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import main_menu, return_to_menu
from keyboards.client import close_kb, main_menu, return_to_menu
from misc.utils import build_main_menu_text, format_subscription_link
from schemas.di import DependenciesDTO
from services.rw import get_user_by_telegram_id
from texts import FAQ_TEXT, GENERAL_PROCESSING
router = Router()
@@ -32,7 +33,7 @@ async def fetch_referal(
session, user_id=msg.from_user.id, referal=referal, rw_sdk=deps.rw_sdk
)
await msg.reply(
await msg.answer(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@@ -49,7 +50,7 @@ async def standard_start(
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
await msg.reply(
await msg.answer(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@@ -88,3 +89,19 @@ async def faq(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(FAQ_TEXT, reply_markup=return_to_menu)
@router.callback_query(F.data == "sub_info")
async def sub_info(
cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO, session: AsyncSession
):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
await cb.message.edit_text(
deps.user_service.format_subscription_message(rw_user, link=user.subscription_link),
reply_markup=return_to_menu,
)

View File

@@ -4,14 +4,9 @@ from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.client import return_to_menu
from schemas.di import DependenciesDTO
from services.user_service import UserServiceError
from states.support import SupportStorage
from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT
from texts import SUPPORT_INIT
router = Router()
logger = logging.getLogger(__name__)
@@ -22,7 +17,6 @@ async def support_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(text=SUPPORT_INIT, reply_markup=return_to_menu)
await state.set_state(SupportStorage.prompt)
@router.message(Command("support"))
@@ -31,60 +25,3 @@ async def support_init_cmd(msg: Message, state: FSMContext):
await msg.delete()
await msg.answer(text=SUPPORT_INIT, reply_markup=return_to_menu)
await state.set_state(SupportStorage.prompt)
@router.message(SupportStorage.prompt)
async def received_message(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
notification_msg = await msg.reply(GENERAL_PROCESSING)
try:
await deps.user_service.support_forward_message(session, msg, deps.rw_sdk)
except UserServiceError:
await msg.reply(STANDARD_FALLBACK)
raise
except Exception:
await msg.reply(STANDARD_FALLBACK)
logger.exception("Exception occured while trying to forward a msg to support")
raise
await notification_msg.edit_text(SUPPORT_MSG_SENT, reply_markup=return_to_menu)
await state.set_state(SupportStorage.prompt)
@router.message(
F.chat.id == settings.admin_group_id,
F.message_thread_id.is_not(None),
F.from_user.is_bot == False, # noqa: E712
~Command("close"),
~Command("refpay"),
)
async def handle_admin_message(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
print(str(msg))
await deps.user_service.support_answer(session, msg)
@router.message(F.chat.id == settings.admin_group_id, Command("close"))
async def close_ticket(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await deps.user_service.close_ticket(session, msg)
@router.callback_query(F.message.chat.id == settings.admin_group_id, F.data == "close")
async def close_ticket_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await deps.user_service.close_ticket(session, cb.message)

View File

@@ -7,13 +7,14 @@ from schemas.subscriptions import SubscriptionDuration
main_menu = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
[
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"),
InlineKeyboardButton(text="FAQ", callback_data="faq"),
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
],
[
InlineKeyboardButton(text="Наш Канал", url="https://goo.gle"),
InlineKeyboardButton(text="Тех. Поддержка", callback_data="support"),
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"),
],
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")],
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],

View File

@@ -1,3 +1,4 @@
import math
import urllib.parse
from datetime import UTC, datetime, timedelta
from random import choice
@@ -179,8 +180,15 @@ def calculate_price(subscription: SubscriptionPlan):
) * convert_duration_to_int(subscription.duration)
def get_discount(months) -> float:
if months <= 0:
return 0
calculated = 5 * (math.log2(months / 3) + 1)
return max(0, calculated)
def format_subscription_link(link: str | None = None):
return f"<code>{link or '...'}</code>"
return f"<code>{link or texts.NOTHING_PLACEHOLDER}</code>"
def format_share_deep_link(link: str):

View File

@@ -1,26 +1,20 @@
import logging
from aiogram import Bot
from aiogram.types import Message, ReactionTypeEmoji, User as TelegramUser
from aiogram.types import Message
from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from db.models.tickets import TicketStatus
from db.models.users import User
from keyboards.admins import ticket_actions
from misc import utils
from repositories import UserRepository
from repositories.tickets import TicketRepository
from schemas.subscriptions import SubscriptionPlan
from schemas.tickets import NewTicketDTO
from schemas.users import NewUserDTO, TicketCreator
from services.rw import RWUserInfo, get_user_by_telegram_id
from texts import (
ADMIN_STANDARD_FALLBACK,
ADMIN_TICKET_WITH_RW,
ADMIN_TICKET_WITHOUT_RW,
SUPPORT_SIGNATURE,
NO_SUBSCRIPTION,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_INFORMATION,
TICKET_STATUS_EMOJIS,
)
@@ -51,49 +45,29 @@ class UserService:
)
@staticmethod
async def format_admin_support_message(
ticket_number: int,
user: TelegramUser,
def format_subscription_message(
rw_user: RWUserInfo | None,
balance: int,
referal_id: int | None = None,
link: str | None = None,
):
username_display = utils.format_username(user.username)
referal_display = utils.format_referal_id(referal_id)
if rw_user is None:
# Пользователь не найден в Remnawave
return ADMIN_TICKET_WITHOUT_RW.format(
ticket_number=ticket_number,
full_name=user.full_name,
username_display=username_display,
telegram_id=user.id,
referal_id=referal_display,
balance=balance,
)
return NO_SUBSCRIPTION
status_icon, status_text = utils.format_status(rw_user.status)
expire_date = rw_user.expire_at.strftime("%d.%m.%Y")
days_left = utils.format_days_left(rw_user.expire_at)
used_traffic = utils.format_traffic(rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes)
hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit)
squads = utils.format_squads(rw_user.active_squads)
link = link or NOTHING_PLACEHOLDER
return ADMIN_TICKET_WITH_RW.format(
ticket_number=ticket_number,
full_name=user.full_name,
username_display=username_display,
telegram_id=user.id,
balance=balance,
return SUBSCRIPTION_INFORMATION.format(
status_icon=status_icon,
status=status_text,
expire_date=expire_date,
days_left=days_left,
used_traffic=used_traffic,
hwid_limit=hwid_limit,
squads=squads,
remnawave_uuid=rw_user.uuid,
referal_id=referal_display,
link=link,
)
async def add_user(
@@ -114,154 +88,6 @@ class UserService:
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)
db_user = await self.user_repository.get_user_by_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=subscription,
full_name=user.full_name,
user_id=user.id,
username=user.username,
balance=db_user.balance,
referal_id=db_user.referal_id,
),
reply_markup=ticket_actions,
)
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
)
db_user = await self.user_repository.get_user_by_id(session, ticket.creator_id)
await bot.send_message(
chat_id=settings.admin_group_id,
message_thread_id=ticket.thread_id,
text=utils.format_support_subscription(
subscription=subscription,
full_name=user.full_name,
user_id=user.id,
username=user.username,
balance=db_user.balance,
referal_id=db_user.referal_id,
),
reply_markup=ticket_actions,
)
async def support_forward_message(self, session: AsyncSession, msg: Message, rw: RemnawaveSDK):
user = await self.user_repository.get_user_by_id(session, msg.from_user.id)
if not user:
raise UserServiceError("User %d is not found.", msg.from_user.id)
ticket = await self.ticket_repository.get_available_ticket_by_user_id(session, user.id)
if ticket:
try:
await msg.copy_to(
chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id
)
return
except Exception:
await self.ticket_repository.update_ticket_status(
session, ticket, TicketStatus.CLOSED
)
raise
rw_user = await get_user_by_telegram_id(rw, user.id)
creator = TicketCreator(id=user.id, username=msg.from_user.username)
ticket_dto = NewTicketDTO(
creator_id=user.id,
)
ticket = await self.ticket_repository.create_ticket(session, ticket_data=ticket_dto)
thread = await msg.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 msg.bot.send_message(
settings.admin_group_id,
message_thread_id=thread.message_thread_id,
text=await self.format_admin_support_message(
ticket_number=ticket.id,
user=msg.from_user,
rw_user=rw_user,
balance=user.balance,
referal_id=user.referal_id,
),
reply_markup=ticket_actions,
)
await msg.copy_to(settings.admin_group_id, message_thread_id=thread.message_thread_id)
async def support_answer(self, session: AsyncSession, msg: Message):
thread_id = msg.message_thread_id
ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id)
if ticket.status == TicketStatus.PENDING:
await self.ticket_repository.update_ticket_status(session, ticket, TicketStatus.OPEN)
await msg.bot.edit_forum_topic(
settings.admin_group_id,
ticket.thread_id,
name=self.format_ticket_name(TicketCreator(ticket.creator_id), ticket_id=ticket.id),
)
try:
await msg.bot.send_message(ticket.creator_id, SUPPORT_SIGNATURE)
await msg.copy_to(ticket.creator_id)
await msg.react([ReactionTypeEmoji(emoji="")])
except Exception:
await msg.reply(ADMIN_STANDARD_FALLBACK)
async def close_ticket(self, session: AsyncSession, msg: Message):
thread_id = msg.message_thread_id
if not thread_id:
return
ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id)
await msg.bot.edit_forum_topic(
settings.admin_group_id,
ticket.thread_id,
name=self.format_ticket_name(
TicketCreator(ticket.creator_id),
ticket_id=ticket.id,
status=TicketStatus.CLOSED,
),
)
await self.ticket_repository.update_ticket_status(session, ticket, TicketStatus.CLOSED)
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
) -> User:
@@ -278,16 +104,6 @@ class UserService:
return user
async def fetch_user_by_thread(self, session: AsyncSession, thread_id: int) -> User | None:
ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id)
return await self.user_repository.get_user_by_id(session, ticket.creator_id)
async def fetch_referal_by_thread(self, session: AsyncSession, thread_id: int) -> User | None:
ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id)
return await self.user_repository.get_referal_by_user_id(session, ticket.creator_id)
async def send_promotion_to_users(self, session: AsyncSession, msg: Message) -> list[User]:
users = await self.user_repository.get_users(session)

View File

@@ -23,6 +23,21 @@ MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
# ============================================================
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
# ============================================================
_SUPPORT_USER_DESCRIPTION = (
"👤 <b>Профиль:</b> {full_name}\n"
"🔗 <b>Alias:</b> {username_display}\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
"❤️ <b>Referal UID:</b> <code>{referal_id}</code>\n\n"
"💰 <b>Баланс:</b> <code>{balance}</code>"
)
_DIVIDER = "────────────────────────────\n\n"
# ============================================================
# МАППИНГИ
# ============================================================
@@ -46,7 +61,7 @@ STATUS_ICON_LIMITED = "🟠"
STATUS_ICON_UNKNOWN = ""
# — Тексты статусов
STATUS_ACTIVE = "работает"
STATUS_ACTIVE = "активна"
STATUS_DISABLED = "отключена"
STATUS_EXPIRED = "истёк"
STATUS_LIMITED = "трафик ограничен"
@@ -109,7 +124,9 @@ FAQ_TEXT = (
GENERAL_PROCESSING = "<i>⏳ Подождите...</i>"
STANDARD_FALLBACK = "<b>❌ Ошибка.</b> Прожмите /start. Если не поможет — значит, мы где-то не доглядели."
STANDARD_FALLBACK = (
"<b>❌ Ошибка.</b> Прожмите /start. Если не поможет — значит, мы где-то не доглядели."
)
CALLBACK_FALLBACK = "❌ Что-то пошло не так. Повторите попытку или обратитесь в поддержку."
@@ -117,6 +134,18 @@ CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могл
UNDEFINED_MESSAGE = "❌ Таких команд у нас нет.\n\n<i>Ищете техподдержку? Вам в /support</i>\n<i>Потерялись? Вам в /start.</i>"
SUBSCRIPTION_INFORMATION = (
"<b>📊 Данные о подписке</b>\n\n"
f"{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code>\n\n"
"{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Истекает:</b> {expire_date}\n"
+ "⏳ <b>Осталось дней:</b> {days_left}\n"
+ "📊 <b>Израсходовано трафика:</b> {used_traffic}\n"
+ "📱 <b>Количество устройств:</b> {hwid_limit}\n"
)
NO_SUBSCRIPTION = "⚠️ <b>Подписка не найдена.</b>"
# ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА
# ============================================================
@@ -134,50 +163,28 @@ REFERALS_MENU = (
# ОФОРМЛЕНИЕ ПОДПИСКИ
# ============================================================
SUBSCRIPTION_DEVICE_SELECTOR = (
"Сколько устройств вы хотите подключить?\n\nЦена: <b>{price}₽</b>"
)
SUBSCRIPTION_DEVICE_SELECTOR = "Сколько устройств вы хотите подключить?\n\nЦена: <b>{price}₽</b>"
SUBSCRIPTION_DURATION_SELECTOR = "На какой срок хотите оформить подписку?\n\nИтого: <b>{price}₽</b>"
SUBSCRIPTION_DURATION_SELECTOR = (
"На какой срок хотите оформить подписку?\n\nИтого: <b>{price}₽</b> <i>(скидка: {discount}%)</i>"
)
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
CHECKOUT_PAYMENT_CHOICE = (
"Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
)
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
# ============================================================
# ПОДДЕРЖКА
# ============================================================
SUPPORT_INIT = (
f"<b>{MALENIA_SUPPORT} Технический отдел</b>\n"
"— если интернет превратился в тыкву или есть идеи по конфигам.\n"
"Пишите по делу, скриншоты приветствуются.\n\n"
"<i>🌑 /cancel — вернуться в меню</i>"
)
SUPPORT_INIT = "<b>🎧 Тех. Поддержка Malenia</b> — не здесь. Кнопка ниже."
SUPPORT_MSG_SENT = "⏳ Приняли ваше сообщение. Мы постараемся ответить на него в ближайшее время.\n<i>Вернуться в меню: /cancel</i>"
SUPPORT_SIGNATURE = "<b>📡 Ответ от Malenia:</b>"
# ============================================================
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
# ============================================================
_SUPPORT_USER_DESCRIPTION = (
"👤 <b>Профиль:</b> {full_name}\n"
"🔗 <b>Alias:</b> {username_display}\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
"❤️ <b>Referal UID:</b> <code>{referal_id}</code>\n\n"
"💰 <b>Баланс:</b> <code>{balance}</code>"
)
_DIVIDER = "────────────────────────────\n\n"
# ============================================================
# ШАБЛОНЫ — ЗАЯВКИ И ТИКЕТЫ
# ============================================================
@@ -214,7 +221,7 @@ ADMIN_TICKET_WITHOUT_RW = (
+ _SUPPORT_USER_DESCRIPTION
+ "\n"
+ _DIVIDER
+ "⚠️ <b>Remnawave-профиль отсутствует</b>"
+ NO_SUBSCRIPTION
)
ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу."