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:
@@ -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
|
||||
|
||||
@@ -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
69
handlers/ads.py
Normal 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
20
handlers/common.py
Normal 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()
|
||||
@@ -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
0
keyboards/__init__.py
Normal file
9
keyboards/admins.py
Normal file
9
keyboards/admins.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
9
schemas/promotions.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewPromotionContext:
|
||||
creator_id: int
|
||||
message: Message | None = None
|
||||
@@ -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
5
states/admins.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class AdminStorage(StatesGroup):
|
||||
promotion_msg = State()
|
||||
10
texts.py
10
texts.py
@@ -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} пользователям."
|
||||
|
||||
Reference in New Issue
Block a user