Compare commits

...

7 Commits

12 changed files with 245 additions and 56 deletions

2
.gitignore vendored
View File

@@ -175,3 +175,5 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
plans

View File

@@ -233,7 +233,7 @@ async def proceed_to_checkout(
balance_before=deducted_user.balance + amount, balance_before=deducted_user.balance + amount,
balance_after=deducted_user.balance, balance_after=deducted_user.balance,
devices=ctx.devices, devices=ctx.devices,
duration_days=convert_duration_to_int(ctx.duration), duration_days=convert_duration_to_int(ctx.duration) * 30,
whitelists=ctx.whitelists, whitelists=ctx.whitelists,
credit=credit, credit=credit,
) )

View File

@@ -4,20 +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, 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_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_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()
@@ -34,7 +54,7 @@ async def fetch_referal(
data = command.args.split("_") data = command.args.split("_")
if not data: if not data:
await standard_start(msg, command, state) await standard_start(msg, state, session, deps)
return return
referal = int(data[1]) referal = int(data[1])
@@ -126,28 +146,43 @@ async def sub_info(
return return
if not user: if not user:
await cb.answer("1" + 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("2" + 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 +194,58 @@ async def sub_info(
) )
@router.callback_query(F.data == "hwid_control")
async def hwid_control(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO):
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)
if not rw_user:
await cb.message.edit_text(NO_SUBSCRIPTION, reply_markup=return_to_menu)
return
devices = await get_hwid_list(deps.rw_sdk, rw_user.uuid)
if not devices:
await cb.message.edit_text(NO_CONNECTIONS, reply_markup=return_to_menu)
return
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")
async def toggle_autorenewal( async def toggle_autorenewal(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
@@ -191,19 +278,3 @@ async def toggle_autorenewal(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
), ),
) )
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
user: User | None = await deps.user_service.update_user_link(
session, cb.from_user.id, deps.rw_sdk
)
status_text = "включено" if new_autorenew else "отключено"
await cb.answer(f"Автопродление {status_text}")
await cb.message.edit_text(
deps.user_service.format_subscription_message(
rw_user, link=user.subscription_link, autorenew=sub.autorenew
),
reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
)

View File

@@ -1,8 +1,9 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
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
@@ -63,6 +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(_return_to_menu_btn) builder.row(_return_to_menu_btn)
return builder.as_markup() return builder.as_markup()
@@ -143,3 +147,25 @@ def pay_url(url: str) -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="", callback_data="menu:main")], [InlineKeyboardButton(text="", callback_data="menu:main")],
] ]
).as_markup() ).as_markup()
def construct_devices_list(devices: list[HwidDeviceDto]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for device in devices:
builder.row(
InlineKeyboardButton(
text=format_hwid_device(device), callback_data=f"hwid:{device.hwid}"
)
)
builder.row(_return_btn(data="sub_info"))
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,6 +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>"
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>"
# ============================================================ # ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА # РЕФЕРАЛЬНАЯ СИСТЕМА
@@ -228,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

@@ -4,7 +4,13 @@ from datetime import UTC, datetime, timedelta
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -349,3 +355,32 @@ async def create_user(
except Exception as exc: except Exception as exc:
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, user_uuid: str) -> list[HwidDeviceDto] | None:
try:
resp = await sdk.hwid.get_hwid_user(user_uuid)
return resp.devices
except Exception:
logger.exception("failed to fetch hwid list for user=%s", user_uuid)
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

@@ -8,6 +8,7 @@ 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
@@ -122,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,
@@ -179,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,
@@ -239,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,
]
)

View File

@@ -66,7 +66,7 @@ def main():
gross_amount = args.amount * (1 + commission_rate) gross_amount = args.amount * (1 + commission_rate)
commission = gross_amount - args.amount commission = gross_amount - args.amount
print(f"customer pays fee:") print("customer pays fee:")
print(f" Сумма заказа: {args.amount} RUB") print(f" Сумма заказа: {args.amount} RUB")
print(f" Комиссия: {commission:.2f} RUB") print(f" Комиссия: {commission:.2f} RUB")
print(f" Итого к оплате: {gross_amount:.2f} RUB") print(f" Итого к оплате: {gross_amount:.2f} RUB")