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 ruff check --fix
black . 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 "[+] Running migrations..."
echo echo
alembic upgrade head alembic upgrade head

View File

@@ -1,6 +1,8 @@
from .ads import router as ads_router
from .buy import router as buy_router from .buy import router as buy_router
from .common import router as common_router
from .menus import router as menus_router from .menus import router as menus_router
from .referals import router as referal_router from .referals import router as referal_router
from .support import router as support_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 F.from_user.is_bot == False, # noqa: E712
~Command("close"), ~Command("close"),
~Command("refpay"), ~Command("refpay"),
F.text,
) )
async def handle_admin_message( async def handle_admin_message(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO 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) 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): def devices_selector(current: int = settings.min_devices, whitelists: bool = False):
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()

View File

@@ -6,6 +6,12 @@ from schemas.users import NewUserDTO
class UserRepository: 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: async def get_user_by_id(self, session: AsyncSession, user_id: int) -> User | None:
stmt = select(User).where(User.id == user_id) stmt = select(User).where(User.id == user_id)
result = await session.execute(stmt) 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 import Bot
from aiogram.types import Message, ReactionTypeEmoji, User as TelegramUser from aiogram.types import Message, ReactionTypeEmoji, User as TelegramUser
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
@@ -21,6 +23,8 @@ from texts import (
TICKET_STATUS_EMOJIS, TICKET_STATUS_EMOJIS,
) )
logger = logging.getLogger(__name__)
class UserServiceError(Exception): ... class UserServiceError(Exception): ...
@@ -261,3 +265,16 @@ class UserService:
ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id) 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) 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 = "🌑 Сессия сброшена. Данные могли обновиться." CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться."
UNDEFINED_MESSAGE = "❌ Таких команд у нас нет. /start если потерялись."
# ============================================================ # ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА # РЕФЕРАЛЬНАЯ СИСТЕМА
@@ -209,3 +210,12 @@ ADMIN_TICKET_WITHOUT_RW = (
) )
ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу." ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу."
# ============================================================
# АДМИНСКАЯ ХУЙНЯ
# ============================================================
ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки"
ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?"
SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям."