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
|
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
|
||||||
|
|||||||
@@ -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
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
|
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
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)
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
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 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
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 = "🌑 Сессия сброшена. Данные могли обновиться."
|
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} пользователям."
|
||||||
|
|||||||
Reference in New Issue
Block a user