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
.pypirc
plans

View File

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

View File

@@ -4,20 +4,40 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
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.texts import (
CALLBACK_FALLBACK,
FAQ_TEXT,
GENERAL_PROCESSING,
HWID_LIST,
NO_CONNECTIONS,
NO_SUBSCRIPTION,
PROMPT_HWID_DELETION,
STANDARD_FALLBACK,
SUCCESSFUL_HWID_DELETION,
)
from db.models.users import User
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.subscriptions import InternalSquads, SubscriptionPlan
from services.rw import get_user_by_telegram_id
from schemas.subscriptions import (
InternalSquads,
SubscriptionDuration,
SubscriptionPlan,
SubscriptionStatus,
)
from services.rw import delete_hwid, get_hwid_list, get_user_by_telegram_id
router = Router()
@@ -34,7 +54,7 @@ async def fetch_referal(
data = command.args.split("_")
if not data:
await standard_start(msg, command, state)
await standard_start(msg, state, session, deps)
return
referal = int(data[1])
@@ -126,28 +146,43 @@ async def sub_info(
return
if not user:
await cb.answer("1" + CALLBACK_FALLBACK)
await cb.answer(CALLBACK_FALLBACK)
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:
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(
session,
user_id=user.id,
end_date=rw_user.expire_at,
devices=rw_user.hwid_device_limit,
duration_days=30,
devices=dto.devices,
duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
whitelists=dto.whitelists,
plan_price_paid=utils.calculate_price(dto),
)
except Exception:
await cb.answer("2" + CALLBACK_FALLBACK)
await cb.answer(CALLBACK_FALLBACK)
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(
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")
async def toggle_autorenewal(
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
),
)
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.utils.keyboard import InlineKeyboardBuilder
from remnawave.models.hwid import HwidDeviceDto
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 .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="💻 Управление устройствами", callback_data="hwid_control")
)
builder.row(_return_to_menu_btn)
return builder.as_markup()
@@ -143,3 +147,25 @@ def pay_url(url: str) -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="", callback_data="menu:main")],
]
).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 лимит
UNLIMITED_HWID = "без лимитов"
# — OS эмодзи
WINDOWS = "💻"
LINUX = "🐧"
ANDROID = "💚"
APPLE = "🍎"
UNKNOWN_OS = "👤"
# — Трафик
NO_TRAFFIC_LIMIT = "не ограничен"
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
@@ -155,6 +162,10 @@ SUBSCRIPTION_INFORMATION = (
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 = (
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>"
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>\n"
"Новая дата окончания: <code>{end_date}</code>."
)

View File

@@ -3,6 +3,8 @@ import urllib.parse
from datetime import UTC, datetime, timedelta
from random import choice
from remnawave.models.hwid import HwidDeviceDto
from bot import texts
from config import settings
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}
current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids
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)
.where(
Subscription.autorenew == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE,
Subscription.end_date <= threshold,
)
.with_for_update()
@@ -69,7 +68,7 @@ class SubscriptionRepository:
whitelists: bool,
duration_days: int,
plan_price_paid: int,
autorenew: bool = False,
autorenew: bool,
status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
) -> Subscription | None:
stmt = (

View File

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

View File

@@ -49,7 +49,9 @@ class RenewalService:
)
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)
if not rw_user:
raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}")
@@ -58,7 +60,7 @@ class RenewalService:
if subscription.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.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices)
@@ -89,7 +91,9 @@ class RenewalService:
await self.subscription_repository.update_subscription_autorenew(
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
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)
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:
await self._renew_subscription_in_rw(subscription)
await self._renew_subscription_in_rw(subscription, new_expire)
except RenewalServiceError:
logger.exception("Failed to renew subscription in RW for user %d", user_id)
await self.user_repository.increase_user_balance(
@@ -117,11 +127,10 @@ class RenewalService:
)
return False
new_end_date = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
await self.subscription_repository.update_sub(
session,
user_id,
end_date=new_end_date,
end_date=new_expire,
autorenew=True,
status=SubscriptionStatus.ACTIVE,
devices=subscription.devices,
@@ -134,7 +143,7 @@ class RenewalService:
user_id,
AUTORENEWAL_SUCCESS.format(
price=price,
end_date=new_end_date.strftime("%d.%m.%Y"),
end_date=new_expire.strftime("%d.%m.%Y"),
),
)
await notify_subscription_renewal(
@@ -146,7 +155,7 @@ class RenewalService:
devices=subscription.devices,
duration_days=subscription.duration_days,
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)

View File

@@ -4,7 +4,13 @@ from datetime import UTC, datetime, timedelta
from uuid import UUID
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__)
@@ -349,3 +355,32 @@ async def create_user(
except Exception as exc:
logger.warning("Failed to create user username=%s: %s", username, exc)
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 config import settings
from db.models import Subscription
from db.models.users import User
from misc import utils
from repositories import UserRepository
@@ -122,7 +123,7 @@ class UserService:
session,
user.id,
end_date=rw_user.expire_at + duration,
autorenew=False,
autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
@@ -179,7 +180,7 @@ class UserService:
session,
user.id,
end_date=expire,
autorenew=False,
autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
@@ -239,3 +240,14 @@ class UserService:
logger.exception("caught an exception while launching promo, skipping user...")
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)
commission = gross_amount - args.amount
print(f"customer pays fee:")
print("customer pays fee:")
print(f" Сумма заказа: {args.amount} RUB")
print(f" Комиссия: {commission:.2f} RUB")
print(f" Итого к оплате: {gross_amount:.2f} RUB")