Compare commits

..

2 Commits

Author SHA1 Message Date
1be4770afd fix: schemas + states 2026-04-08 19:35:23 +07:00
930fabc028 Implement buy flow and migrate DTOs to schemas
Move DTO definitions from dto/ to schemas/ and update imports.
Add buy handler with FSM state management for device selection,
whitelist toggling, and duration choice. Include pricing logic,
inline keyboards, and new configuration fields.
2026-04-08 19:34:25 +07:00
19 changed files with 315 additions and 16 deletions

View File

@@ -13,6 +13,11 @@ class settings(BaseSettings):
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
remnawave_url: str = Field(alias="REMNAWAVE_URL")
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
min_devices: int = Field(3, alias="MIN_DEVICES")
max_devices: int = Field(12, alias="MAX_DEVICES")
per_device_cost: int = Field(50, alias="PER_DEVICE_COST")
whitelist_cost: int = Field(100, alias="WHITELIST_COST")
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
settings = settings() # type: ignore

View File

@@ -1,4 +1,5 @@
from .menus import router as menus_router
from .support import router as support_router
from .buy import router as buy_router
routers = [menus_router, support_router]
routers = [menus_router, support_router, buy_router]

View File

@@ -1,10 +1,153 @@
from aiogram import Router
from typing import Optional
from aiogram import Router, F
from aiogram.types import CallbackQuery
from aiogram.fsm.context import FSMContext
from dto.di import DependenciesDTO
from keyboards.client import devices_selector, duration_selector
from misc.utils import calculate_price, convert_int_to_duration
from schemas.subscriptions import SubscriptionPlan
from states.buy import SubscriptionStorage
from texts import (
CALLBACK_FALLBACK,
CONTEXT_REINITIATED,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
)
from config import settings
router = Router()
# @router.callback_query()
@router.callback_query(F.data == "buy")
async def buy_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
ctx = SubscriptionPlan()
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(settings.min_devices),
)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(F.data.startswith("devices:"))
async def device_count_select(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx", SubscriptionPlan())
cb_data = cb.data.split(":")
devices = int(cb_data[1]) if cb_data[1].isdigit() else settings.min_devices
ctx.devices = devices
ctx.whitelists = ctx.whitelists or devices >= settings.whitelist_device_threshold
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.devices, F.data == "whitelist:toggle")
async def toggle_whitelists(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
if not ctx:
await state.set_state(SubscriptionPlan())
await state.set_data({"ctx": ctx})
await cb.answer(CALLBACK_FALLBACK)
return
ctx.whitelists = (
not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
)
try:
await cb.message.edit_reply_markup(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(
current=ctx.devices, whitelists=ctx.whitelists
),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.devices, F.data == "confirm")
async def select_duration_init(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
if not ctx:
await state.set_state(SubscriptionPlan())
await state.set_data({"ctx": ctx})
await cb.answer(CALLBACK_FALLBACK)
return
try:
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=duration_selector(
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
raise
await state.set_state(SubscriptionStorage.duration)
await state.set_data({"ctx": ctx})
@router.callback_query(F.data.startswith("duration:"))
async def select_duration(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
if not ctx:
await state.set_state()
await state.set_data({"ctx": ctx})
await cb.answer(CONTEXT_REINITIATED)
ctx = SubscriptionPlan()
cb_data = cb.data.split(":")
duration = int(cb_data[1]) if cb_data[1].isdigit() else 1
ctx.duration = convert_int_to_duration(duration)
try:
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=duration_selector(
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
raise
await state.set_state(SubscriptionStorage.duration)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
await cb.answer("yay!!!! fuck off.")
...

View File

@@ -4,7 +4,7 @@ from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from dto.di import DependenciesDTO
from schemas.di import DependenciesDTO
from keyboards.client import main_menu
from texts import build_main_menu_text

View File

@@ -6,7 +6,7 @@ from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from dto.di import DependenciesDTO
from schemas.di import DependenciesDTO
from services.user_service import UserServiceException
from states.support import SupportStorage
from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT

View File

@@ -1,6 +1,10 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import settings
from misc.utils import convert_duration_to_int
from schemas.subscriptions import SubscriptionDuration
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[
@@ -14,6 +18,71 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
]
).as_markup()
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
def _return_btn(
data: str = "menu:main", text: str = "⬅️ Назад"
) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=data)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]]
[[_return_to_menu_btn]]
).as_markup()
def devices_selector(current: int = settings.min_devices, whitelists: bool = False):
builder = InlineKeyboardBuilder()
buttons = [
InlineKeyboardButton(
text=f"{'' if i == current else ''}{i}", callback_data=f"devices:{i}"
)
for i in range(settings.min_devices, settings.max_devices + 1)
]
builder.add(*buttons).adjust(6)
wl_icon = "" if whitelists else ""
builder.row(
InlineKeyboardButton(
text=f"{wl_icon}Обход Белых Списков (+100₽)",
callback_data="whitelist:toggle",
)
)
builder.row(InlineKeyboardButton(text="🟢", callback_data="confirm"))
builder.row(_return_to_menu_btn)
return builder.as_markup()
def duration_selector(
current: SubscriptionDuration = SubscriptionDuration.MONTH,
back_cb: str = "menu:main",
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
duration_int = convert_duration_to_int(current)
durations = [
(1, "1 Месяц"),
(3, "3 Месяца"),
(6, "6 Месяцев"),
(12, "1 Год"),
]
for val, label in durations:
is_selected = val == duration_int
builder.button(
text=f"{'' if is_selected else ''}{label}",
callback_data=f"duration:{val}",
)
builder.row(
InlineKeyboardButton(text="🛒 Перейти к оплате", callback_data="confirm")
)
builder.row(_return_btn(back_cb))
return builder.as_markup()

View File

@@ -6,7 +6,7 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from dto.di import DependenciesDTO
from schemas.di import DependenciesDTO
from handlers import routers
from middlewares import DIMiddleware
from config import settings

View File

@@ -3,7 +3,7 @@ from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from sqlalchemy.ext.asyncio import async_sessionmaker
from dto.di import DependenciesDTO
from schemas.di import DependenciesDTO
class DIMiddleware(BaseMiddleware):

View File

@@ -1,7 +1,9 @@
from typing import Optional
from random import choice
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
import texts
QUOTES: list[str] = [
@@ -125,3 +127,45 @@ def format_username(username: Optional[str]) -> str:
if username:
return "@" + username
return texts.NO_USERNAME
def convert_duration_to_int(duration: SubscriptionDuration) -> int:
mapping: dict[str, int] = {
SubscriptionDuration.MONTH.value: 1,
SubscriptionDuration.THREE_MONTHS.value: 3,
SubscriptionDuration.SIX_MONTHS.value: 6,
SubscriptionDuration.YEAR.value: 12,
}
return mapping.get(duration.value, 1)
def convert_int_to_duration(value: int) -> SubscriptionDuration:
mapping: dict[int, SubscriptionDuration] = {
1: SubscriptionDuration.MONTH,
3: SubscriptionDuration.THREE_MONTHS,
6: SubscriptionDuration.SIX_MONTHS,
12: SubscriptionDuration.YEAR,
}
return mapping.get(value, SubscriptionDuration.MONTH)
def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
MONTH_DELTA = timedelta(days=30)
mapping: dict[str, timedelta] = {
SubscriptionDuration.MONTH.value: MONTH_DELTA,
SubscriptionDuration.THREE_MONTHS.value: MONTH_DELTA * 3,
SubscriptionDuration.SIX_MONTHS.value: MONTH_DELTA * 6,
SubscriptionDuration.YEAR.value: MONTH_DELTA * 12,
}
return mapping.get(duration.value, MONTH_DELTA)
def calculate_price(subscription: SubscriptionPlan):
return (
(subscription.devices * settings.per_device_cost)
+ (
int(subscription.whitelists) * settings.whitelist_cost
if subscription.devices < settings.whitelist_device_threshold
else 0
)
) * convert_duration_to_int(subscription.duration)

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.tickets import Ticket, TicketStatus
from dto.tickets import NewTicketDTO
from schemas.tickets import NewTicketDTO
class TicketRepository:

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import User
from dto.users import NewUserDTO
from schemas.users import NewUserDTO
class UserRepository:

18
schemas/subscriptions.py Normal file
View File

@@ -0,0 +1,18 @@
from enum import Enum
from pydantic import BaseModel
from config import settings
class SubscriptionDuration(Enum):
MONTH = "month"
THREE_MONTHS = "three_months"
SIX_MONTHS = "six_months"
YEAR = "year"
class SubscriptionPlan(BaseModel):
devices: int = settings.min_devices
duration: SubscriptionDuration = SubscriptionDuration.MONTH
whitelists: bool = False

View File

@@ -6,8 +6,8 @@ from remnawave import RemnawaveSDK
from config import settings
from db.models.tickets import TicketStatus
from db.models.users import User
from dto.tickets import NewTicketDTO
from dto.users import NewUserDTO, TicketCreator
from schemas.tickets import NewTicketDTO
from schemas.users import NewUserDTO, TicketCreator
from repositories import UserRepository
from repositories.tickets import TicketRepository
from services.rw import RWUserInfo, get_user_by_telegram_id
@@ -107,7 +107,6 @@ class UserService:
if ticket:
try:
print(ticket.thread_id)
await msg.copy_to(
chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id
)

6
states/buy.py Normal file
View File

@@ -0,0 +1,6 @@
from aiogram.fsm.state import StatesGroup, State
class SubscriptionStorage(StatesGroup):
devices = State()
duration = State()

View File

@@ -38,8 +38,19 @@ STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
)
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>"
@@ -122,3 +133,6 @@ TRAFFIC_WITH_LIMIT = "{used} / {limit}"
# Шаблон для отображения трафика без лимита: {used}
TRAFFIC_NO_LIMIT = "{used}"
NOTHING_PLACEHOLDER = "🍃"