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
This commit is contained in:
2026-04-15 20:19:07 +07:00
parent 0ac8ed8d64
commit 606aa4b89f
13 changed files with 160 additions and 2 deletions

View File

@@ -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

View File

@@ -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]

69
handlers/ads.py Normal file
View File

@@ -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)))

20
handlers/common.py Normal file
View File

@@ -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()

View File

@@ -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

0
keyboards/__init__.py Normal file
View File

9
keyboards/admins.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

9
schemas/promotions.py Normal file
View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass
from aiogram.types import Message
@dataclass
class NewPromotionContext:
creator_id: int
message: Message | None = None

View File

@@ -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

5
states/admins.py Normal file
View File

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

View File

@@ -107,6 +107,7 @@ CALLBACK_FALLBACK = "❌ Пакет потерян, попробуйте сно
CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться."
UNDEFINED_MESSAGE = "❌ Таких команд у нас нет. /start если потерялись."
# ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА
@@ -209,3 +210,12 @@ ADMIN_TICKET_WITHOUT_RW = (
)
ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу."
# ============================================================
# АДМИНСКАЯ ХУЙНЯ
# ============================================================
ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки"
ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?"
SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям."