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