From 606aa4b89f2ef7530e252e8c239b81aa1f031689 Mon Sep 17 00:00:00 2001 From: hexdev Date: Wed, 15 Apr 2026 20:19:07 +0700 Subject: [PATCH] feat: add admin promotion broadcast system and psql health check - Add `/push` command for admins to broadcast messages to all users with approve/decline confirmation flow (handlers/ads.py) - Add common handler for undefined commands and delete callback - Add `UserRepository.get_users()` to fetch all users - Add `UserService.send_promotion_to_users()` with error logging - Add promotion confirmation keyboard and FSM state - Add PostgreSQL availability check in startup.sh before migrations - Add new text constants for promotion flow --- entrypoints/startup.sh | 8 +++++ handlers/__init__.py | 4 ++- handlers/ads.py | 69 ++++++++++++++++++++++++++++++++++++++++ handlers/common.py | 20 ++++++++++++ handlers/support.py | 1 - keyboards/__init__.py | 0 keyboards/admins.py | 9 ++++++ keyboards/client.py | 4 +++ repositories/users.py | 6 ++++ schemas/promotions.py | 9 ++++++ services/user_service.py | 17 ++++++++++ states/admins.py | 5 +++ texts.py | 10 ++++++ 13 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 handlers/ads.py create mode 100644 handlers/common.py create mode 100644 keyboards/__init__.py create mode 100644 keyboards/admins.py create mode 100644 schemas/promotions.py create mode 100644 states/admins.py diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh index 39804a7..283f080 100644 --- a/entrypoints/startup.sh +++ b/entrypoints/startup.sh @@ -6,6 +6,14 @@ echo ruff check --fix black . +echo "[+] checking psql availability" +if ! pg_isready -h localhost -p 5432 ; then + echo "psql is down." >&2 + exit 1 +fi +echo "[+] psql is available!" +echo + echo "[+] Running migrations..." echo alembic upgrade head diff --git a/handlers/__init__.py b/handlers/__init__.py index 81254d4..395c835 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -1,6 +1,8 @@ +from .ads import router as ads_router from .buy import router as buy_router +from .common import router as common_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, menus_router, support_router, buy_router] +routers = [referal_router, menus_router, ads_router, support_router, buy_router, common_router] diff --git a/handlers/ads.py b/handlers/ads.py new file mode 100644 index 0000000..2a033fb --- /dev/null +++ b/handlers/ads.py @@ -0,0 +1,69 @@ +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 config import settings +from keyboards.admins import promotion_confirmation +from keyboards.client import build_return_menu +from schemas.di import DependenciesDTO +from schemas.promotions import NewPromotionContext +from states.admins import AdminStorage +from texts import ( + ADMIN_PROMOTION_CONFIRMATION, + ADMIN_PROMOTION_INIT, + ADMIN_STANDARD_FALLBACK, + SUCCESSFULLY_SENT_PROMOTION, +) + +router = Router() + + +@router.message( + F.chat.id == settings.admin_group_id, F.message_thread_id.is_(None), Command("push") +) +async def push_cmd(msg: Message, state: FSMContext): + await state.clear() + + ctx = NewPromotionContext(creator_id=msg.from_user.id) + await msg.delete() + await msg.answer(ADMIN_PROMOTION_INIT, reply_markup=build_return_menu("delete", "❌")) + + await state.set_state(AdminStorage.promotion_msg) + await state.set_data({"ctx": ctx}) + + +@router.message(F.chat.id == settings.admin_group_id, AdminStorage.promotion_msg) +async def send_promotion(msg: Message, state: FSMContext): + data = await state.get_data() + + ctx: NewPromotionContext | None = data.get("ctx") + if not ctx: + await msg.answer(ADMIN_STANDARD_FALLBACK) + return + + ctx.message = msg + await msg.reply(ADMIN_PROMOTION_CONFIRMATION, reply_markup=promotion_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 promotion_action( + cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO +): + data = await state.get_data() + await state.clear() + + await cb.message.delete() + if cb.data == "decline": + return + ctx: NewPromotionContext | None = data.get("ctx") + if not (ctx and ctx.creator_id and ctx.message): + await cb.message.answer(ADMIN_STANDARD_FALLBACK) + return + + sent = await deps.user_service.send_promotion_to_users(session, ctx.message) + await cb.message.answer(SUCCESSFULLY_SENT_PROMOTION.format(user_count=len(sent))) diff --git a/handlers/common.py b/handlers/common.py new file mode 100644 index 0000000..b742466 --- /dev/null +++ b/handlers/common.py @@ -0,0 +1,20 @@ +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from config import settings +from texts import UNDEFINED_MESSAGE + +router = Router() + + +@router.message(F.chat.id != settings.admin_group_id) +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() diff --git a/handlers/support.py b/handlers/support.py index c28e874..a7fa99b 100644 --- a/handlers/support.py +++ b/handlers/support.py @@ -53,7 +53,6 @@ async def received_message( F.from_user.is_bot == False, # noqa: E712 ~Command("close"), ~Command("refpay"), - F.text, ) async def handle_admin_message( msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keyboards/admins.py b/keyboards/admins.py new file mode 100644 index 0000000..2d1d0e6 --- /dev/null +++ b/keyboards/admins.py @@ -0,0 +1,9 @@ +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() diff --git a/keyboards/client.py b/keyboards/client.py index 91e826f..f509194 100644 --- a/keyboards/client.py +++ b/keyboards/client.py @@ -38,6 +38,10 @@ def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> Inl 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() diff --git a/repositories/users.py b/repositories/users.py index b270305..ebe1363 100644 --- a/repositories/users.py +++ b/repositories/users.py @@ -6,6 +6,12 @@ from schemas.users import NewUserDTO class UserRepository: + async def get_users(self, session: AsyncSession) -> list[User]: + stmt = select(User) + res = await session.execute(stmt) + + return list(res.scalars().all()) + async def get_user_by_id(self, session: AsyncSession, user_id: int) -> User | None: stmt = select(User).where(User.id == user_id) result = await session.execute(stmt) diff --git a/schemas/promotions.py b/schemas/promotions.py new file mode 100644 index 0000000..9336e2b --- /dev/null +++ b/schemas/promotions.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from aiogram.types import Message + + +@dataclass +class NewPromotionContext: + creator_id: int + message: Message | None = None diff --git a/services/user_service.py b/services/user_service.py index 0791e18..0582b27 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -1,3 +1,5 @@ +import logging + from aiogram import Bot from aiogram.types import Message, ReactionTypeEmoji, User as TelegramUser from remnawave import RemnawaveSDK @@ -21,6 +23,8 @@ from texts import ( TICKET_STATUS_EMOJIS, ) +logger = logging.getLogger(__name__) + class UserServiceError(Exception): ... @@ -261,3 +265,16 @@ class UserService: ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id) return await self.user_repository.get_referal_by_user_id(session, ticket.creator_id) + + async def send_promotion_to_users(self, session: AsyncSession, msg: Message) -> list[User]: + users = await self.user_repository.get_users(session) + + sent: list[User] = [] + for user in users: + try: + await msg.copy_to(user.id) + sent.append(user) + except Exception: + logger.critical("failed to send a message to user %d.", user.id, exc_info=True) + + return sent diff --git a/states/admins.py b/states/admins.py new file mode 100644 index 0000000..b35d285 --- /dev/null +++ b/states/admins.py @@ -0,0 +1,5 @@ +from aiogram.fsm.state import State, StatesGroup + + +class AdminStorage(StatesGroup): + promotion_msg = State() diff --git a/texts.py b/texts.py index b6ae8da..5d61e2f 100644 --- a/texts.py +++ b/texts.py @@ -107,6 +107,7 @@ CALLBACK_FALLBACK = "❌ Пакет потерян, попробуйте сно CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться." +UNDEFINED_MESSAGE = "❌ Таких команд у нас нет. /start если потерялись." # ============================================================ # РЕФЕРАЛЬНАЯ СИСТЕМА @@ -209,3 +210,12 @@ ADMIN_TICKET_WITHOUT_RW = ( ) ADMIN_STANDARD_FALLBACK = "❌ Ошибка выполнения. Проверь логи, что-то пошло не по чертежу." + + +# ============================================================ +# АДМИНСКАЯ ХУЙНЯ +# ============================================================ + +ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки" +ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?" +SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям."