Files
Solo_bot/handlers/admin/sender/sender_handler.py
T
2026-04-22 01:17:10 +00:00

738 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
from datetime import datetime, timezone
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import API_TOKEN
from core.executor import run_io, should_run_heavy_tasks_separately
from database import save_blocked_user_ids
from database.models import Server
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
create_scheduled_broadcast,
get_scheduled_broadcast,
list_scheduled_broadcasts,
mark_scheduled_broadcast_failed,
mark_scheduled_broadcast_sent,
start_scheduled_broadcast,
update_scheduled_broadcast,
)
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import (
AdminSenderCallback,
ScheduledBroadcastCallback,
build_broadcast_preview_kb,
build_clusters_kb,
build_scheduled_broadcast_detail_kb,
build_scheduled_broadcasts_list_kb,
build_sender_kb,
)
from .scheduled_service import (
execute_scheduled_broadcast,
format_moscow_datetime,
parse_moscow_datetime_input,
prepare_broadcast_payload,
)
from .sender_service import BroadcastService, run_broadcast_in_thread
from .sender_states import AdminSender
from .sender_utils import get_recipients, parse_message_buttons
def _broadcast_progress_text(completed: int, total: int, sent: int, failed: int) -> str:
"""Формирует текст статус-бара рассылки."""
if total <= 0:
pct = 0
bar_filled = 0
else:
pct = min(100, int(100 * completed / total))
bar_filled = min(10, int(10 * completed / total))
bar = "" * bar_filled + "" * (10 - bar_filled)
return f"📤 <b>Рассылка...</b>\n\n[{bar}] <b>{pct}%</b> ({completed}/{total})\n{sent}{failed}"
def _compose_message_text() -> str:
return (
"✍️ Введите текст сообщения для рассылки\n\n"
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
"Вы можете отправить:\n"
"• Только <b>текст</b>\n"
"• Только <b>картинку</b>\n"
"• <b>Текст + картинку</b>\n"
"• <b>Сообщение + кнопки</b> (см. формат ниже)\n\n"
"<b>📋 Пример формата кнопок:</b>\n"
"<code>Ваше сообщение</code>\n\n"
"<code>BUTTONS:</code>\n"
'<code>{"text": "👤 Личный кабинет", "callback": "profile"}</code>\n'
'<code>{"text": " Купить подписку", "callback": "buy"}</code>\n'
'<code>{"text": "🎁 Забрать купон", "url": "https://t.me/cupons"}</code>\n'
'<code>{"text": "📢 Канал", "url": "https://t.me/channel"}</code>'
)
def _scheduled_status_label(status: str) -> str:
mapping = {
"scheduled": "Запланирована",
"running": "Отправляется",
"sent": "Отправлена",
"cancelled": "Отменена",
"failed": "Ошибка",
"draft": "Черновик",
}
return mapping.get(status, status)
def _truncate_text(value: str, limit: int = 500) -> str:
text = (value or "").strip()
if len(text) <= limit:
return text
return text[: limit - 1] + ""
def _scheduled_broadcast_text(item) -> str:
target = item.send_to
if item.cluster_name:
target = f"{target} ({item.cluster_name})"
lines = [
"🗓 <b>Запланированная рассылка</b>",
"",
f"🆔 <code>{item.id}</code>",
f"📌 <b>Статус:</b> {_scheduled_status_label(item.status)}",
f"🕒 <b>Время:</b> {format_moscow_datetime(item.scheduled_for) or '-'}",
f"👥 <b>Аудитория:</b> {target}",
f"🖼 <b>Фото:</b> {'Да' if item.photo else 'Нет'}",
f"⌨️ <b>Кнопки:</b> {'Да' if item.keyboard_json else 'Нет'}",
"",
"<b>Текст:</b>",
_truncate_text(item.text or ""),
]
if item.error_text:
lines.extend(["", f"⚠️ <b>Ошибка:</b> {_truncate_text(item.error_text, 250)}"])
if item.stats_json:
stats = item.stats_json.get("stats") or {}
lines.extend([
"",
f"✅ <b>Доставлено:</b> {stats.get('success_count', 0)}",
f"❌ <b>Не доставлено:</b> {stats.get('failed_count', 0)}",
])
return "\n".join(lines)
def _scheduled_broadcasts_list_text(items: list, page: int) -> str:
if not items:
return "🗓 <b>Запланированных рассылок пока нет.</b>"
lines = ["🗓 <b>Запланированные рассылки</b>\n", f"Страница: <b>{page + 1}</b>\n"]
for item in items:
target = item.send_to if not item.cluster_name else f"{item.send_to}/{item.cluster_name}"
lines.append(
f"• <code>{item.id[:8]}</code> | {format_moscow_datetime(item.scheduled_for) or '-'} | "
f"{_scheduled_status_label(item.status)} | {target}"
)
return "\n".join(lines)
def _broadcast_result_text(recipients: int, stats: dict) -> str:
duration_minutes = int(stats["total_duration"] // 60)
duration_seconds = int(stats["total_duration"] % 60)
duration_str = (
f"{duration_minutes} мин {duration_seconds} сек" if duration_minutes > 0 else f"{duration_seconds} сек"
)
return (
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {recipients}\n"
f"✅ <b>Доставлено:</b> {stats['success_count']}\n"
f"❌ <b>Не доставлено:</b> {stats['failed_count']}\n"
f"🚫 <b>Заблокировавших бота:</b> {stats['blocked_users']}\n\n"
f"⏱️ <b>Время выполнения:</b> {duration_str}\n"
f"⚡ <b>Средняя скорость:</b> {stats['avg_speed']:.1f} сообщений/сек"
)
from filters.admin import HasPermission
from filters.permissions import PERM_BROADCASTING
router = Router()
router.callback_query.filter(HasPermission(PERM_BROADCASTING))
router.message.filter(HasPermission(PERM_BROADCASTING))
@router.callback_query(
AdminPanelCallback.filter(F.action == "sender"),
IsAdminFilter(),
)
async def handle_sender(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
try:
await callback_query.message.edit_text(
text="✍️ Выберите группу пользователей для рассылки:",
reply_markup=build_sender_kb(),
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.debug("[Sender] Сообщение не изменено")
else:
raise
@router.callback_query(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_cluster_select(callback_query: CallbackQuery, session: AsyncSession, state: FSMContext):
result = await session.execute(select(Server.cluster_name).distinct())
clusters = result.mappings().all()
data = await state.get_data()
text = "✍️ Выберите кластер для рассылки сообщений:"
if data.get("edit_action") == "audience":
text = "✍️ Выберите новый кластер для запланированной рассылки:"
await callback_query.message.answer(
text,
reply_markup=build_clusters_kb(clusters),
)
@router.callback_query(
AdminSenderCallback.filter(F.type != "cluster-select"),
IsAdminFilter(),
)
async def handle_broadcast_type(
callback_query: CallbackQuery,
callback_data: AdminSenderCallback,
state: FSMContext,
session: AsyncSession,
):
data = await state.get_data()
if data.get("edit_action") == "audience":
broadcast_id = data.get("edit_broadcast_id")
page = int(data.get("edit_page", 0))
item = await get_scheduled_broadcast(session, broadcast_id)
if item is None:
await state.clear()
await callback_query.message.edit_text(
"❌ Запланированная рассылка не найдена.",
reply_markup=build_admin_back_kb("sender"),
)
return
try:
prepared = prepare_broadcast_payload(
send_to=callback_data.type,
text=item.text,
photo=item.photo,
cluster_name=callback_data.data,
workers=item.workers,
messages_per_second=item.messages_per_second,
)
except ValueError as exc:
await callback_query.message.edit_text(str(exc), reply_markup=build_admin_back_kb("sender"))
return
updated = await update_scheduled_broadcast(
session,
broadcast_id,
send_to=prepared["send_to"],
cluster_name=prepared["cluster_name"],
error_text=None,
)
await state.clear()
if updated is None:
await callback_query.message.edit_text(
"❌ Эту рассылку уже нельзя изменить.",
reply_markup=build_admin_back_kb("sender"),
)
return
await callback_query.message.edit_text(
_scheduled_broadcast_text(updated),
reply_markup=build_scheduled_broadcast_detail_kb(updated, page=page),
)
return
await callback_query.message.edit_text(
text=_compose_message_text(),
reply_markup=build_admin_back_kb("sender"),
)
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
await state.set_state(AdminSender.waiting_for_message)
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session: AsyncSession):
original_text = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else None
clean_text, keyboard = parse_message_buttons(original_text)
max_len = 1024 if photo else 4096
if len(clean_text) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
data = await state.get_data()
send_to = data.get("type", "all")
cluster_name = data.get("cluster_name")
_, user_count = await get_recipients(session, send_to, cluster_name)
if keyboard:
try:
keyboard_dict = keyboard.model_dump()
InlineKeyboardMarkup.model_validate(keyboard_dict)
except Exception as e:
await message.answer(
f"❌ <b>Ошибка в клавиатуре!</b>\n\n"
f"Не удалось сохранить клавиатуру из указанных кнопок.\n"
f"Ошибка: {str(e)}\n\n"
f"Пожалуйста, проверьте формат кнопок и попробуйте снова.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
await state.set_state(AdminSender.preview)
if photo:
await message.answer_photo(photo=photo, caption=clean_text, parse_mode="HTML", reply_markup=keyboard)
else:
await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
await message.answer(
f"👀 Это предпросмотр рассылки.\n👥 Количество получателей: <b>{user_count}</b>\n\nОтправить?",
reply_markup=build_broadcast_preview_kb(),
)
@router.callback_query(F.data == "send_broadcast", IsAdminFilter())
async def handle_broadcast_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
data = await state.get_data()
text_message = data.get("text")
photo = data.get("photo")
keyboard_data = data.get("keyboard")
send_to = data.get("type", "all")
cluster_name = data.get("cluster_name")
keyboard = None
if keyboard_data:
try:
keyboard = InlineKeyboardMarkup.model_validate(keyboard_data)
except Exception as e:
logger.error(f"[Sender] Ошибка восстановления клавиатуры: {e}")
await callback_query.message.edit_text(
f"❌ <b>Ошибка восстановления клавиатуры!</b>\n\n"
f"Не удалось восстановить клавиатуру из сохраненных данных.\n"
f"Ошибка: {str(e)}\n\n"
f"Пожалуйста, создайте рассылку заново.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
tg_ids, total_users = await get_recipients(session, send_to, cluster_name)
if not tg_ids:
await callback_query.message.edit_text(
"⚠️ Не найдено получателей для рассылки.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
status_message = callback_query.message
total_users_for_bar = len(tg_ids)
await status_message.edit_text(
_broadcast_progress_text(0, total_users_for_bar, 0, 0),
)
bot = callback_query.bot
state_keyboard_data = data.get("keyboard")
if should_run_heavy_tasks_separately():
main_loop = asyncio.get_running_loop()
async def _edit_progress(completed: int, total: int, sent: int, failed: int) -> None:
text = _broadcast_progress_text(completed, total, sent, failed)
try:
await bot.edit_message_text(
chat_id=status_message.chat.id,
message_id=status_message.message_id,
text=text,
)
except TelegramBadRequest as e:
if "message is not modified" not in str(e).lower():
logger.debug(f"[Sender] Обновление прогресса: {e}")
def progress_cb(completed: int, total: int, sent: int, failed: int) -> None:
main_loop.call_soon_threadsafe(
lambda c=completed, t=total, s=sent, f=failed: asyncio.ensure_future(
_edit_progress(c, t, s, f), loop=main_loop
)
)
stats = await run_io(
run_broadcast_in_thread,
API_TOKEN,
tg_ids,
text_message,
photo,
state_keyboard_data,
progress_cb,
)
if stats.get("blocked_user_ids"):
try:
await save_blocked_user_ids(session, stats["blocked_user_ids"])
except Exception as e:
logger.error(f"❌ Ошибка при сохранении заблокированных пользователей: {e}")
else:
messages = []
for tg_id in tg_ids:
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
messages.append(message_data)
async def on_progress(completed: int, total: int, sent: int, failed: int) -> None:
text = _broadcast_progress_text(completed, total, sent, failed)
try:
await bot.edit_message_text(
chat_id=status_message.chat.id,
message_id=status_message.message_id,
text=text,
)
except TelegramBadRequest as e:
if "message is not modified" not in str(e).lower():
logger.debug(f"[Sender] Обновление прогресса: {e}")
broadcast_service = BroadcastService(bot=bot, session=session, messages_per_second=30)
stats = await broadcast_service.broadcast(
messages,
workers=5,
on_progress=on_progress,
progress_interval=2.0,
progress_every=200,
)
await callback_query.message.answer(
text=_broadcast_result_text(total_users, stats),
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
@router.callback_query(F.data == "schedule_broadcast", IsAdminFilter())
async def handle_schedule_broadcast(callback_query: CallbackQuery, state: FSMContext):
data = await state.get_data()
if not data.get("text"):
await callback_query.message.edit_text(
"⚠️ Не найден черновик рассылки. Создайте его заново.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
await callback_query.message.edit_text(
"🕒 Введите дату и время отправки по Москве в формате <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>.",
reply_markup=build_admin_back_kb("sender"),
)
await state.set_state(AdminSender.waiting_for_schedule_datetime)
@router.message(AdminSender.waiting_for_schedule_datetime, IsAdminFilter())
async def handle_schedule_datetime_input(message: Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
try:
scheduled_for = parse_moscow_datetime_input(message.text or "")
except ValueError as exc:
await message.answer(str(exc))
return
if scheduled_for <= datetime.now(timezone.utc):
await message.answer("⚠️ Время отправки должно быть в будущем.")
return
created = await create_scheduled_broadcast(
session,
created_by_tg_id=message.from_user.id if message.from_user else None,
send_to=data.get("type", "all"),
cluster_name=data.get("cluster_name"),
text=data.get("text", ""),
photo=data.get("photo"),
keyboard_json=data.get("keyboard"),
scheduled_for=scheduled_for,
workers=5,
messages_per_second=30,
)
await state.clear()
await message.answer(
_scheduled_broadcast_text(created),
reply_markup=build_scheduled_broadcast_detail_kb(created, page=0),
)
@router.callback_query(ScheduledBroadcastCallback.filter(F.action == "list"), IsAdminFilter())
async def handle_scheduled_broadcasts_list(
callback_query: CallbackQuery,
callback_data: ScheduledBroadcastCallback,
session: AsyncSession,
state: FSMContext,
):
await state.clear()
page = max(0, int(callback_data.page or 0))
items = await list_scheduled_broadcasts(session, limit=5, offset=page * 5)
await callback_query.message.edit_text(
_scheduled_broadcasts_list_text(items, page),
reply_markup=build_scheduled_broadcasts_list_kb(items, page=page),
)
@router.callback_query(ScheduledBroadcastCallback.filter(F.action == "view"), IsAdminFilter())
async def handle_scheduled_broadcast_view(
callback_query: CallbackQuery,
callback_data: ScheduledBroadcastCallback,
session: AsyncSession,
):
item = await get_scheduled_broadcast(session, callback_data.broadcast_id)
if item is None:
await callback_query.message.edit_text(
"❌ Запланированная рассылка не найдена.",
reply_markup=build_admin_back_kb("sender"),
)
return
await callback_query.message.edit_text(
_scheduled_broadcast_text(item),
reply_markup=build_scheduled_broadcast_detail_kb(item, page=callback_data.page),
)
@router.callback_query(ScheduledBroadcastCallback.filter(F.action == "edit_message"), IsAdminFilter())
async def handle_scheduled_broadcast_edit_message(
callback_query: CallbackQuery,
callback_data: ScheduledBroadcastCallback,
session: AsyncSession,
state: FSMContext,
):
item = await get_scheduled_broadcast(session, callback_data.broadcast_id)
if item is None:
await callback_query.message.edit_text(
"❌ Запланированная рассылка не найдена.",
reply_markup=build_admin_back_kb("sender"),
)
return
await state.update_data(edit_broadcast_id=item.id, edit_page=callback_data.page)
await state.set_state(AdminSender.waiting_for_edit_message)
await callback_query.message.edit_text(
"✏️ Отправьте новое сообщение для этой рассылки.",
reply_markup=build_admin_back_kb("sender"),
)
@router.message(AdminSender.waiting_for_edit_message, IsAdminFilter())
async def handle_scheduled_broadcast_edit_message_input(
message: Message,
state: FSMContext,
session: AsyncSession,
):
data = await state.get_data()
broadcast_id = data.get("edit_broadcast_id")
page = int(data.get("edit_page", 0))
item = await get_scheduled_broadcast(session, broadcast_id)
if item is None:
await state.clear()
await message.answer("❌ Запланированная рассылка не найдена.", reply_markup=build_admin_back_kb("sender"))
return
original_text = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else item.photo
try:
prepared = prepare_broadcast_payload(
send_to=item.send_to,
text=original_text,
photo=photo,
cluster_name=item.cluster_name,
workers=item.workers,
messages_per_second=item.messages_per_second,
)
except ValueError as exc:
await message.answer(str(exc))
return
updated = await update_scheduled_broadcast(
session,
broadcast_id,
text=prepared["text"],
photo=prepared["photo"],
keyboard_json=prepared["keyboard_json"],
error_text=None,
)
await state.clear()
if updated is None:
await message.answer("❌ Эту рассылку уже нельзя изменить.", reply_markup=build_admin_back_kb("sender"))
return
await message.answer(
_scheduled_broadcast_text(updated),
reply_markup=build_scheduled_broadcast_detail_kb(updated, page=page),
)
@router.callback_query(ScheduledBroadcastCallback.filter(F.action == "edit_time"), IsAdminFilter())
async def handle_scheduled_broadcast_edit_time(
callback_query: CallbackQuery,
callback_data: ScheduledBroadcastCallback,
session: AsyncSession,
state: FSMContext,
):
item = await get_scheduled_broadcast(session, callback_data.broadcast_id)
if item is None:
await callback_query.message.edit_text(
"❌ Запланированная рассылка не найдена.",
reply_markup=build_admin_back_kb("sender"),
)
return
await state.update_data(edit_broadcast_id=item.id, edit_page=callback_data.page)
await state.set_state(AdminSender.waiting_for_edit_schedule_datetime)
await callback_query.message.edit_text(
"🕒 Введите новое время отправки по Москве в формате <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>.",
reply_markup=build_admin_back_kb("sender"),
)
@router.message(AdminSender.waiting_for_edit_schedule_datetime, IsAdminFilter())
async def handle_scheduled_broadcast_edit_time_input(
message: Message,
state: FSMContext,
session: AsyncSession,
):
data = await state.get_data()
broadcast_id = data.get("edit_broadcast_id")
page = int(data.get("edit_page", 0))
try:
scheduled_for = parse_moscow_datetime_input(message.text or "")
except ValueError as exc:
await message.answer(str(exc))
return
if scheduled_for <= datetime.now(timezone.utc):
await message.answer("⚠️ Время отправки должно быть в будущем.")
return
updated = await update_scheduled_broadcast(
session,
broadcast_id,
scheduled_for=scheduled_for,
status="scheduled",
error_text=None,
)
await state.clear()
if updated is None:
await message.answer("❌ Эту рассылку уже нельзя изменить.", reply_markup=build_admin_back_kb("sender"))
return
await message.answer(
_scheduled_broadcast_text(updated),
reply_markup=build_scheduled_broadcast_detail_kb(updated, page=page),
)
@router.callback_query(ScheduledBroadcastCallback.filter(F.action == "edit_audience"), IsAdminFilter())
async def handle_scheduled_broadcast_edit_audience(
callback_query: CallbackQuery,
callback_data: ScheduledBroadcastCallback,
session: AsyncSession,
state: FSMContext,
):
item = await get_scheduled_broadcast(session, callback_data.broadcast_id)
if item is None:
await callback_query.message.edit_text(
"❌ Запланированная рассылка не найдена.",
reply_markup=build_admin_back_kb("sender"),
)
return
await state.update_data(edit_broadcast_id=item.id, edit_page=callback_data.page, edit_action="audience")
await callback_query.message.edit_text(
"👥 Выберите новую аудиторию для рассылки:",
reply_markup=build_sender_kb(include_scheduled=False),
)
@router.callback_query(ScheduledBroadcastCallback.filter(F.action == "cancel"), IsAdminFilter())
async def handle_scheduled_broadcast_cancel(
callback_query: CallbackQuery,
callback_data: ScheduledBroadcastCallback,
session: AsyncSession,
state: FSMContext,
):
await state.clear()
item = await cancel_scheduled_broadcast(session, callback_data.broadcast_id)
if item is None:
await callback_query.message.edit_text(
"❌ Эту рассылку уже нельзя отменить.",
reply_markup=build_admin_back_kb("sender"),
)
return
await callback_query.message.edit_text(
_scheduled_broadcast_text(item),
reply_markup=build_scheduled_broadcast_detail_kb(item, page=callback_data.page),
)
@router.callback_query(ScheduledBroadcastCallback.filter(F.action == "send_now"), IsAdminFilter())
async def handle_scheduled_broadcast_send_now(
callback_query: CallbackQuery,
callback_data: ScheduledBroadcastCallback,
session: AsyncSession,
state: FSMContext,
):
await state.clear()
item = await start_scheduled_broadcast(session, callback_data.broadcast_id)
if item is None:
await callback_query.message.edit_text(
"❌ Эту рассылку уже нельзя отправить сейчас.",
reply_markup=build_admin_back_kb("sender"),
)
return
await callback_query.message.edit_text("⏳ Запускаю рассылку...")
try:
result = await execute_scheduled_broadcast(item, bot=callback_query.bot)
except Exception as exc:
failed_item = await mark_scheduled_broadcast_failed(session, callback_data.broadcast_id, str(exc))
await callback_query.message.edit_text(
_scheduled_broadcast_text(failed_item),
reply_markup=build_scheduled_broadcast_detail_kb(failed_item, page=callback_data.page),
)
return
if result.get("success"):
updated = await mark_scheduled_broadcast_sent(session, callback_data.broadcast_id, result)
await callback_query.message.edit_text(
_scheduled_broadcast_text(updated),
reply_markup=build_scheduled_broadcast_detail_kb(updated, page=callback_data.page),
)
stats = result.get("stats") or {
"total_duration": 0,
"success_count": 0,
"failed_count": 0,
"blocked_users": 0,
"avg_speed": 0,
}
await callback_query.message.answer(
_broadcast_result_text(result.get("recipients", 0), stats),
reply_markup=build_admin_back_kb("sender"),
)
return
failed_item = await mark_scheduled_broadcast_failed(
session,
callback_data.broadcast_id,
result.get("message", "Broadcast failed"),
)
await callback_query.message.edit_text(
_scheduled_broadcast_text(failed_item),
reply_markup=build_scheduled_broadcast_detail_kb(failed_item, page=callback_data.page),
)
@router.callback_query(F.data == "cancel_broadcast", IsAdminFilter())
async def handle_broadcast_cancel(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.edit_text(
"🚫 Рассылка отменена.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()