feat: add broadcast preview with confirmation / improve referral bonuses (percent and fixed RUB) / unify personal message formatting
This commit is contained in:
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user