Refactor bot handlers and keyboards; implement admin features

- Removed unused prehandling, referrals, support, and states files.
- Updated admin and client handlers to improve structure and functionality.
- Introduced admin filters and routers for better access control.
- Added new billing and deposit functionalities for client interactions.
- Enhanced keyboard layouts for admin and client menus.
- Implemented promotion handling for admin users.
- Updated configuration to include admin settings.
- Improved utility functions for quote fetching and menu text building.
- Added common handlers for undefined commands and message deletions.
This commit is contained in:
2026-05-13 10:34:06 +07:00
parent e9f84d9377
commit acb8662a66
28 changed files with 257 additions and 65 deletions

8
bot/filters/admins.py Normal file
View File

@@ -0,0 +1,8 @@
from aiogram.filters import Filter
from config import settings
class AdminFilter(Filter):
async def __call__(self, event) -> bool:
return event.from_user.id in settings.admins

View File

@@ -1,21 +1,9 @@
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 .deposit import router as deposit_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
from .admins import routers as admin_routers
from .client import routers as client_routers
from .system import routers as system_routers
routers = [
prehandling_router,
referal_router,
billing_router,
deposit_router,
menus_router,
ads_router,
support_router,
buy_router,
common_router,
__all__ = [
"admin_routers",
"client_routers",
"system_routers",
]

View File

@@ -0,0 +1,4 @@
from .admins import router as general_router
from .ads import router as ads_router
routers = [general_router, ads_router]

View File

@@ -0,0 +1,36 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from bot.filters.admins import AdminFilter
from bot.keyboards.admins import main_menu
from misc.utils import build_adm_main_menu_text
from schemas.di import DependenciesDTO
router = Router()
@router.message(AdminFilter(), Command(commands=["start", "cancel"]))
async def standard_start(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
users = await deps.user_repository.user_count(session)
await msg.answer(
build_adm_main_menu_text(user_count=users, user_id=msg.from_user.id), reply_markup=main_menu
)
@router.callback_query(AdminFilter(), F.data == "menu:main")
async def main_menu_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
users = await deps.user_repository.user_count(session)
await cb.message.edit_text(
build_adm_main_menu_text(user_count=users, user_id=cb.from_user.id), reply_markup=main_menu
)

View File

@@ -0,0 +1,52 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from schemas.di import DependenciesDTO
from schemas.promotions import NewPromotionContext
from bot.texts import ADM_PROMO_INIT, CALLBACK_FALLBACK, GENERAL_PROCESSING, ADM_VERIFY_PROMOTION, SUCCESSFULLY_SENT_PROMO
from bot.keyboards.common import return_to_menu
from bot.keyboards.admins import action_confirmation
from bot.states.admins import AdminStorage
router = Router()
@router.callback_query(F.data == "promotion")
async def promo_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(ADM_PROMO_INIT, reply_markup=return_to_menu)
ctx = NewPromotionContext(cb.message)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.message(AdminStorage.promotion_msg)
async def promo_text(msg: Message, state: FSMContext):
data = await state.get_data()
await state.clear()
edited_msg = await msg.answer(GENERAL_PROCESSING)
ctx: NewPromotionContext | None = data.get("ctx")
if ctx:
await ctx.msg.delete()
ctx.promotion = msg
await edited_msg.edit_text(ADM_VERIFY_PROMOTION, reply_markup=action_confirmation)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.callback_query(AdminStorage.promotion_msg, F.data.in_(("approve", "decline")))
async def promo_send(cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO):
data = await state.get_data()
await state.clear()
ctx: NewPromotionContext | None = data.get("ctx")
if not (ctx and ctx.promotion and ctx.msg):
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
return
successful = await deps.user_service.launch_promotion(session, ctx.promotion)
await cb.message.edit_text(SUCCESSFULLY_SENT_PROMO.format(successful=successful), reply_markup=return_to_menu)

View File

@@ -1,5 +0,0 @@
from aiogram import Router
router = Router()
# FIXME: Refine newsletter system

View File

@@ -0,0 +1,15 @@
from .billing import router as billing_router
from .buy import router as buy_router
from .deposit import router as deposit_router
from .menus import router as menus_router
from .referals import router as referal_router
from .support import router as support_router
routers = [
referal_router,
billing_router,
deposit_router,
menus_router,
support_router,
buy_router,
]

View File

@@ -5,7 +5,8 @@ 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.keyboards.client import pay_url
from bot.keyboards.common import 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

View File

@@ -6,7 +6,9 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector, return_to_menu
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector
from bot.keyboards.common import return_to_menu
from bot.states.buy import SubscriptionStorage
from bot.texts import (
CALLBACK_FALLBACK,
@@ -176,7 +178,10 @@ async def proceed_to_checkout(
if effective_balance < amount:
await cb.message.edit_text(
INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount, diff=abs(amount-user.balance)), reply_markup=deposit_kb
INSUFFICIENT_FUNDS.format(
balance=user.balance, amount=amount, diff=abs(amount - user.balance)
),
reply_markup=deposit_kb,
)
return

View File

@@ -2,7 +2,8 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot.keyboards.client import payment_gateways, return_to_menu
from bot.keyboards.client import payment_gateways
from bot.keyboards.common import return_to_menu
from bot.states.buy import BillingStorage
from bot.states.deposit import DepositStorage
from bot.texts import (

View File

@@ -4,7 +4,8 @@ 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, subscription_controls
from bot.keyboards.client import close_kb, main_menu, subscription_controls
from bot.keyboards.common import return_to_menu
from bot.texts import (
CALLBACK_FALLBACK,
FAQ_TEXT,
@@ -149,7 +150,9 @@ async def sub_info(
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),
reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
)
@@ -183,5 +186,7 @@ async def toggle_autorenewal(
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),
reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
)

View File

@@ -0,0 +1,4 @@
from .common import router as common_router
from .prehandling import router as prehandling_router
routers = [common_router, prehandling_router]

View File

@@ -1,16 +1,16 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
promotion_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder(
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="📢 Рассылка", callback_data="promotion")],
[InlineKeyboardButton(text="👤 Управление пользователем", callback_data="usermgmt")],
]
).as_markup()
action_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()

View File

@@ -4,6 +4,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import settings
from misc.utils import convert_duration_to_int
from schemas.subscriptions import SubscriptionDuration
from .common import _return_to_menu_btn
main_menu = InlineKeyboardBuilder(
[
@@ -24,12 +25,6 @@ main_menu = InlineKeyboardBuilder(
]
).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")],
@@ -54,7 +49,9 @@ deposit_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
).as_markup()
def subscription_controls(show_autorenew: bool, current_autorenewal_status: bool = None) -> InlineKeyboardMarkup:
def subscription_controls(
show_autorenew: bool, current_autorenewal_status: bool | None = None
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if show_autorenew:
@@ -64,7 +61,7 @@ def subscription_controls(show_autorenew: bool, current_autorenewal_status: bool
else "🔴 Отключить автопродление"
)
builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal"))
builder.row(_return_to_menu_btn)
return builder.as_markup()

12
bot/keyboards/common.py Normal file
View File

@@ -0,0 +1,12 @@
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
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_btn]]).as_markup()

View File

@@ -6,7 +6,7 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from remnawave import RemnawaveSDK
from bot.handlers import routers
from bot.handlers import admin_routers, client_routers, system_routers
from bot.middlewares import DIMiddleware
from config import settings
from db.session import async_session
@@ -55,8 +55,12 @@ async def main():
default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
for router in routers:
dp.include_router(router)
for r in admin_routers:
dp.include_router(r)
for r in client_routers:
dp.include_router(r)
for r in system_routers:
dp.include_router(r)
await dp.start_polling(bot)

View File

@@ -1,5 +0,0 @@
from aiogram.fsm.state import State, StatesGroup
class SupportStorage(StatesGroup):
prompt = State()

View File

@@ -19,20 +19,15 @@ MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
MALENIA_SHIELD = PremiumEmoji(5444924740596702937, "☀️")
MALENIA_SILHOUETTE = PremiumEmoji(5447117295631506779, "☀️")
MALENIA_CARD = PremiumEmoji(5447145526451543197, "☀️")
# ============================================================
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
# ============================================================
_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"
@@ -205,3 +200,20 @@ SUPPORT_INIT = "<b>🎧 Тех. Поддержка Malenia</b> — не здес
SUPPORT_MSG_SENT = "⏳ Приняли ваше сообщение. Мы постараемся ответить на него в ближайшее время.\n<i>Вернуться в меню: /cancel</i>"
SUPPORT_SIGNATURE = "<b>📡 Ответ от Malenia:</b>"
# ========================================================================================================================
# ============================================================
# ADMIN
# ============================================================
ADM_MAIN_MENU = (
f"<b>{MALENIA_SHIELD} Malenia. Админ-панель.</b>\n"
"<i>— {quote}</i>\n\n"
f"<b>{MALENIA_SILHOUETTE} Пользователей в боте:</b> <code>{{user_count}}</code>\n"
f"<b>{MALENIA_CARD} Ваш ID:</b> <code>{{user_id}}</code>\n"
)
ADM_PROMO_INIT = "<b>📢 Введите/перешлите текст рассылки:</b>"
ADM_VERIFY_PROMOTION = "<b>❓ Отправить рассылку? <i>Это действие будет невозможно отменить.</i></b>"
SUCCESSFULLY_SENT_PROMO = "<b>💚 Успешно отправлена рассылка.</b>\n<i>⚡ Успешных отправок: <code>{successful}</code>.</i>"

View File

@@ -11,6 +11,7 @@ class Settings(BaseSettings):
)
bot_token: str = Field(alias="BOT_TOKEN")
admins: list[int] = Field(alias="ADMINS")
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
pally_token: str = Field(alias="PALLY_TOKEN")

View File

@@ -29,6 +29,7 @@ services:
environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN}
ADMINS: ${ADMINS}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-}
REMNAWAVE_URL: ${REMNAWAVE_URL}
@@ -53,6 +54,7 @@ services:
environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN}
ADMINS: ${ADMINS}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-}
REMNAWAVE_URL: ${REMNAWAVE_URL}

View File

@@ -41,11 +41,33 @@ QUOTES: list[str] = [
"Весь мир на экране. Без лишних вопросов.",
]
ADM_QUOTES: list[str] = [
"Они строят стены. Мы пишем для них двери.",
"За каждым коммитом — чьё-то право на выбор.",
"Цензура — это баг. И мы выкатили патч.",
"Обычный день в админке. Глоток свободы для них.",
"Наш труд невидим. Наше влияние безгранично.",
"Мы не пишем код. Мы держим сеть открытой.",
"Пульт управления свободой. Добро пожаловать.",
"Malenia дышит. Благодаря тебе.",
"Пока они запрещают, мы масштабируем.",
"Тихая работа в бэкенде. Громкие результаты в сети.",
"Миллионы терабайт трафика. Одна общая цель.",
"Инструменты создают люди. Свободу создаешь ты.",
"Никакой магии. Только наш код и упорство.",
"Будущее интернета компилируется прямо здесь.",
"Для кого-то — интерфейс. Для нас — линия фронта.",
]
def fetch_quote() -> str:
return choice(QUOTES)
def adm_fetch_quote() -> str:
return choice(ADM_QUOTES)
def format_bytes(num_bytes: float) -> str:
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
gb_capacity = 1_073_741_824
@@ -236,6 +258,12 @@ def build_main_menu_text(link: str, balance: int) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance)
def build_adm_main_menu_text(user_count: int, user_id: int):
return texts.ADM_MAIN_MENU.format(
quote=adm_fetch_quote(), user_count=user_count, user_id=user_id
)
def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool:
if rw_user.hwid_device_limit != new_plan.devices:
return False

View File

@@ -2,6 +2,7 @@ import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.functions import count
from db.models import User
from db.models.transactions import BalanceTransaction, BalanceTxType
@@ -121,3 +122,9 @@ class UserRepository:
await session.commit()
return user
async def user_count(self, session: AsyncSession) -> int:
stmt = select(count()).select_from(User)
res = await session.execute(stmt)
return int(res.scalar_one_or_none())

View File

@@ -5,5 +5,5 @@ from aiogram.types import Message
@dataclass
class NewPromotionContext:
creator_id: int
message: Message | None = None
msg: Message
promotion: Message | None = None

View File

@@ -14,6 +14,7 @@ from repositories.subscriptions import SubscriptionRepository
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
from schemas.users import NewUserDTO
from services import rw
from config import settings
logger = logging.getLogger(__name__)
@@ -220,3 +221,22 @@ class UserService:
remaining_days = max(0, (rw_user.expire_at - datetime.datetime.now(datetime.UTC)).days)
return math.floor(db_sub.plan_price_paid * remaining_days / db_sub.duration_days)
async def launch_promotion(
self,
session: AsyncSession,
promo_msg: Message
) -> int:
users = await self.user_repository.get_users(session)
sent = 0
for user in users:
if user.id in settings.admins:
continue
try:
await promo_msg.copy_to(user.id)
sent += 1
except Exception:
logger.exception("caught an exception while launching promo, skipping user...")
return sent