From acb8662a6681134d9b7553f4fbc4bd3bcc03d011 Mon Sep 17 00:00:00 2001 From: hexdev Date: Wed, 13 May 2026 10:34:06 +0700 Subject: [PATCH] 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. --- bot/filters/admins.py | 8 ++++ bot/handlers/__init__.py | 26 ++++-------- bot/handlers/admins/__init__.py | 4 ++ bot/handlers/admins/admins.py | 36 ++++++++++++++++ bot/handlers/admins/ads.py | 52 ++++++++++++++++++++++++ bot/handlers/ads.py | 5 --- bot/handlers/client/__init__.py | 15 +++++++ bot/handlers/{ => client}/billing.py | 3 +- bot/handlers/{ => client}/buy.py | 9 +++- bot/handlers/{ => client}/deposit.py | 3 +- bot/handlers/{ => client}/menus.py | 11 +++-- bot/handlers/{ => client}/referals.py | 0 bot/handlers/{ => client}/support.py | 0 bot/handlers/system/__init__.py | 4 ++ bot/handlers/{ => system}/common.py | 0 bot/handlers/{ => system}/prehandling.py | 0 bot/keyboards/admins.py | 16 ++++---- bot/keyboards/client.py | 13 +++--- bot/keyboards/common.py | 12 ++++++ bot/main.py | 10 +++-- bot/states/support.py | 5 --- bot/texts.py | 28 +++++++++---- config.py | 1 + docker-compose.yml | 2 + misc/utils.py | 28 +++++++++++++ repositories/users.py | 7 ++++ schemas/promotions.py | 4 +- services/user_service.py | 20 +++++++++ 28 files changed, 257 insertions(+), 65 deletions(-) create mode 100644 bot/filters/admins.py create mode 100644 bot/handlers/admins/__init__.py create mode 100644 bot/handlers/admins/admins.py create mode 100644 bot/handlers/admins/ads.py delete mode 100644 bot/handlers/ads.py create mode 100644 bot/handlers/client/__init__.py rename bot/handlers/{ => client}/billing.py (94%) rename bot/handlers/{ => client}/buy.py (96%) rename bot/handlers/{ => client}/deposit.py (95%) rename bot/handlers/{ => client}/menus.py (93%) rename bot/handlers/{ => client}/referals.py (100%) rename bot/handlers/{ => client}/support.py (100%) create mode 100644 bot/handlers/system/__init__.py rename bot/handlers/{ => system}/common.py (100%) rename bot/handlers/{ => system}/prehandling.py (100%) create mode 100644 bot/keyboards/common.py delete mode 100644 bot/states/support.py diff --git a/bot/filters/admins.py b/bot/filters/admins.py new file mode 100644 index 0000000..06df705 --- /dev/null +++ b/bot/filters/admins.py @@ -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 diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index 1af2023..c055c28 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -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", ] diff --git a/bot/handlers/admins/__init__.py b/bot/handlers/admins/__init__.py new file mode 100644 index 0000000..fd39132 --- /dev/null +++ b/bot/handlers/admins/__init__.py @@ -0,0 +1,4 @@ +from .admins import router as general_router +from .ads import router as ads_router + +routers = [general_router, ads_router] diff --git a/bot/handlers/admins/admins.py b/bot/handlers/admins/admins.py new file mode 100644 index 0000000..faa05d5 --- /dev/null +++ b/bot/handlers/admins/admins.py @@ -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 + ) diff --git a/bot/handlers/admins/ads.py b/bot/handlers/admins/ads.py new file mode 100644 index 0000000..013ec1a --- /dev/null +++ b/bot/handlers/admins/ads.py @@ -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) \ No newline at end of file diff --git a/bot/handlers/ads.py b/bot/handlers/ads.py deleted file mode 100644 index 193b29b..0000000 --- a/bot/handlers/ads.py +++ /dev/null @@ -1,5 +0,0 @@ -from aiogram import Router - -router = Router() - -# FIXME: Refine newsletter system diff --git a/bot/handlers/client/__init__.py b/bot/handlers/client/__init__.py new file mode 100644 index 0000000..05ed1ef --- /dev/null +++ b/bot/handlers/client/__init__.py @@ -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, +] diff --git a/bot/handlers/billing.py b/bot/handlers/client/billing.py similarity index 94% rename from bot/handlers/billing.py rename to bot/handlers/client/billing.py index e58ca91..8adde72 100644 --- a/bot/handlers/billing.py +++ b/bot/handlers/client/billing.py @@ -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 diff --git a/bot/handlers/buy.py b/bot/handlers/client/buy.py similarity index 96% rename from bot/handlers/buy.py rename to bot/handlers/client/buy.py index dcc5d09..67f4a7d 100644 --- a/bot/handlers/buy.py +++ b/bot/handlers/client/buy.py @@ -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 diff --git a/bot/handlers/deposit.py b/bot/handlers/client/deposit.py similarity index 95% rename from bot/handlers/deposit.py rename to bot/handlers/client/deposit.py index dc53f5f..25b6010 100644 --- a/bot/handlers/deposit.py +++ b/bot/handlers/client/deposit.py @@ -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 ( diff --git a/bot/handlers/menus.py b/bot/handlers/client/menus.py similarity index 93% rename from bot/handlers/menus.py rename to bot/handlers/client/menus.py index be64f00..32dc6de 100644 --- a/bot/handlers/menus.py +++ b/bot/handlers/client/menus.py @@ -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 + ), ) diff --git a/bot/handlers/referals.py b/bot/handlers/client/referals.py similarity index 100% rename from bot/handlers/referals.py rename to bot/handlers/client/referals.py diff --git a/bot/handlers/support.py b/bot/handlers/client/support.py similarity index 100% rename from bot/handlers/support.py rename to bot/handlers/client/support.py diff --git a/bot/handlers/system/__init__.py b/bot/handlers/system/__init__.py new file mode 100644 index 0000000..01b687b --- /dev/null +++ b/bot/handlers/system/__init__.py @@ -0,0 +1,4 @@ +from .common import router as common_router +from .prehandling import router as prehandling_router + +routers = [common_router, prehandling_router] diff --git a/bot/handlers/common.py b/bot/handlers/system/common.py similarity index 100% rename from bot/handlers/common.py rename to bot/handlers/system/common.py diff --git a/bot/handlers/prehandling.py b/bot/handlers/system/prehandling.py similarity index 100% rename from bot/handlers/prehandling.py rename to bot/handlers/system/prehandling.py diff --git a/bot/keyboards/admins.py b/bot/keyboards/admins.py index 75375f5..a2de2ac 100644 --- a/bot/keyboards/admins.py +++ b/bot/keyboards/admins.py @@ -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() diff --git a/bot/keyboards/client.py b/bot/keyboards/client.py index 08ba02e..fea1b0d 100644 --- a/bot/keyboards/client.py +++ b/bot/keyboards/client.py @@ -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() diff --git a/bot/keyboards/common.py b/bot/keyboards/common.py new file mode 100644 index 0000000..6fa1322 --- /dev/null +++ b/bot/keyboards/common.py @@ -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() \ No newline at end of file diff --git a/bot/main.py b/bot/main.py index 240f765..4bebd49 100644 --- a/bot/main.py +++ b/bot/main.py @@ -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) diff --git a/bot/states/support.py b/bot/states/support.py deleted file mode 100644 index f95c398..0000000 --- a/bot/states/support.py +++ /dev/null @@ -1,5 +0,0 @@ -from aiogram.fsm.state import State, StatesGroup - - -class SupportStorage(StatesGroup): - prompt = State() diff --git a/bot/texts.py b/bot/texts.py index 08d4136..b6baab2 100644 --- a/bot/texts.py +++ b/bot/texts.py @@ -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 = ( - "👤 Профиль: {full_name}\n" - "🔗 Alias: {username_display}\n" - "🆔 UID: {telegram_id}\n\n" - "❤️ Referal UID: {referal_id}\n\n" - "💰 Баланс: {balance}" -) - _DIVIDER = "────────────────────────────\n\n" @@ -205,3 +200,20 @@ SUPPORT_INIT = "🎧 Тех. Поддержка Malenia — не здес SUPPORT_MSG_SENT = "⏳ Приняли ваше сообщение. Мы постараемся ответить на него в ближайшее время.\nВернуться в меню: /cancel" SUPPORT_SIGNATURE = "📡 Ответ от Malenia:" + +# ======================================================================================================================== + +# ============================================================ +# ADMIN +# ============================================================ + +ADM_MAIN_MENU = ( + f"{MALENIA_SHIELD} Malenia. Админ-панель.\n" + "— {quote}\n\n" + f"{MALENIA_SILHOUETTE} Пользователей в боте: {{user_count}}\n" + f"{MALENIA_CARD} Ваш ID: {{user_id}}\n" +) + +ADM_PROMO_INIT = "📢 Введите/перешлите текст рассылки:" +ADM_VERIFY_PROMOTION = "❓ Отправить рассылку? Это действие будет невозможно отменить." +SUCCESSFULLY_SENT_PROMO = "💚 Успешно отправлена рассылка.\n⚡ Успешных отправок: {successful}." \ No newline at end of file diff --git a/config.py b/config.py index 467c115..96d286b 100644 --- a/config.py +++ b/config.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 46e2a0e..930ef8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/misc/utils.py b/misc/utils.py index 22715ef..212198c 100644 --- a/misc/utils.py +++ b/misc/utils.py @@ -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 diff --git a/repositories/users.py b/repositories/users.py index f4eb796..0c7c83c 100644 --- a/repositories/users.py +++ b/repositories/users.py @@ -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()) diff --git a/schemas/promotions.py b/schemas/promotions.py index 9336e2b..fcd293b 100644 --- a/schemas/promotions.py +++ b/schemas/promotions.py @@ -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 diff --git a/services/user_service.py b/services/user_service.py index 0e8fefb..0c114e4 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -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 \ No newline at end of file