feat: Implement payment notification system and API structure
- Added notifications for successful payments in `api/core/notifications.py`. - Created main API entry point in `api/main.py` with FastAPI integration. - Established routing structure in `api/routes/__init__.py` and `api/routes/pally.py` for handling payment callbacks. - Developed billing handlers in `bot/handlers/billing.py` and `bot/handlers/buy.py` for subscription management. - Introduced state management for user interactions in `bot/states/`. - Created user interface elements in `bot/keyboards/` for navigation and payment processing. - Set up middleware for dependency injection in `bot/middlewares/di.py`. - Added support for user commands and interactions in `bot/handlers/common.py` and `bot/handlers/support.py`. - Implemented logging and error handling throughout the bot's functionality. - Created entry point script for API server in `entrypoints/api.sh`.
This commit is contained in:
0
bot/__init__.py
Normal file
0
bot/__init__.py
Normal file
19
bot/handlers/__init__.py
Normal file
19
bot/handlers/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .ads import router as ads_router
|
||||
from .billing import router as billing_router
|
||||
from .buy import router as buy_router
|
||||
from .common import router as common_router
|
||||
from .menus import router as menus_router
|
||||
from .prehandling import router as prehandling_router
|
||||
from .referals import router as referal_router
|
||||
from .support import router as support_router
|
||||
|
||||
routers = [
|
||||
prehandling_router,
|
||||
referal_router,
|
||||
billing_router,
|
||||
menus_router,
|
||||
ads_router,
|
||||
support_router,
|
||||
buy_router,
|
||||
common_router,
|
||||
]
|
||||
5
bot/handlers/ads.py
Normal file
5
bot/handlers/ads.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from aiogram import Router
|
||||
|
||||
router = Router()
|
||||
|
||||
# FIXME: Refine newsletter system
|
||||
49
bot/handlers/billing.py
Normal file
49
bot/handlers/billing.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.client import pay_url, return_to_menu
|
||||
from bot.states.buy import BillingStorage
|
||||
from bot.texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
|
||||
from schemas.billing import BillingContext, BillingProviders
|
||||
from schemas.di import DependenciesDTO
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.callback_query(BillingStorage.pending, F.data == "billing:pally")
|
||||
async def pally_init(
|
||||
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||
):
|
||||
data = await state.get_data()
|
||||
ctx: BillingContext | None = data.get("ctx")
|
||||
if not ctx:
|
||||
logger.warning("ctx not found on billing choice")
|
||||
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
ctx.provider = BillingProviders.PALLY
|
||||
|
||||
await cb.message.edit_text(
|
||||
BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())
|
||||
)
|
||||
|
||||
bill = await deps.billing_service.pally_initiate_payment(
|
||||
session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount
|
||||
)
|
||||
|
||||
if not bill:
|
||||
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
|
||||
logger.critical("bill creation prompted, but bill is None")
|
||||
return
|
||||
|
||||
await cb.message.edit_text(
|
||||
BILL_CREATED.format(amount=ctx.amount, provider=ctx.provider.value.title()),
|
||||
reply_markup=pay_url(bill.link_page_url),
|
||||
)
|
||||
176
bot/handlers/buy.py
Normal file
176
bot/handlers/buy.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import logging
|
||||
import math
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from bot.keyboards.client import (
|
||||
devices_selector,
|
||||
duration_selector,
|
||||
payment_gateways,
|
||||
return_to_menu,
|
||||
)
|
||||
from bot.states.buy import BillingStorage, SubscriptionStorage
|
||||
from bot.texts import (
|
||||
CALLBACK_FALLBACK,
|
||||
CHECKOUT_PAYMENT_CHOICE,
|
||||
CONTEXT_REINITIATED,
|
||||
NOTHING_PLACEHOLDER,
|
||||
STANDARD_FALLBACK,
|
||||
SUBSCRIPTION_DEVICE_SELECTOR,
|
||||
SUBSCRIPTION_DURATION_SELECTOR,
|
||||
)
|
||||
from config import settings
|
||||
from misc.utils import (
|
||||
calculate_price,
|
||||
convert_duration_to_int,
|
||||
convert_int_to_duration,
|
||||
get_discount,
|
||||
)
|
||||
from schemas.billing import BillingContext
|
||||
from schemas.subscriptions import SubscriptionPlan
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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: SubscriptionPlan | None = 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: SubscriptionPlan | None = data.get("ctx", SubscriptionPlan())
|
||||
ctx.whitelists = not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
|
||||
|
||||
try:
|
||||
await cb.message.edit_text(
|
||||
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: SubscriptionPlan | None = data.get("ctx")
|
||||
if not ctx:
|
||||
await cb.answer(CALLBACK_FALLBACK)
|
||||
return
|
||||
|
||||
try:
|
||||
discount = get_discount(convert_duration_to_int(ctx.duration))
|
||||
ctx.discount = 1 - discount / 100
|
||||
await cb.message.edit_text(
|
||||
SUBSCRIPTION_DURATION_SELECTOR.format(
|
||||
price=calculate_price(ctx),
|
||||
discount=math.floor(discount),
|
||||
),
|
||||
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: SubscriptionPlan | None = data.get("ctx")
|
||||
if not 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:
|
||||
discount = get_discount(convert_duration_to_int(ctx.duration))
|
||||
ctx.discount = 1 - discount / 100
|
||||
await cb.message.edit_text(
|
||||
SUBSCRIPTION_DURATION_SELECTOR.format(
|
||||
price=calculate_price(ctx),
|
||||
discount=math.floor(discount),
|
||||
),
|
||||
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):
|
||||
data = await state.get_data()
|
||||
await state.clear()
|
||||
|
||||
ctx: SubscriptionPlan | None = data.get("ctx")
|
||||
if not ctx:
|
||||
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
|
||||
return
|
||||
|
||||
await cb.message.edit_text(
|
||||
CHECKOUT_PAYMENT_CHOICE,
|
||||
reply_markup=payment_gateways,
|
||||
) # FIXME: temp solution that's incredibly stupid
|
||||
|
||||
await state.set_state(BillingStorage.pending)
|
||||
billing_context = BillingContext(amount=calculate_price(ctx))
|
||||
await state.set_data({"ctx": billing_context})
|
||||
19
bot/handlers/common.py
Normal file
19
bot/handlers/common.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from bot.texts import UNDEFINED_MESSAGE
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def undefined_cmd(msg: Message):
|
||||
await msg.delete()
|
||||
await msg.answer(UNDEFINED_MESSAGE)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "delete")
|
||||
async def delete_cb(cb: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await cb.message.delete()
|
||||
107
bot/handlers/menus.py
Normal file
107
bot/handlers/menus.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command, CommandObject, CommandStart
|
||||
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, return_to_menu
|
||||
from bot.texts import FAQ_TEXT, GENERAL_PROCESSING
|
||||
from misc.utils import build_main_menu_text, format_subscription_link
|
||||
from schemas.di import DependenciesDTO
|
||||
from services.rw import get_user_by_telegram_id
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(CommandStart(deep_link=True, deep_link_encoded=True))
|
||||
async def fetch_referal(
|
||||
msg: Message,
|
||||
command: CommandObject,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
deps: DependenciesDTO,
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
data = command.args.split("_")
|
||||
if not data:
|
||||
await standard_start(msg, command, state)
|
||||
return
|
||||
|
||||
referal = int(data[1])
|
||||
user = await deps.user_service.add_user(
|
||||
session, user_id=msg.from_user.id, referal=referal, rw_sdk=deps.rw_sdk
|
||||
)
|
||||
|
||||
await msg.answer(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command(commands=["start", "cancel"]))
|
||||
async def standard_start(
|
||||
msg: Message,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
deps: DependenciesDTO,
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
|
||||
|
||||
await msg.answer(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "menu:main")
|
||||
async def main_menu_cb(
|
||||
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||
await cb.message.edit_text(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "update_link")
|
||||
async def update_link(
|
||||
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||
):
|
||||
await state.clear()
|
||||
await cb.message.edit_text(GENERAL_PROCESSING)
|
||||
|
||||
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
|
||||
await cb.answer("✅")
|
||||
await cb.message.edit_text(
|
||||
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||
reply_markup=main_menu,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "faq")
|
||||
async def faq(cb: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
await cb.message.edit_text(FAQ_TEXT, reply_markup=return_to_menu)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "sub_info")
|
||||
async def sub_info(
|
||||
cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO, session: AsyncSession
|
||||
):
|
||||
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)
|
||||
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
|
||||
|
||||
await cb.message.edit_text(
|
||||
deps.user_service.format_subscription_message(rw_user, link=user.subscription_link),
|
||||
reply_markup=return_to_menu,
|
||||
)
|
||||
9
bot/handlers/prehandling.py
Normal file
9
bot/handlers/prehandling.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import Message
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(F.chat.type != "private")
|
||||
async def prehandle_non_private(msg: Message):
|
||||
return
|
||||
28
bot/handlers/referals.py
Normal file
28
bot/handlers/referals.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram.utils.deep_linking import create_start_link
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.client import referals_kb
|
||||
from bot.texts import REFERALS_MENU
|
||||
from misc.utils import format_share_deep_link
|
||||
from schemas.di import DependenciesDTO
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "referal")
|
||||
async def referal_menu(
|
||||
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||
referal_link = await create_start_link(cb.bot, f"ref_{user.id}", encode=True)
|
||||
share_link = format_share_deep_link(referal_link)
|
||||
|
||||
await cb.message.edit_text(
|
||||
REFERALS_MENU.format(balance=user.balance, link=referal_link),
|
||||
reply_markup=referals_kb(share_link),
|
||||
)
|
||||
27
bot/handlers/support.py
Normal file
27
bot/handlers/support.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import logging
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from bot.keyboards.client import support_url_kb
|
||||
from bot.texts import SUPPORT_INIT
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "support")
|
||||
async def support_init(cb: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
await cb.message.edit_text(text=SUPPORT_INIT, reply_markup=support_url_kb)
|
||||
|
||||
|
||||
@router.message(Command("support"))
|
||||
async def support_init_cmd(msg: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
await msg.delete()
|
||||
|
||||
await msg.answer(text=SUPPORT_INIT, reply_markup=support_url_kb)
|
||||
0
bot/keyboards/__init__.py
Normal file
0
bot/keyboards/__init__.py
Normal file
16
bot/keyboards/admins.py
Normal file
16
bot/keyboards/admins.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
promotion_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="✅", callback_data="approve")],
|
||||
[InlineKeyboardButton(text="❌", callback_data="decline")],
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
ticket_actions: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="🔒", callback_data="close")],
|
||||
[InlineKeyboardButton(text="💸 Пополнить баланс реферала", callback_data="refpay")],
|
||||
]
|
||||
).as_markup()
|
||||
125
bot/keyboards/client.py
Normal file
125
bot/keyboards/client.py
Normal file
@@ -0,0 +1,125 @@
|
||||
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 = InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"),
|
||||
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
|
||||
InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"),
|
||||
],
|
||||
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")],
|
||||
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data="menu:main"
|
||||
)
|
||||
|
||||
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_btn]]).as_markup()
|
||||
|
||||
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")],
|
||||
[InlineKeyboardButton(text="✍️ Перейти в поддержку", url="https://t.me/maleniasupportbot")],
|
||||
[_return_to_menu_btn],
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[[InlineKeyboardButton(text="❌", callback_data="delete")]]
|
||||
).as_markup()
|
||||
|
||||
support_url_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="✍️ Перейти в поддержку", url="https://t.me/maleniasupportbot")],
|
||||
[_return_to_menu_btn],
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
|
||||
def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(text=text, callback_data=data)
|
||||
|
||||
|
||||
def build_return_menu(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardBuilder([[_return_btn(data, text)]]).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()
|
||||
|
||||
|
||||
def referals_kb(share_link: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="🔗 Поделиться ссылкой", url=share_link)],
|
||||
[_return_to_menu_btn],
|
||||
]
|
||||
).as_markup()
|
||||
|
||||
|
||||
def pay_url(url: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="💸 Оплатить", url=url)],
|
||||
[InlineKeyboardButton(text="❌", callback_data="menu:main")],
|
||||
]
|
||||
).as_markup()
|
||||
62
bot/main.py
Normal file
62
bot/main.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from remnawave import RemnawaveSDK
|
||||
|
||||
from bot.handlers import routers
|
||||
from bot.middlewares import DIMiddleware
|
||||
from config import settings
|
||||
from db.session import async_session
|
||||
from misc.pally import PallyClient
|
||||
from repositories.bills import BillsRepository
|
||||
from repositories.users import UserRepository
|
||||
from schemas.di import DependenciesDTO
|
||||
from services.billing_service import BillingService
|
||||
from services.user_service import UserService
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
async def main():
|
||||
dp = Dispatcher()
|
||||
|
||||
pally_client = PallyClient(settings.pally_token)
|
||||
|
||||
user_repository = UserRepository()
|
||||
user_service = UserService(user_repository)
|
||||
|
||||
bills_repository = BillsRepository()
|
||||
billing_service = BillingService(
|
||||
user_repository=user_repository, bills_repository=bills_repository
|
||||
)
|
||||
|
||||
rw_sdk = RemnawaveSDK(
|
||||
base_url=settings.remnawave_url,
|
||||
token=settings.remnawave_token,
|
||||
)
|
||||
|
||||
deps = DependenciesDTO(
|
||||
user_repository=user_repository,
|
||||
user_service=user_service,
|
||||
rw_sdk=rw_sdk,
|
||||
pally_client=pally_client,
|
||||
bills_repository=bills_repository,
|
||||
billing_service=billing_service,
|
||||
)
|
||||
dp.update.middleware.register(DIMiddleware(deps, async_session))
|
||||
|
||||
aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
|
||||
default = DefaultBotProperties(parse_mode="HTML")
|
||||
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
|
||||
|
||||
for router in routers:
|
||||
dp.include_router(router)
|
||||
|
||||
await dp.start_polling(bot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
3
bot/middlewares/__init__.py
Normal file
3
bot/middlewares/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .di import DIMiddleware
|
||||
|
||||
__all__ = ["DIMiddleware"]
|
||||
26
bot/middlewares/di.py
Normal file
26
bot/middlewares/di.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from schemas.di import DependenciesDTO
|
||||
|
||||
|
||||
class DIMiddleware(BaseMiddleware):
|
||||
def __init__(self, deps: DependenciesDTO, session_factory: async_sessionmaker) -> None:
|
||||
self.deps = deps
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
data["deps"] = self.deps
|
||||
|
||||
async with self.session_factory() as session:
|
||||
data["session"] = session
|
||||
return await handler(event, data)
|
||||
0
bot/states/__init__.py
Normal file
0
bot/states/__init__.py
Normal file
6
bot/states/admins.py
Normal file
6
bot/states/admins.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class AdminStorage(StatesGroup):
|
||||
promotion_msg = State()
|
||||
refpay_amount = State()
|
||||
12
bot/states/buy.py
Normal file
12
bot/states/buy.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class SubscriptionStorage(StatesGroup):
|
||||
devices = State()
|
||||
duration = State()
|
||||
checkout = State()
|
||||
|
||||
|
||||
class BillingStorage(StatesGroup):
|
||||
pending = State()
|
||||
refresh = State()
|
||||
5
bot/states/support.py
Normal file
5
bot/states/support.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class SupportStorage(StatesGroup):
|
||||
prompt = State()
|
||||
247
bot/texts.py
Normal file
247
bot/texts.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# ============================================================
|
||||
# PREMIUM EMOJI
|
||||
# ============================================================
|
||||
|
||||
|
||||
class PremiumEmoji:
|
||||
def __init__(self, emoji_id: int, emoji: str):
|
||||
self.emoji_id = emoji_id
|
||||
self.emoji = emoji
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'<tg-emoji emoji-id="{self.emoji_id}">{self.emoji}</tg-emoji>'
|
||||
|
||||
|
||||
# — Иконки бота
|
||||
MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️")
|
||||
MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️")
|
||||
MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
|
||||
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
|
||||
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
|
||||
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
|
||||
# ============================================================
|
||||
|
||||
_SUPPORT_USER_DESCRIPTION = (
|
||||
"👤 <b>Профиль:</b> {full_name}\n"
|
||||
"🔗 <b>Alias:</b> {username_display}\n"
|
||||
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
|
||||
"❤️ <b>Referal UID:</b> <code>{referal_id}</code>\n\n"
|
||||
"💰 <b>Баланс:</b> <code>{balance}</code>"
|
||||
)
|
||||
|
||||
_DIVIDER = "────────────────────────────\n\n"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# МАППИНГИ
|
||||
# ============================================================
|
||||
|
||||
# ============================================================
|
||||
# СТАТУСЫ ПОДПИСКИ
|
||||
# ============================================================
|
||||
|
||||
# — Иконки статусов
|
||||
STATUS_ICON_ACTIVE = "🟢"
|
||||
STATUS_ICON_DISABLED = "⚪"
|
||||
STATUS_ICON_EXPIRED = "🔴"
|
||||
STATUS_ICON_LIMITED = "🟠"
|
||||
STATUS_ICON_UNKNOWN = "❓"
|
||||
|
||||
# — Тексты статусов
|
||||
STATUS_ACTIVE = "активна"
|
||||
STATUS_DISABLED = "отключена"
|
||||
STATUS_EXPIRED = "истёк"
|
||||
STATUS_LIMITED = "трафик ограничен"
|
||||
STATUS_UNKNOWN = "неизвестный статус"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ФОРМАТИРОВАНИЕ ДАННЫХ
|
||||
# ============================================================
|
||||
|
||||
# — Дни до истечения подписки
|
||||
DAYS_LEFT_POSITIVE = "{days} дн."
|
||||
DAYS_LEFT_EXPIRED = "истекла {days} дн. назад"
|
||||
DAYS_LEFT_TODAY = "подходит к концу сегодня"
|
||||
|
||||
# — Username
|
||||
NO_USERNAME = "отсутствует"
|
||||
USERNAME_DISPLAY = "@{username}"
|
||||
|
||||
# — Internal Squads
|
||||
NO_SQUADS = "отсутствуют"
|
||||
|
||||
# — HWID лимит
|
||||
UNLIMITED_HWID = "без лимитов"
|
||||
|
||||
# — Трафик
|
||||
NO_TRAFFIC_LIMIT = "не ограничен"
|
||||
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
|
||||
TRAFFIC_NO_LIMIT = "{used}"
|
||||
|
||||
# — Прочее
|
||||
NOTHING_PLACEHOLDER = "∅"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ПОЛЬЗОВАТЕЛЬСКИЙ ИНТЕРФЕЙС
|
||||
# ============================================================
|
||||
|
||||
MAIN_MENU = (
|
||||
str(MALENIA_LOGO)
|
||||
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
|
||||
+ f"<i>{MALENIA_COINS} Личный кабинет</i>\n"
|
||||
+ f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
|
||||
)
|
||||
|
||||
FAQ_TEXT = (
|
||||
"<b>Ваши частые вопросы:</b>\n\n"
|
||||
"<b>— Зачем мне это?</b>\n"
|
||||
"Если вас устраивает, что ваш интернет ограничен парой сайтов и банков, то вам не к нам. Но если хочется свободы и анонимности — добро пожаловать.\n\n"
|
||||
"<b>— Это безопасно?</b>\n"
|
||||
"Мы не храним ваши данные, не используем их в коммерческих целях и не передаем третьим лицам,"
|
||||
"но важно понимать, что полной анонимности не существует. Мы делаем всё, чтобы минимизировать риски, но не продаём иллюзий.\n\n"
|
||||
"<b>— Почему что-то не работает?</b>\n"
|
||||
"Фильтры постоянно обновляются и подстраиваются под новые методы обхода. А мы подстраиваемся под них.\n"
|
||||
"Если что-то перестало работать, скорее всего, это временно, и мы уже работаем над решением. Но обращения в поддержку с описанием проблемы и скриншотами всегда приветствуются, это помогает нам быстрее находить и устранять проблемы.\n\n"
|
||||
"<b>— Как работает реферальная система?</b>\n"
|
||||
"Вы получаете уникальную ссылку, по которой могут прийти ваши друзья. За каждого приведённого друга, который оформит подписку,"
|
||||
"<b>вам будет начисляться процент от их оплаченной подписки</b> на баланс."
|
||||
)
|
||||
|
||||
GENERAL_PROCESSING = "<i>⏳ Подождите...</i>"
|
||||
|
||||
STANDARD_FALLBACK = (
|
||||
"<b>❌ Ошибка.</b> Прожмите /start. Если не поможет — значит, мы где-то не доглядели."
|
||||
)
|
||||
|
||||
CALLBACK_FALLBACK = "❌ Что-то пошло не так. Повторите попытку или обратитесь в поддержку."
|
||||
|
||||
CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться."
|
||||
|
||||
UNDEFINED_MESSAGE = "❌ Таких команд у нас нет.\n\n<i>Ищете техподдержку? Вам в /support</i>\n<i>Потерялись? Вам в /start.</i>"
|
||||
|
||||
SUBSCRIPTION_INFORMATION = (
|
||||
"<b>📊 Данные о подписке</b>\n\n"
|
||||
f"{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code>\n\n"
|
||||
"{status_icon} <b>Статус:</b> {status}\n"
|
||||
+ "📅 <b>Истекает:</b> {expire_date}\n"
|
||||
+ "⏳ <b>Осталось дней:</b> {days_left}\n"
|
||||
+ "📊 <b>Израсходовано трафика:</b> {used_traffic}\n"
|
||||
+ "📱 <b>Количество устройств:</b> {hwid_limit}\n"
|
||||
)
|
||||
|
||||
NO_SUBSCRIPTION = "⚠️ <b>Подписка не найдена.</b>"
|
||||
|
||||
# ============================================================
|
||||
# РЕФЕРАЛЬНАЯ СИСТЕМА
|
||||
# ============================================================
|
||||
|
||||
REFERALS_MENU = (
|
||||
"<b>👥 Реферальная система</b>\n\n"
|
||||
"Делитесь свободой с друзьями и получайте деньги на оплату своей подписки.\n"
|
||||
f"<b>{MALENIA_COINS} На вашем счету:</b> "
|
||||
"<code>{balance}₽</code>\n"
|
||||
f"<b>{MALENIA_LINK} Ваша реферальная ссылка:</b> "
|
||||
"<code>{link}</code>"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# ОФОРМЛЕНИЕ ПОДПИСКИ
|
||||
# ============================================================
|
||||
|
||||
SUBSCRIPTION_DEVICE_SELECTOR = "Сколько устройств вы хотите подключить?\n\nЦена: <b>{price}₽</b>"
|
||||
|
||||
SUBSCRIPTION_DURATION_SELECTOR = (
|
||||
"На какой срок хотите оформить подписку?\n\nИтого: <b>{price}₽</b> <i>(скидка: {discount}%)</i>"
|
||||
)
|
||||
|
||||
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
|
||||
|
||||
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
|
||||
|
||||
# ============================================================
|
||||
# BILLING
|
||||
# ============================================================
|
||||
|
||||
_BILL_TEMPLATE = (
|
||||
"<b>💸 Счёт на {amount}₽</b>\n\n"
|
||||
"<i>‼️ Вам придёт уведомление в течение нескольких минут после завершения платежа.</i>\n"
|
||||
"<b>🔒 Способ оплаты:</b> <i>{provider}</i>\n\n"
|
||||
)
|
||||
|
||||
_BILL_LINK = f"<b>{MALENIA_LINK} Ссылка на оплату:</b>"
|
||||
|
||||
BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
|
||||
|
||||
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ПОДДЕРЖКА
|
||||
# ============================================================
|
||||
|
||||
SUPPORT_INIT = "<b>🎧 Тех. Поддержка Malenia</b> — не здесь. Кнопка ниже."
|
||||
|
||||
SUPPORT_MSG_SENT = "⏳ Приняли ваше сообщение. Мы постараемся ответить на него в ближайшее время.\n<i>Вернуться в меню: /cancel</i>"
|
||||
|
||||
SUPPORT_SIGNATURE = "<b>📡 Ответ от Malenia:</b>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ШАБЛОНЫ — ЗАЯВКИ И ТИКЕТЫ
|
||||
# ============================================================
|
||||
|
||||
SUPPORT_SUBSCRIPTION = (
|
||||
"<b>🎫 ВХОДЯЩИЙ ЗАПРОС:</b>\n\n"
|
||||
+ _SUPPORT_USER_DESCRIPTION
|
||||
+ "\n"
|
||||
+ _DIVIDER
|
||||
+ "🗓️ Срок: <b>{duration} мес.</b>\n"
|
||||
+ "📱 Устройства: <b>{devices}</b>\n"
|
||||
+ "🏳️ Whitelists: <b>{whitelist_description}</b>\n\n"
|
||||
+ "💰 К оплате: <b>{price}₽</b>"
|
||||
)
|
||||
|
||||
ADMIN_TICKET_WITH_RW = (
|
||||
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
||||
+ _SUPPORT_USER_DESCRIPTION
|
||||
+ "\n"
|
||||
+ _DIVIDER
|
||||
+ "📊 <b>ТЕХ. ДАННЫЕ (Remnawave)</b>\n\n"
|
||||
+ "{status_icon} <b>Статус:</b> {status}\n"
|
||||
+ "📅 <b>Deadline:</b> {expire_date}\n"
|
||||
+ "⏳ <b>Остаток:</b> {days_left}\n"
|
||||
+ "📊 <b>Трафик:</b> {used_traffic}\n"
|
||||
+ "📱 <b>HWID:</b> {hwid_limit}\n"
|
||||
+ "🎯 <b>Squads:</b> {squads}\n\n"
|
||||
+ _DIVIDER
|
||||
+ "🔑 <b>UUID:</b> <code>{remnawave_uuid}</code>"
|
||||
)
|
||||
|
||||
ADMIN_TICKET_WITHOUT_RW = (
|
||||
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
||||
+ _SUPPORT_USER_DESCRIPTION
|
||||
+ "\n"
|
||||
+ _DIVIDER
|
||||
+ NO_SUBSCRIPTION
|
||||
)
|
||||
|
||||
ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу."
|
||||
|
||||
|
||||
# ============================================================
|
||||
# АДМИНСКАЯ ХУЙНЯ
|
||||
# ============================================================
|
||||
|
||||
ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки"
|
||||
ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?"
|
||||
SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям."
|
||||
SPECIFY_REFERAL_PAYMENT_AMOUNT = "Укажите количество средств для перевода рефералу пользователя."
|
||||
USER_NO_REFERAL = "❌ У пользователя нет реферала."
|
||||
INVALID_ARGUMENT_REFPAY = "❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя."
|
||||
SUCCESSFUL_REFPAY = "<b>💸 Баланс реферала ({referal_id}) пополнен на {amount}₽</b>"
|
||||
Reference in New Issue
Block a user