Compare commits

..

4 Commits

9 changed files with 208 additions and 51 deletions

View File

@@ -4,21 +4,40 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import close_kb, construct_devices_list, main_menu, subscription_controls from bot.keyboards.client import (
close_kb,
construct_devices_list,
main_menu,
prompt_hwid_deletion_kb,
subscription_controls,
)
from bot.keyboards.common import return_to_menu from bot.keyboards.common import return_to_menu
from bot.texts import ( from bot.texts import (
CALLBACK_FALLBACK, CALLBACK_FALLBACK,
FAQ_TEXT, FAQ_TEXT,
GENERAL_PROCESSING, GENERAL_PROCESSING,
HWID_LIST,
NO_CONNECTIONS, NO_CONNECTIONS,
NO_SUBSCRIPTION, NO_SUBSCRIPTION,
PROMPT_HWID_DELETION,
STANDARD_FALLBACK,
SUCCESSFUL_HWID_DELETION,
) )
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from misc.utils import build_main_menu_text, format_subscription_link from misc.utils import (
build_main_menu_text,
convert_duration_to_int,
format_subscription_link,
)
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from schemas.subscriptions import InternalSquads, SubscriptionPlan from schemas.subscriptions import (
from services.rw import get_hwid_list, get_user_by_telegram_id InternalSquads,
SubscriptionDuration,
SubscriptionPlan,
SubscriptionStatus,
)
from services.rw import delete_hwid, get_hwid_list, get_user_by_telegram_id
router = Router() router = Router()
@@ -130,25 +149,40 @@ async def sub_info(
await cb.answer(CALLBACK_FALLBACK) await cb.answer(CALLBACK_FALLBACK)
return return
dto = SubscriptionPlan(
devices=max(rw_user.hwid_device_limit, 3),
whitelists=bool(rw_user.active_squads.count(InternalSquads.WHITELISTS)),
)
if rw_user and not sub: if rw_user and not sub:
try: try:
dto = SubscriptionPlan(
devices=rw_user.hwid_device_limit,
whitelists=bool(rw_user.active_squads.count(InternalSquads.WHITELISTS)),
discount=0,
)
sub = await deps.subscriptions_repository.create_subscription( sub = await deps.subscriptions_repository.create_subscription(
session, session,
user_id=user.id, user_id=user.id,
end_date=rw_user.expire_at, end_date=rw_user.expire_at,
devices=rw_user.hwid_device_limit, devices=dto.devices,
duration_days=30, duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
whitelists=dto.whitelists, whitelists=dto.whitelists,
plan_price_paid=utils.calculate_price(dto), plan_price_paid=utils.calculate_price(dto),
) )
except Exception: except Exception:
await cb.answer(CALLBACK_FALLBACK) await cb.answer(CALLBACK_FALLBACK)
return return
else:
await deps.subscriptions_repository.update_sub(
session,
cb.from_user.id,
status=(
SubscriptionStatus.ACTIVE
if rw_user.status == "ACTIVE"
else SubscriptionStatus.EXPIRED
),
end_date=rw_user.expire_at,
devices=dto.devices,
whitelists=dto.whitelists,
duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
plan_price_paid=utils.calculate_price(dto),
autorenew=sub.autorenew
)
await cb.message.edit_text( await cb.message.edit_text(
deps.user_service.format_subscription_message( deps.user_service.format_subscription_message(
@@ -159,6 +193,7 @@ async def sub_info(
), ),
) )
@router.callback_query(F.data == "hwid_control") @router.callback_query(F.data == "hwid_control")
async def hwid_control(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO): async def hwid_control(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO):
await state.clear() await state.clear()
@@ -169,14 +204,46 @@ async def hwid_control(cb: CallbackQuery, state: FSMContext, deps: DependenciesD
if not rw_user: if not rw_user:
await cb.message.edit_text(NO_SUBSCRIPTION, reply_markup=return_to_menu) await cb.message.edit_text(NO_SUBSCRIPTION, reply_markup=return_to_menu)
return return
devices = await get_hwid_list(deps.rw_sdk, rw_user.uuid) devices = await get_hwid_list(deps.rw_sdk, rw_user.uuid)
if not devices: if not devices:
await cb.message.edit_text(NO_CONNECTIONS, reply_markup=return_to_menu) await cb.message.edit_text(NO_CONNECTIONS, reply_markup=return_to_menu)
return return
await cb.message.edit_text(text="pepe", reply_markup=construct_devices_list(devices))
hwid_limit = max(rw_user.hwid_device_limit, 3)
await cb.message.edit_text(
text=HWID_LIST.format(count=len(devices), limit=hwid_limit),
reply_markup=construct_devices_list(devices),
)
@router.callback_query(F.data.startswith("hwid:"))
async def prompt_delete_hwid(cb: CallbackQuery, state: FSMContext):
await state.clear()
hwid = cb.data.split(":")[1]
await cb.message.edit_text(PROMPT_HWID_DELETION, reply_markup=prompt_hwid_deletion_kb(hwid))
@router.callback_query(F.data.startswith("delete_hwid:"))
async def delete_hwid_bot(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO):
await state.clear()
hwid = cb.data.split(":")[1]
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)
if not rw_user:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
status = await delete_hwid(deps.rw_sdk, rw_user.uuid, hwid)
if status:
await cb.message.edit_text(SUCCESSFUL_HWID_DELETION, reply_markup=return_to_menu)
else:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
@router.callback_query(F.data == "toggle:autorenewal") @router.callback_query(F.data == "toggle:autorenewal")

View File

@@ -3,7 +3,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from remnawave.models.hwid import HwidDeviceDto from remnawave.models.hwid import HwidDeviceDto
from config import settings from config import settings
from misc.utils import convert_duration_to_int from misc.utils import convert_duration_to_int, format_hwid_device
from schemas.subscriptions import SubscriptionDuration from schemas.subscriptions import SubscriptionDuration
from .common import _return_to_menu_btn from .common import _return_to_menu_btn
@@ -64,7 +64,9 @@ def subscription_controls(
) )
builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal")) builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal"))
builder.row(InlineKeyboardButton(text="Управление устройствами", callback_data="hwid_control")) builder.row(
InlineKeyboardButton(text="💻 Управление устройствами", callback_data="hwid_control")
)
builder.row(_return_to_menu_btn) builder.row(_return_to_menu_btn)
return builder.as_markup() return builder.as_markup()
@@ -146,10 +148,24 @@ def pay_url(url: str) -> InlineKeyboardMarkup:
] ]
).as_markup() ).as_markup()
def construct_devices_list(devices: list[HwidDeviceDto]) -> InlineKeyboardMarkup: def construct_devices_list(devices: list[HwidDeviceDto]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
for device in devices: for device in devices:
builder.row(InlineKeyboardButton(text=f"{device.platform} | {device.device_model} | {device.user_agent.split('/')[0]}", callback_data=f"hwid:{device.hwid}")) builder.row(
InlineKeyboardButton(
text=format_hwid_device(device), callback_data=f"hwid:{device.hwid}"
)
)
builder.row(_return_btn(data="sub_info")) builder.row(_return_btn(data="sub_info"))
return builder.as_markup() return builder.as_markup()
def prompt_hwid_deletion_kb(hwid: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"delete_hwid:{hwid}")],
[_return_btn("hwid_control", text="❌ Отмена")],
]
).as_markup()

View File

@@ -74,6 +74,13 @@ NO_SQUADS = "отсутствуют"
# — HWID лимит # — HWID лимит
UNLIMITED_HWID = "без лимитов" UNLIMITED_HWID = "без лимитов"
# — OS эмодзи
WINDOWS = "💻"
LINUX = "🐧"
ANDROID = "💚"
APPLE = "🍎"
UNKNOWN_OS = "👤"
# — Трафик # — Трафик
NO_TRAFFIC_LIMIT = "не ограничен" NO_TRAFFIC_LIMIT = "не ограничен"
TRAFFIC_WITH_LIMIT = "{used} / {limit}" TRAFFIC_WITH_LIMIT = "{used} / {limit}"
@@ -155,7 +162,10 @@ SUBSCRIPTION_INFORMATION = (
NO_SUBSCRIPTION = f"{MALENIA_WARN} <b>Подписка не найдена.</b>" NO_SUBSCRIPTION = f"{MALENIA_WARN} <b>Подписка не найдена.</b>"
NO_CONNECTIONS = f"{MALENIA_SHIELD} <b>Вы ещё не подключались по своей ссылке.</b>" NO_CONNECTIONS = f"{MALENIA_SHIELD} <b>У вас нет подключённых устройств.</b>"
HWID_LIST = f"{MALENIA_SHIELD} <b>Ваши устройства ({{count}} / {{limit}}):</b>"
PROMPT_HWID_DELETION = f"{MALENIA_WARN} <b>Вы уверены что хотите удалить это устройство?</b>"
SUCCESSFUL_HWID_DELETION = f"{MALENIA_HEART} <b>Устройство успешно удалено.</b>"
# ============================================================ # ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА # РЕФЕРАЛЬНАЯ СИСТЕМА
@@ -229,7 +239,7 @@ AUTORENEWAL_INSUFFICIENT_FUNDS = (
) )
AUTORENEWAL_SUCCESS = ( AUTORENEWAL_SUCCESS = (
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>" f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>\n"
"Новая дата окончания: <code>{end_date}</code>." "Новая дата окончания: <code>{end_date}</code>."
) )

View File

@@ -3,6 +3,8 @@ import urllib.parse
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from random import choice from random import choice
from remnawave.models.hwid import HwidDeviceDto
from bot import texts from bot import texts
from config import settings from config import settings
from schemas.subscriptions import InternalSquads, SubscriptionDuration, SubscriptionPlan from schemas.subscriptions import InternalSquads, SubscriptionDuration, SubscriptionPlan
@@ -271,3 +273,25 @@ def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool:
active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads} active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads}
current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids
return current_has_whitelist == new_plan.whitelists return current_has_whitelist == new_plan.whitelists
def _format_os_emoji(os: str) -> str:
os = os.lower()
if os == "windows":
return texts.WINDOWS
if os == "linux":
return texts.LINUX
if os == "ios":
return texts.APPLE
if os == "android":
return texts.ANDROID
return texts.UNKNOWN_OS
def format_hwid_device(device: HwidDeviceDto) -> str:
os = device.platform
model = device.device_model
client = device.user_agent.split("/")[0]
emoji = _format_os_emoji(os)
return f"{emoji} {os} | {model} | {client}"

View File

@@ -16,7 +16,6 @@ class SubscriptionRepository:
select(Subscription) select(Subscription)
.where( .where(
Subscription.autorenew == True, # noqa: E712 Subscription.autorenew == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE,
Subscription.end_date <= threshold, Subscription.end_date <= threshold,
) )
.with_for_update() .with_for_update()
@@ -69,7 +68,7 @@ class SubscriptionRepository:
whitelists: bool, whitelists: bool,
duration_days: int, duration_days: int,
plan_price_paid: int, plan_price_paid: int,
autorenew: bool = False, autorenew: bool,
status: SubscriptionStatus = SubscriptionStatus.ACTIVE, status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
) -> Subscription | None: ) -> Subscription | None:
stmt = ( stmt = (

View File

@@ -99,13 +99,13 @@ async def notify_subscription_renewal(
bot, bot,
settings.admin_log_subscriptions_topic_id, settings.admin_log_subscriptions_topic_id,
( (
"<b>Продление подписки</b>", "<b>Продление подписки</b>\n\n"
f"Пользователь: <code>{user_id}</code>", f"Пользователь: <code>{user_id}</code>\n"
f"Стоимость: <b>{amount}₽</b>", f"Стоимость: <b>{amount}₽</b>\n"
f"Баланс: {balance_before}₽ -> {balance_after}", f"Баланс: {balance_before}₽ -> {balance_after}\n"
f"Устройства: {devices}", f"Устройства: {devices}\n"
f"Срок: {duration_days} дн.", f"Срок: {duration_days} дн.\n"
f"Whitelist: {'да' if whitelists else 'нет'}", f"Whitelist: {'да' if whitelists else 'нет'}\n"
f"Новая дата окончания: {escape(end_date)}", f"Новая дата окончания: {escape(end_date)}"
), ),
) )

View File

@@ -49,7 +49,9 @@ class RenewalService:
) )
return utils.calculate_price(plan) return utils.calculate_price(plan)
async def _renew_subscription_in_rw(self, subscription: Subscription) -> None: async def _renew_subscription_in_rw(
self, subscription: Subscription, expire_at: datetime.datetime
) -> None:
rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id) rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id)
if not rw_user: if not rw_user:
raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}") raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}")
@@ -58,7 +60,7 @@ class RenewalService:
if subscription.whitelists: if subscription.whitelists:
squads.append(InternalSquads.WHITELISTS) squads.append(InternalSquads.WHITELISTS)
await rw_service.add_days(self.rw_sdk, rw_user.uuid, subscription.duration_days) await rw_service.update_expire_at(self.rw_sdk, rw_user.uuid, expire_at)
await rw_service.update_squads(self.rw_sdk, rw_user.uuid, squads) await rw_service.update_squads(self.rw_sdk, rw_user.uuid, squads)
await rw_service.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices) await rw_service.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices)
@@ -89,7 +91,9 @@ class RenewalService:
await self.subscription_repository.update_subscription_autorenew( await self.subscription_repository.update_subscription_autorenew(
session, user_id, False session, user_id, False
) )
await self._notify_user(user_id, AUTORENEWAL_INSUFFICIENT_FUNDS) await self._notify_user(
user_id, AUTORENEWAL_INSUFFICIENT_FUNDS.format(price=price, balance=user.balance)
)
return False return False
renewed_user = await self.user_repository.decrease_user_balance( renewed_user = await self.user_repository.decrease_user_balance(
@@ -104,8 +108,14 @@ class RenewalService:
logger.error("Failed to decrease balance for user %d", user_id) logger.error("Failed to decrease balance for user %d", user_id)
return False return False
now_utc = datetime.datetime.now(datetime.UTC)
if subscription.end_date > now_utc:
new_expire = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
else:
new_expire = now_utc + datetime.timedelta(days=subscription.duration_days)
try: try:
await self._renew_subscription_in_rw(subscription) await self._renew_subscription_in_rw(subscription, new_expire)
except RenewalServiceError: except RenewalServiceError:
logger.exception("Failed to renew subscription in RW for user %d", user_id) logger.exception("Failed to renew subscription in RW for user %d", user_id)
await self.user_repository.increase_user_balance( await self.user_repository.increase_user_balance(
@@ -117,11 +127,10 @@ class RenewalService:
) )
return False return False
new_end_date = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
await self.subscription_repository.update_sub( await self.subscription_repository.update_sub(
session, session,
user_id, user_id,
end_date=new_end_date, end_date=new_expire,
autorenew=True, autorenew=True,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=subscription.devices, devices=subscription.devices,
@@ -134,7 +143,7 @@ class RenewalService:
user_id, user_id,
AUTORENEWAL_SUCCESS.format( AUTORENEWAL_SUCCESS.format(
price=price, price=price,
end_date=new_end_date.strftime("%d.%m.%Y"), end_date=new_expire.strftime("%d.%m.%Y"),
), ),
) )
await notify_subscription_renewal( await notify_subscription_renewal(
@@ -146,7 +155,7 @@ class RenewalService:
devices=subscription.devices, devices=subscription.devices,
duration_days=subscription.duration_days, duration_days=subscription.duration_days,
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
end_date=new_end_date.strftime("%d.%m.%Y"), end_date=new_expire.strftime("%d.%m.%Y"),
) )
logger.info("Successfully renewed subscription for user %d", user_id) logger.info("Successfully renewed subscription for user %d", user_id)

View File

@@ -1,11 +1,15 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import List
from uuid import UUID from uuid import UUID
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto from remnawave.models import (
CreateUserRequestDto,
DeleteUserHwidDeviceResponseDto,
HWIDDeleteRequest,
UpdateUserRequestDto,
)
from remnawave.models.hwid import HwidDeviceDto from remnawave.models.hwid import HwidDeviceDto
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -352,14 +356,31 @@ async def create_user(
logger.warning("Failed to create user username=%s: %s", username, exc) logger.warning("Failed to create user username=%s: %s", username, exc)
return None return None
async def get_hwid_list(
sdk: RemnawaveSDK, async def get_hwid_list(sdk: RemnawaveSDK, user_uuid: str) -> list[HwidDeviceDto] | None:
user_uuid: str
) -> List[HwidDeviceDto] | None:
try: try:
resp = await sdk.hwid.get_hwid_user(user_uuid) resp = await sdk.hwid.get_hwid_user(user_uuid)
return resp.devices return resp.devices
except Exception as exc: except Exception:
logger.exception("failed to fetch hwid list for user=%s", user_uuid) logger.exception("failed to fetch hwid list for user=%s", user_uuid)
return return
async def delete_hwid(sdk: RemnawaveSDK, user_uuid: str, hwid: str) -> bool:
body = HWIDDeleteRequest(user_uuid=user_uuid, hwid=hwid)
try:
resp = await sdk.hwid.delete_hwid_to_user(body)
return isinstance(resp, DeleteUserHwidDeviceResponseDto)
except Exception:
logger.exception("failed to delete hwid=%s for user=%s", hwid, user_uuid)
return False
async def update_expire_at(sdk: RemnawaveSDK, user_uuid: str, expire_at: datetime):
dto = UpdateUserRequestDto(uuid=user_uuid, expire_at=expire_at) # type: ignore
try:
await sdk.users.update_user(body=dto)
return True
except Exception:
logger.exception("failed to update expire_at=%s for user=%s", str(expire_at), user_uuid)
return False

View File

@@ -2,13 +2,13 @@ import datetime
import logging import logging
import math import math
from aiogram.types import InlineKeyboardMarkup, Message, User as TelegramUser from aiogram.types import Message, User as TelegramUser
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from remnawave.models.hwid import HwidDeviceDto
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION
from config import settings from config import settings
from db.models import Subscription
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
@@ -123,7 +123,7 @@ class UserService:
session, session,
user.id, user.id,
end_date=rw_user.expire_at + duration, end_date=rw_user.expire_at + duration,
autorenew=False, autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices), devices=int(subscription.devices),
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
@@ -180,7 +180,7 @@ class UserService:
session, session,
user.id, user.id,
end_date=expire, end_date=expire,
autorenew=False, autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices), devices=int(subscription.devices),
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
@@ -240,3 +240,14 @@ class UserService:
logger.exception("caught an exception while launching promo, skipping user...") logger.exception("caught an exception while launching promo, skipping user...")
return sent return sent
def validate_db_subscription(self, subscription: Subscription, rw_user: rw.RWUserInfo):
return all(
[
(rw_user.status != "ACTIVE" and subscription.status == SubscriptionStatus.EXPIRED)
or (
rw_user.status == "ACTIVE" and subscription.status == SubscriptionStatus.ACTIVE
),
rw_user.expire_at == subscription.end_date,
]
)