Compare commits
2 Commits
fc45816eba
...
1be4770afd
| Author | SHA1 | Date | |
|---|---|---|---|
| 1be4770afd | |||
| 930fabc028 |
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
151
handlers/buy.py
151
handlers/buy.py
@@ -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.")
|
||||
...
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
2
main.py
2
main.py
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
18
schemas/subscriptions.py
Normal 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
|
||||
@@ -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
6
states/buy.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
|
||||
|
||||
class SubscriptionStorage(StatesGroup):
|
||||
devices = State()
|
||||
duration = State()
|
||||
14
texts.py
14
texts.py
@@ -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 = "🍃"
|
||||
|
||||
Reference in New Issue
Block a user