feat: add broadcast preview with confirmation / improve referral bonuses (percent and fixed RUB) / unify personal message formatting

This commit is contained in:
Capybara-z
2025-06-19 00:11:35 +03:00
parent d532f1211c
commit 0abb83aae1
3 changed files with 165 additions and 38 deletions
+65 -30
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.types import CallbackQuery, Message, InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,6 +19,7 @@ router = Router()
class AdminSender(StatesGroup):
waiting_for_message = State()
preview = State()
@router.callback_query(
@@ -40,7 +41,14 @@ async def handle_sender_callback_text(
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
):
await callback_query.message.edit_text(
text="✍️ Введите текст сообщения для рассылки:",
text=(
"✍️ Введите текст сообщения для рассылки\n\n"
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
"Вы можете отправить:\n"
"• Только <b>текст</b>\n"
"• Только <b>картинку</b>\n"
"• <b>Текст + картинку</b>"
),
reply_markup=build_admin_back_kb("sender"),
)
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
@@ -62,31 +70,51 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSe
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session):
text_message = message.html_text if message.text else None
async def handle_message_input(message: Message, state: FSMContext):
text_message = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else None
photo_url = (
message.caption
if message.photo and message.caption and message.caption.startswith("http")
else None
)
if not text_message and message.caption:
text_message = message.caption
if not text_message and not photo and not photo_url:
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
max_len = 1024 if photo else 4096
if len(text_message) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\n"
f"Максимум: <b>{max_len}</b> символов, сейчас: <b>{len(text_message)}</b>.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
state_data = await state.get_data()
send_to = state_data.get("type", "all")
await state.update_data(text=text_message, photo=photo)
await state.set_state(AdminSender.preview)
if photo:
await message.answer_photo(photo=photo, caption=text_message, parse_mode="HTML")
else:
await message.answer(text=text_message, parse_mode="HTML")
await message.answer(
"👀 Это предпросмотр рассылки.\nОтправить?",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_message"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_message"),
]
]),
)
@router.callback_query(F.data == "send_message", IsAdminFilter())
async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
data = await state.get_data()
text_message = data.get("text")
photo = data.get("photo")
send_to = data.get("type", "all")
cluster_name = data.get("cluster_name")
now_ms = int(datetime.utcnow().timestamp() * 1000)
query = None
if send_to == "subscribed":
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
@@ -101,20 +129,16 @@ async def handle_message_input(message: Message, state: FSMContext, session):
)
)
query = select(distinct(subquery.c.tg_id))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery))
elif send_to == "cluster":
cluster_name = state_data.get("cluster_name")
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
)
elif send_to == "hotleads":
subquery = select(Key.tg_id)
query = (
@@ -123,7 +147,6 @@ async def handle_message_input(message: Message, state: FSMContext, session):
.where(Payment.status == "success")
.where(~User.tg_id.in_(subquery))
)
else:
query = select(distinct(User.tg_id))
@@ -133,27 +156,30 @@ async def handle_message_input(message: Message, state: FSMContext, session):
total_users = len(tg_ids)
success_count = 0
await message.answer(
await callback_query.message.edit_text(
f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
)
for tg_id in tg_ids:
try:
if photo or photo_url:
await message.bot.send_photo(
if photo:
await callback_query.bot.send_photo(
chat_id=tg_id,
photo=photo or photo_url,
photo=photo,
caption=text_message,
parse_mode="HTML",
)
else:
await message.bot.send_message(
chat_id=tg_id, text=text_message, parse_mode="HTML"
await callback_query.bot.send_message(
chat_id=tg_id,
text=text_message,
parse_mode="HTML",
)
success_count += 1
except Exception as e:
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
await message.answer(
await callback_query.message.answer(
text=(
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
@@ -163,3 +189,12 @@ async def handle_message_input(message: Message, state: FSMContext, session):
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
@router.callback_query(F.data == "cancel_message", IsAdminFilter())
async def handle_send_cancel(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.edit_text(
"🚫 Рассылка отменена.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
+71 -5
View File
@@ -8,7 +8,7 @@ from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -77,6 +77,7 @@ class UserEditorState(StatesGroup):
waiting_for_balance = State()
waiting_for_expiry_time = State()
waiting_for_message_text = State()
preview_message = State()
selecting_cluster = State()
selecting_duration = State()
selecting_country = State()
@@ -315,7 +316,14 @@ async def handle_send_message(
tg_id = callback_data.tg_id
await callback_query.message.edit_text(
text="✉️ Введите текст сообщения, которое вы хотите отправить пользователю:",
text=(
"✉️ Введите текст сообщения, которое вы хотите отправить пользователю:\n\n"
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
"Вы можете отправить:\n"
"• Только <b>текст</b>\n"
"• Только <b>картинку</b>\n"
"• <b>Текст + картинку</b>"
),
reply_markup=build_editor_kb(tg_id),
)
@@ -327,18 +335,76 @@ async def handle_send_message(
async def handle_message_text_input(message: Message, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
text_message = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else None
try:
await message.bot.send_message(chat_id=tg_id, text=message.text)
max_len = 1024 if photo else 4096
if len(text_message) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\n"
f"Максимум: <b>{max_len}</b> символов, сейчас: <b>{len(text_message)}</b>.",
reply_markup=build_editor_kb(tg_id),
)
await state.clear()
return
await state.update_data(text=text_message, photo=photo)
await state.set_state(UserEditorState.preview_message)
if photo:
await message.answer_photo(photo=photo, caption=text_message, parse_mode="HTML")
else:
await message.answer(text=text_message, parse_mode="HTML")
await message.answer(
"👀 Это предпросмотр сообщения. Отправить?",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_user_message"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_user_message"),
]
]),
)
@router.callback_query(F.data == "send_user_message", IsAdminFilter(), UserEditorState.preview_message)
async def handle_send_user_message(callback_query: CallbackQuery, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
text_message = data.get("text")
photo = data.get("photo")
try:
if photo:
await callback_query.bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=text_message,
parse_mode="HTML",
)
else:
await callback_query.bot.send_message(
chat_id=tg_id,
text=text_message,
parse_mode="HTML",
)
await callback_query.message.edit_text(
text="✅ Сообщение успешно отправлено.", reply_markup=build_editor_kb(tg_id)
)
except Exception as e:
await message.answer(
await callback_query.message.edit_text(
text=f"❌ Не удалось отправить сообщение: {e}",
reply_markup=build_editor_kb(tg_id),
)
await state.clear()
@router.callback_query(F.data == "cancel_user_message", IsAdminFilter(), UserEditorState.preview_message)
async def handle_cancel_user_message(callback_query: CallbackQuery, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
await callback_query.message.edit_text(
text="🚫 Отправка сообщения отменена.", reply_markup=build_editor_kb(tg_id)
)
await state.clear()
+29 -3
View File
@@ -18,7 +18,7 @@ from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import ADMIN_ID, INLINE_MODE, TOP_REFERRAL_BUTTON, TRIAL_CONFIG, USERNAME_BOT
from config import ADMIN_ID, INLINE_MODE, TOP_REFERRAL_BUTTON, TRIAL_CONFIG, USERNAME_BOT, REFERRAL_BONUS_PERCENTAGES
from database import (
add_referral,
add_user,
@@ -34,10 +34,11 @@ from handlers.texts import (
REFERRAL_OFFERS,
REFERRAL_SUCCESS_MSG,
TOP_REFERRALS_TEXT,
INVITE_MESSAGE_TEMPLATE,
)
from logger import logger
from .texts import get_referral_link, invite_message_send
from .texts import get_referral_link
from .utils import edit_or_send_message, format_days
router = Router()
@@ -57,7 +58,32 @@ async def invite_handler(
referral_link = get_referral_link(chat_id)
referral_stats = await get_referral_stats(session, chat_id)
invite_message = invite_message_send(referral_link, referral_stats)
bonuses_lines = []
for level, value in REFERRAL_BONUS_PERCENTAGES.items():
if isinstance(value, float):
bonuses_lines.append(f"{level} уровень: 🌟 {int(value * 100)}% бонуса")
else:
bonuses_lines.append(f"{level} уровень: 💸 {int(value)}₽ бонуса")
bonuses_block = "\n".join(bonuses_lines)
details_lines = []
for level, stats in referral_stats['referrals_by_level'].items():
bonus_value = REFERRAL_BONUS_PERCENTAGES.get(level)
if isinstance(bonus_value, float):
bonus_str = f"{int(bonus_value * 100)}%"
else:
bonus_str = f"{int(bonus_value)}"
details_lines.append(f"🔹 Уровень {level}: {stats['total']} - {bonus_str}")
details_block = "\n".join(details_lines)
invite_message = INVITE_MESSAGE_TEMPLATE.format(
referral_link=referral_link,
bonuses_block=bonuses_block,
total_referrals=referral_stats['total_referrals'],
details_block=details_block,
total_referral_bonus=referral_stats['total_referral_bonus'],
)
image_path = os.path.join("img", "pic_invite.jpg")
builder = InlineKeyboardBuilder()