import html import logging import asyncio from datetime import datetime, timedelta from typing import Optional from aiogram import Dispatcher, types, F from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import InterfaceError from sqlalchemy import select, func, and_, or_ from app.config import settings from app.states import AdminStates from app.database.models import ( User, UserStatus, Subscription, SubscriptionStatus, BroadcastHistory, Tariff, ) from app.database.database import AsyncSessionLocal from app.keyboards.admin import ( get_admin_messages_keyboard, get_broadcast_target_keyboard, get_custom_criteria_keyboard, get_broadcast_history_keyboard, get_admin_pagination_keyboard, get_broadcast_media_keyboard, get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS, get_broadcast_button_config, get_broadcast_button_labels, get_pinned_message_keyboard ) from app.localization.texts import get_texts from app.database.crud.user import get_users_list from app.database.crud.subscription import get_expiring_subscriptions from app.database.crud.tariff import get_all_tariffs from app.utils.decorators import admin_required, error_handler from app.utils.miniapp_buttons import build_miniapp_or_callback_button from app.services.pinned_message_service import ( broadcast_pinned_message, get_active_pinned_message, set_active_pinned_message, unpin_active_pinned_message, ) logger = logging.getLogger(__name__) async def safe_edit_or_send_text( callback: types.CallbackQuery, text: str, reply_markup=None, parse_mode: str = "HTML" ): """ Безопасно редактирует сообщение или удаляет и отправляет новое. Нужно для случаев, когда текущее сообщение - медиа (фото/видео), которое нельзя отредактировать через edit_text. """ try: await callback.message.edit_text( text, reply_markup=reply_markup, parse_mode=parse_mode ) except TelegramBadRequest as e: if "there is no text in the message to edit" in str(e): # Сообщение - медиа без текста, удаляем и отправляем новое try: await callback.message.delete() except Exception: pass await callback.bot.send_message( chat_id=callback.message.chat.id, text=text, reply_markup=reply_markup, parse_mode=parse_mode ) else: raise BUTTON_ROWS = BROADCAST_BUTTON_ROWS DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS TEXT_MENU_MINIAPP_BUTTON_KEYS = { "balance", "referrals", "promocode", "connect", "subscription", } def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language) def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language) def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]: selected_buttons = selected_buttons or [] keyboard: list[list[types.InlineKeyboardButton]] = [] button_config_map = get_broadcast_button_config(language) for row in BUTTON_ROWS: row_buttons: list[types.InlineKeyboardButton] = [] for button_key in row: if button_key not in selected_buttons: continue button_config = button_config_map[button_key] if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS: row_buttons.append( build_miniapp_or_callback_button( text=button_config["text"], callback_data=button_config["callback"], ) ) else: row_buttons.append( types.InlineKeyboardButton( text=button_config["text"], callback_data=button_config["callback"] ) ) if row_buttons: keyboard.append(row_buttons) if not keyboard: return None return types.InlineKeyboardMarkup(inline_keyboard=keyboard) async def _persist_broadcast_result( db: AsyncSession, broadcast_history: BroadcastHistory, sent_count: int, failed_count: int, status: str, ) -> None: """Сохраняет результаты рассылки с повторной попыткой при обрыве соединения.""" broadcast_history.sent_count = sent_count broadcast_history.failed_count = failed_count broadcast_history.status = status broadcast_history.completed_at = datetime.utcnow() try: await db.commit() return except InterfaceError as error: logger.warning( "Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз", exc_info=error, ) await db.rollback() try: async with AsyncSessionLocal() as retry_session: retry_history = await retry_session.get(BroadcastHistory, broadcast_history.id) if not retry_history: logger.critical( "Не удалось найти запись BroadcastHistory #%s для повторной записи результатов", broadcast_history.id, ) return retry_history.sent_count = sent_count retry_history.failed_count = failed_count retry_history.status = status retry_history.completed_at = broadcast_history.completed_at await retry_session.commit() logger.info( "Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)", broadcast_history.id, ) except Exception as retry_error: logger.critical( "Не удалось сохранить результаты рассылки после восстановления подключения", exc_info=retry_error, ) @admin_required @error_handler async def show_messages_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession ): text = """ 📨 Управление рассылками Выберите тип рассылки: - Всем пользователям - рассылка всем активным пользователям - По подпискам - фильтрация по типу подписки - По критериям - настраиваемые фильтры - История - просмотр предыдущих рассылок ⚠️ Будьте осторожны с массовыми рассылками! """ await safe_edit_or_send_text( callback, text, reply_markup=get_admin_messages_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_pinned_message_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, ): await state.clear() pinned_message = await get_active_pinned_message(db) if pinned_message: content_preview = html.escape(pinned_message.content or "") last_updated = pinned_message.updated_at or pinned_message.created_at timestamp_text = last_updated.strftime("%d.%m.%Y %H:%M") if last_updated else "—" media_line = "" if pinned_message.media_type: media_label = "Фото" if pinned_message.media_type == "photo" else "Видео" media_line = f"📎 Медиа: {media_label}\n" position_line = ( "⬆️ Отправлять перед меню" if pinned_message.send_before_menu else "⬇️ Отправлять после меню" ) start_mode_line = ( "🔁 При каждом /start" if pinned_message.send_on_every_start else "🚫 Только один раз и при обновлении" ) body = ( "📌 Закрепленное сообщение\n\n" "📝 Текущий текст:\n" f"{content_preview}\n\n" f"{media_line}" f"{position_line}\n" f"{start_mode_line}\n" f"🕒 Обновлено: {timestamp_text}" ) else: body = ( "📌 Закрепленное сообщение\n\n" "Сообщение не задано. Отправьте новый текст, чтобы разослать и закрепить его у пользователей." ) await callback.message.edit_text( body, reply_markup=get_pinned_message_keyboard( db_user.language, send_before_menu=getattr(pinned_message, "send_before_menu", True), send_on_every_start=getattr(pinned_message, "send_on_every_start", True), ), parse_mode="HTML", ) await callback.answer() @admin_required @error_handler async def prompt_pinned_message_update( callback: types.CallbackQuery, db_user: User, state: FSMContext, ): await state.set_state(AdminStates.editing_pinned_message) await callback.message.edit_text( "✏️ Новое закрепленное сообщение\n\n" "Пришлите текст, фото или видео, которое нужно закрепить.\n" "Бот отправит его всем активным пользователям, открепит старое и закрепит новое без уведомлений.", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_pinned_message")] ]), parse_mode="HTML", ) await callback.answer() @admin_required @error_handler async def toggle_pinned_message_position( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, ): pinned_message = await get_active_pinned_message(db) if not pinned_message: await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True) return pinned_message.send_before_menu = not pinned_message.send_before_menu pinned_message.updated_at = datetime.utcnow() await db.commit() await show_pinned_message_menu(callback, db_user, db, state) @admin_required @error_handler async def toggle_pinned_message_start_mode( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, ): pinned_message = await get_active_pinned_message(db) if not pinned_message: await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True) return pinned_message.send_on_every_start = not pinned_message.send_on_every_start pinned_message.updated_at = datetime.utcnow() await db.commit() await show_pinned_message_menu(callback, db_user, db, state) @admin_required @error_handler async def delete_pinned_message( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, ): pinned_message = await get_active_pinned_message(db) if not pinned_message: await callback.answer("Закрепленное сообщение уже отсутствует", show_alert=True) return await callback.message.edit_text( "🗑️ Удаление закрепленного сообщения\n\n" "Подождите, пока бот открепит сообщение у пользователей...", parse_mode="HTML", ) unpinned_count, failed_count, deleted = await unpin_active_pinned_message( callback.bot, db, ) if not deleted: await callback.message.edit_text( "❌ Не удалось найти активное закрепленное сообщение для удаления", reply_markup=get_admin_messages_keyboard(db_user.language), parse_mode="HTML", ) await state.clear() return total = unpinned_count + failed_count await callback.message.edit_text( "✅ Закрепленное сообщение удалено\n\n" f"👥 Чатов обработано: {total}\n" f"✅ Откреплено: {unpinned_count}\n" f"⚠️ Ошибок: {failed_count}\n\n" "Новое сообщение можно задать кнопкой \"Обновить\".", reply_markup=get_admin_messages_keyboard(db_user.language), parse_mode="HTML", ) await state.clear() @admin_required @error_handler async def process_pinned_message_update( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): texts = get_texts(db_user.language) media_type: Optional[str] = None media_file_id: Optional[str] = None if message.photo: media_type = "photo" media_file_id = message.photo[-1].file_id elif message.video: media_type = "video" media_file_id = message.video.file_id pinned_text = message.html_text or message.caption_html or message.text or message.caption or "" if not pinned_text and not media_file_id: await message.answer( texts.t("ADMIN_PINNED_NO_CONTENT", "❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.") ) return try: pinned_message = await set_active_pinned_message( db, pinned_text, db_user.id, media_type=media_type, media_file_id=media_file_id, ) except ValueError as validation_error: await message.answer(f"❌ {validation_error}") return # Сообщение сохранено, спрашиваем о рассылке from app.keyboards.admin import get_pinned_broadcast_confirm_keyboard from app.states import AdminStates await message.answer( texts.t( "ADMIN_PINNED_SAVED_ASK_BROADCAST", "📌 Сообщение сохранено!\n\n" "Выберите, как доставить сообщение пользователям:\n\n" "• Разослать сейчас — отправит и закрепит у всех активных пользователей\n" "• Только при /start — пользователи увидят при следующем запуске бота", ), reply_markup=get_pinned_broadcast_confirm_keyboard(db_user.language, pinned_message.id), parse_mode="HTML", ) await state.set_state(AdminStates.confirming_pinned_broadcast) @admin_required @error_handler async def handle_pinned_broadcast_now( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): """Разослать закреплённое сообщение сейчас всем пользователям.""" texts = get_texts(db_user.language) # Получаем ID сообщения из callback_data pinned_message_id = int(callback.data.split(":")[1]) # Получаем сообщение из БД from sqlalchemy import select from app.database.models import PinnedMessage result = await db.execute( select(PinnedMessage).where(PinnedMessage.id == pinned_message_id) ) pinned_message = result.scalar_one_or_none() if not pinned_message: await callback.answer("❌ Сообщение не найдено", show_alert=True) await state.clear() return await callback.message.edit_text( texts.t("ADMIN_PINNED_SAVING", "📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей..."), parse_mode="HTML", ) sent_count, failed_count = await broadcast_pinned_message( callback.bot, db, pinned_message, ) total = sent_count + failed_count await callback.message.edit_text( texts.t( "ADMIN_PINNED_UPDATED", "✅ Закрепленное сообщение обновлено\n\n" "👥 Получателей: {total}\n" "✅ Отправлено: {sent}\n" "⚠️ Ошибок: {failed}", ).format(total=total, sent=sent_count, failed=failed_count), reply_markup=get_admin_messages_keyboard(db_user.language), parse_mode="HTML", ) await state.clear() @admin_required @error_handler async def handle_pinned_broadcast_skip( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): """Пропустить рассылку — пользователи увидят при /start.""" texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( "ADMIN_PINNED_SAVED_NO_BROADCAST", "✅ Закрепленное сообщение сохранено\n\n" "Рассылка не выполнена. Пользователи увидят сообщение при следующем вводе /start.", ), reply_markup=get_admin_messages_keyboard(db_user.language), parse_mode="HTML", ) await state.clear() @admin_required @error_handler async def show_broadcast_targets( callback: types.CallbackQuery, db_user: User, state: FSMContext ): await callback.message.edit_text( "🎯 Выбор целевой аудитории\n\n" "Выберите категорию пользователей для рассылки:", reply_markup=get_broadcast_target_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_tariff_filter( callback: types.CallbackQuery, db_user: User, db: AsyncSession ): """Показывает список тарифов для фильтрации рассылки.""" tariffs = await get_all_tariffs(db, include_inactive=False) if not tariffs: await callback.message.edit_text( "❌ Нет доступных тарифов\n\n" "Создайте тарифы в разделе управления тарифами.", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")] ]), parse_mode="HTML" ) await callback.answer() return # Получаем количество подписчиков на каждом тарифе tariff_counts = {} for tariff in tariffs: count_query = ( select(func.count(Subscription.id)) .where( Subscription.tariff_id == tariff.id, Subscription.status == SubscriptionStatus.ACTIVE.value, ) ) result = await db.execute(count_query) tariff_counts[tariff.id] = result.scalar() or 0 buttons = [] for tariff in tariffs: count = tariff_counts.get(tariff.id, 0) buttons.append([ types.InlineKeyboardButton( text=f"{tariff.name} ({count} чел.)", callback_data=f"broadcast_tariff_{tariff.id}" ) ]) buttons.append([types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")]) await callback.message.edit_text( "📦 Рассылка по тарифу\n\n" "Выберите тариф для рассылки пользователям с активной подпиской на этот тариф:", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_messages_history( callback: types.CallbackQuery, db_user: User, db: AsyncSession ): page = 1 if '_page_' in callback.data: page = int(callback.data.split('_page_')[1]) limit = 10 offset = (page - 1) * limit stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit) result = await db.execute(stmt) broadcasts = result.scalars().all() count_stmt = select(func.count(BroadcastHistory.id)) count_result = await db.execute(count_stmt) total_count = count_result.scalar() or 0 total_pages = (total_count + limit - 1) // limit if not broadcasts: text = """ 📋 История рассылок ❌ История рассылок пуста. Отправьте первую рассылку, чтобы увидеть её здесь. """ keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]] else: text = f"📋 История рассылок (страница {page}/{total_pages})\n\n" for broadcast in broadcasts: status_emoji = "✅" if broadcast.status == "completed" else "❌" if broadcast.status == "failed" else "⏳" success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0 message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text import html message_preview = html.escape(message_preview) text += f""" {status_emoji} {broadcast.created_at.strftime('%d.%m.%Y %H:%M')} 📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%) 🎯 Аудитория: {get_target_name(broadcast.target_type)} 👤 Админ: {broadcast.admin_name} 📝 Сообщение: {message_preview} ━━━━━━━━━━━━━━━━━━━━━━━ """ keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard await callback.message.edit_text( text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_custom_broadcast( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): stats = await get_users_statistics(db) text = f""" 📝 Рассылка по критериям 📊 Доступные фильтры: 👥 По регистрации: • Сегодня: {stats['today']} чел. • За неделю: {stats['week']} чел. • За месяц: {stats['month']} чел. 💼 По активности: • Активные сегодня: {stats['active_today']} чел. • Неактивные 7+ дней: {stats['inactive_week']} чел. • Неактивные 30+ дней: {stats['inactive_month']} чел. 🔗 По источнику: • Через рефералов: {stats['referrals']} чел. • Прямая регистрация: {stats['direct']} чел. Выберите критерий для фильтрации: """ await callback.message.edit_text( text, reply_markup=get_custom_criteria_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def select_custom_criteria( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): criteria = callback.data.replace('criteria_', '') criteria_names = { "today": "Зарегистрированные сегодня", "week": "Зарегистрированные за неделю", "month": "Зарегистрированные за месяц", "active_today": "Активные сегодня", "inactive_week": "Неактивные 7+ дней", "inactive_month": "Неактивные 30+ дней", "referrals": "Пришедшие через рефералов", "direct": "Прямая регистрация" } user_count = await get_custom_users_count(db, criteria) await state.update_data(broadcast_target=f"custom_{criteria}") await callback.message.edit_text( f"📨 Создание рассылки\n\n" f"🎯 Критерий: {criteria_names.get(criteria, criteria)}\n" f"👥 Получателей: {user_count}\n\n" f"Введите текст сообщения для рассылки:\n\n" f"Поддерживается HTML разметка", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] ]), parse_mode="HTML" ) await state.set_state(AdminStates.waiting_for_broadcast_message) await callback.answer() @admin_required @error_handler async def select_broadcast_target( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): raw_target = callback.data[len("broadcast_"):] target_aliases = { "no_sub": "no", } target = target_aliases.get(raw_target, raw_target) target_names = { "all": "Всем пользователям", "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", "expiring": "С истекающей подпиской", "expired": "С истекшей подпиской", "active_zero": "Активная подписка, трафик 0 ГБ", "trial_zero": "Триальная подписка, трафик 0 ГБ", } # Обработка фильтра по тарифу target_name = target_names.get(target, target) if target.startswith("tariff_"): tariff_id = int(target.split("_")[1]) from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, tariff_id) if tariff: target_name = f"Тариф «{tariff.name}»" else: target_name = f"Тариф #{tariff_id}" user_count = await get_target_users_count(db, target) await state.update_data(broadcast_target=target) await callback.message.edit_text( f"📨 Создание рассылки\n\n" f"🎯 Аудитория: {target_name}\n" f"👥 Получателей: {user_count}\n\n" f"Введите текст сообщения для рассылки:\n\n" f"Поддерживается HTML разметка", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] ]), parse_mode="HTML" ) await state.set_state(AdminStates.waiting_for_broadcast_message) await callback.answer() @admin_required @error_handler async def process_broadcast_message( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession ): broadcast_text = message.text if len(broadcast_text) > 4000: await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)") return await state.update_data(broadcast_message=broadcast_text) await message.answer( "🖼️ Добавление медиафайла\n\n" "Вы можете добавить к сообщению фото, видео или документ.\n" "Или пропустить этот шаг.\n\n" "Выберите тип медиа:", reply_markup=get_broadcast_media_keyboard(db_user.language), parse_mode="HTML" ) @admin_required @error_handler async def handle_media_selection( callback: types.CallbackQuery, db_user: User, state: FSMContext ): if callback.data == "skip_media": await state.update_data(has_media=False) await show_button_selector_callback(callback, db_user, state) return media_type = callback.data.replace('add_media_', '') media_instructions = { "photo": "📷 Отправьте фотографию для рассылки:", "video": "🎥 Отправьте видео для рассылки:", "document": "📄 Отправьте документ для рассылки:" } await state.update_data( media_type=media_type, waiting_for_media=True ) instruction_text = ( f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n" f"Размер файла не должен превышать 50 МБ" ) instruction_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] ]) # Проверяем, является ли текущее сообщение медиа-сообщением is_media_message = ( callback.message.photo or callback.message.video or callback.message.document or callback.message.animation or callback.message.audio or callback.message.voice ) if is_media_message: # Удаляем медиа-сообщение и отправляем новое текстовое try: await callback.message.delete() except Exception: pass await callback.message.answer( instruction_text, reply_markup=instruction_keyboard, parse_mode="HTML" ) else: await callback.message.edit_text( instruction_text, reply_markup=instruction_keyboard, parse_mode="HTML" ) await state.set_state(AdminStates.waiting_for_broadcast_media) await callback.answer() @admin_required @error_handler async def process_broadcast_media( message: types.Message, db_user: User, state: FSMContext ): data = await state.get_data() expected_type = data.get('media_type') media_file_id = None media_type = None if message.photo and expected_type == "photo": media_file_id = message.photo[-1].file_id media_type = "photo" elif message.video and expected_type == "video": media_file_id = message.video.file_id media_type = "video" elif message.document and expected_type == "document": media_file_id = message.document.file_id media_type = "document" else: await message.answer( f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции." ) return await state.update_data( has_media=True, media_file_id=media_file_id, media_type=media_type, media_caption=message.caption ) await show_media_preview(message, db_user, state) async def show_media_preview( message: types.Message, db_user: User, state: FSMContext ): data = await state.get_data() media_type = data.get('media_type') media_file_id = data.get('media_file_id') preview_text = f"🖼️ Медиафайл добавлен\n\n" \ f"📎 Тип: {media_type}\n" \ f"✅ Файл сохранен и готов к отправке\n\n" \ f"Что делать дальше?" # Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа # чтобы показать именно загруженное фото from app.utils.message_patch import _original_answer if media_type == "photo" and media_file_id: # Показываем предпросмотр с загруженным фото await message.bot.send_photo( chat_id=message.chat.id, photo=media_file_id, caption=preview_text, reply_markup=get_media_confirm_keyboard(db_user.language), parse_mode="HTML" ) else: # Для других типов медиа или если нет фото, используем обычное сообщение await _original_answer(message, preview_text, reply_markup=get_media_confirm_keyboard(db_user.language), parse_mode="HTML") @admin_required @error_handler async def handle_media_confirmation( callback: types.CallbackQuery, db_user: User, state: FSMContext ): action = callback.data if action == "confirm_media": await show_button_selector_callback(callback, db_user, state) elif action == "replace_media": data = await state.get_data() media_type = data.get('media_type', 'photo') await handle_media_selection(callback, db_user, state) elif action == "skip_media": await state.update_data( has_media=False, media_file_id=None, media_type=None, media_caption=None ) await show_button_selector_callback(callback, db_user, state) @admin_required @error_handler async def handle_change_media( callback: types.CallbackQuery, db_user: User, state: FSMContext ): await safe_edit_or_send_text( callback, "🖼️ Изменение медиафайла\n\n" "Выберите новый тип медиа:", reply_markup=get_broadcast_media_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_button_selector_callback( callback: types.CallbackQuery, db_user: User, state: FSMContext ): data = await state.get_data() has_media = data.get('has_media', False) selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) media_info = "" if has_media: media_type = data.get('media_type', 'файл') media_info = f"\n🖼️ Медиафайл: {media_type} добавлен" text = f""" 📘 Выбор дополнительных кнопок Выберите кнопки, которые будут добавлены к сообщению рассылки: 💰 Пополнить баланс — откроет методы пополнения 🤝 Партнерка — откроет реферальную программу 🎫 Промокод — откроет форму ввода промокода 🔗 Подключиться — поможет подключить приложение 📱 Подписка — покажет состояние подписки 🛠️ Техподдержка — свяжет с поддержкой 🏠 Кнопка "На главную" включена по умолчанию, но вы можете отключить её при необходимости.{media_info} Выберите нужные кнопки и нажмите "Продолжить": """ keyboard = get_updated_message_buttons_selector_keyboard_with_media( selected_buttons, has_media, db_user.language ) # Проверяем, является ли текущее сообщение медиа-сообщением # (фото, видео, документ и т.д.) - для них нельзя использовать edit_text is_media_message = ( callback.message.photo or callback.message.video or callback.message.document or callback.message.animation or callback.message.audio or callback.message.voice ) if is_media_message: # Удаляем медиа-сообщение и отправляем новое текстовое try: await callback.message.delete() except Exception: pass # Игнорируем ошибки удаления await callback.message.answer( text, reply_markup=keyboard, parse_mode="HTML" ) else: await callback.message.edit_text( text, reply_markup=keyboard, parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_button_selector( message: types.Message, db_user: User, state: FSMContext ): data = await state.get_data() selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) text = """ 📘 Выбор дополнительных кнопок Выберите кнопки, которые будут добавлены к сообщению рассылки: 💰 Пополнить баланс — откроет методы пополнения 🤝 Партнерка — откроет реферальную программу 🎫 Промокод — откроет форму ввода промокода 🔗 Подключиться — поможет подключить приложение 📱 Подписка — покажет состояние подписки 🛠️ Техподдержка — свяжет с поддержкой 🏠 Кнопка "На главную" включена по умолчанию, но вы можете отключить её при необходимости. Выберите нужные кнопки и нажмите "Продолжить": """ keyboard = get_updated_message_buttons_selector_keyboard_with_media( selected_buttons, has_media, db_user.language ) await message.answer( text, reply_markup=keyboard, parse_mode="HTML" ) @admin_required @error_handler async def toggle_button_selection( callback: types.CallbackQuery, db_user: User, state: FSMContext ): button_type = callback.data.replace('btn_', '') data = await state.get_data() selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) else: selected_buttons = list(selected_buttons) if button_type in selected_buttons: selected_buttons.remove(button_type) else: selected_buttons.append(button_type) await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) keyboard = get_updated_message_buttons_selector_keyboard_with_media( selected_buttons, has_media, db_user.language ) await callback.message.edit_reply_markup(reply_markup=keyboard) await callback.answer() @admin_required @error_handler async def confirm_button_selection( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): data = await state.get_data() target = data.get('broadcast_target') message_text = data.get('broadcast_message') selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) media_type = data.get('media_type') user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', '')) target_display = get_target_display_name(target) media_info = "" if has_media: media_type_names = { "photo": "Фотография", "video": "Видео", "document": "Документ" } media_info = f"\n🖼️ Медиафайл: {media_type_names.get(media_type, media_type)}" ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row] button_labels = get_broadcast_button_labels(db_user.language) selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons] if selected_names: buttons_info = f"\n📘 Кнопки: {', '.join(selected_names)}" else: buttons_info = "\n📘 Кнопки: отсутствуют" preview_text = f""" 📨 Предварительный просмотр рассылки 🎯 Аудитория: {target_display} 👥 Получателей: {user_count} 📝 Сообщение: {message_text}{media_info} {buttons_info} Подтвердить отправку? """ keyboard = [ [ types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"), types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons") ] ] if has_media: keyboard.append([ types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media") ]) keyboard.append([ types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages") ]) # Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение if has_media and media_type == "photo": media_file_id = data.get('media_file_id') if media_file_id: # Удаляем текущее сообщение и отправляем новое с фото try: await callback.message.delete() except Exception: pass await callback.bot.send_photo( chat_id=callback.message.chat.id, photo=media_file_id, caption=preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) else: # Если нет file_id, используем safe редактирование await safe_edit_or_send_text( callback, preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) else: # Для текстовых сообщений или других типов медиа используем safe редактирование await safe_edit_or_send_text( callback, preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def confirm_broadcast( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): data = await state.get_data() target = data.get('broadcast_target') message_text = data.get('broadcast_message') selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) has_media = data.get('has_media', False) media_type = data.get('media_type') media_file_id = data.get('media_file_id') media_caption = data.get('media_caption') await safe_edit_or_send_text( callback, "📨 Начинаю рассылку...\n\n" "⏳ Это может занять несколько минут.", reply_markup=None, parse_mode="HTML" ) if target.startswith('custom_'): users = await get_custom_users(db, target.replace('custom_', '')) else: users = await get_target_users(db, target) broadcast_history = BroadcastHistory( target_type=target, message_text=message_text, has_media=has_media, media_type=media_type, media_file_id=media_file_id, media_caption=media_caption, total_count=len(users), sent_count=0, failed_count=0, admin_id=db_user.id, admin_name=db_user.full_name, status="in_progress" ) db.add(broadcast_history) await db.commit() await db.refresh(broadcast_history) sent_count = 0 failed_count = 0 broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language) # Ограничение на количество одновременных отправок и базовая задержка между сообщениями, # чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках max_concurrent_sends = 5 per_message_delay = 0.05 semaphore = asyncio.Semaphore(max_concurrent_sends) async def send_single_broadcast(user): """Отправляет одно сообщение рассылки с семафором ограничения""" async with semaphore: for attempt in range(3): try: if has_media and media_file_id: if media_type == "photo": await callback.bot.send_photo( chat_id=user.telegram_id, photo=media_file_id, caption=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) elif media_type == "video": await callback.bot.send_video( chat_id=user.telegram_id, video=media_file_id, caption=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) elif media_type == "document": await callback.bot.send_document( chat_id=user.telegram_id, document=media_file_id, caption=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) else: await callback.bot.send_message( chat_id=user.telegram_id, text=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) await asyncio.sleep(per_message_delay) return True, user.telegram_id except TelegramRetryAfter as e: retry_delay = min(e.retry_after + 1, 30) logger.warning( f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек." ) await asyncio.sleep(retry_delay) except TelegramForbiddenError: # Пользователь мог удалить бота или запретить сообщения logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden") return False, user.telegram_id except TelegramBadRequest as e: logger.error( f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}" ) return False, user.telegram_id except Exception as e: logger.error( f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}" ) await asyncio.sleep(0.5 * (attempt + 1)) return False, user.telegram_id # Отправляем сообщения пакетами для эффективности batch_size = 50 for i in range(0, len(users), batch_size): batch = users[i:i + batch_size] tasks = [send_single_broadcast(user) for user in batch] results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, tuple): # (success, telegram_id) success, _ = result if success: sent_count += 1 else: failed_count += 1 elif isinstance(result, Exception): failed_count += 1 # Небольшая задержка между пакетами для снижения нагрузки на API await asyncio.sleep(0.25) status = "completed" if failed_count == 0 else "partial" await _persist_broadcast_result( db=db, broadcast_history=broadcast_history, sent_count=sent_count, failed_count=failed_count, status=status, ) media_info = "" if has_media: media_info = f"\n🖼️ Медиафайл: {media_type}" result_text = f""" ✅ Рассылка завершена! 📊 Результат: - Отправлено: {sent_count} - Не доставлено: {failed_count} - Всего пользователей: {len(users)} - Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info} Администратор: {db_user.full_name} """ try: await callback.message.edit_text( result_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] ]), parse_mode="HTML" ) except TelegramBadRequest as e: error_msg = str(e).lower() if "message to edit not found" in error_msg or "there is no text" in error_msg or "message can't be edited" in error_msg: # Сообщение удалено или это медиа - отправляем новое await callback.bot.send_message( chat_id=callback.message.chat.id, text=result_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] ]), parse_mode="HTML" ) else: raise await state.clear() logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})") async def get_target_users_count(db: AsyncSession, target: str) -> int: """Быстрый подсчёт пользователей через SQL COUNT вместо загрузки всех в память.""" from sqlalchemy import func as sql_func, distinct from datetime import datetime, timedelta base_filter = User.status == UserStatus.ACTIVE.value if target == "all": query = select(sql_func.count(User.id)).where(base_filter) result = await db.execute(query) return result.scalar() or 0 if target == "active": # Активные платные подписки (не триал) query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.is_trial == False, ) ) result = await db.execute(query) return result.scalar() or 0 if target == "trial": # Триальные подписки (без проверки is_active, как в оригинале) query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.is_trial == True, ) ) result = await db.execute(query) return result.scalar() or 0 if target == "no": # Без активной подписки - используем NOT EXISTS для корректности subquery = ( select(Subscription.id) .where( Subscription.user_id == User.id, Subscription.status == SubscriptionStatus.ACTIVE.value, ) .exists() ) query = ( select(sql_func.count(User.id)) .where(base_filter, ~subquery) ) result = await db.execute(query) return result.scalar() or 0 if target == "expiring": # Истекающие в ближайшие 3 дня now = datetime.utcnow() expiry_threshold = now + timedelta(days=3) query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= expiry_threshold, Subscription.end_date > now, ) ) result = await db.execute(query) return result.scalar() or 0 if target == "expiring_subscribers": # Истекающие в ближайшие 7 дней now = datetime.utcnow() expiry_threshold = now + timedelta(days=7) query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= expiry_threshold, Subscription.end_date > now, ) ) result = await db.execute(query) return result.scalar() or 0 if target == "expired": # Истекшие подписки now = datetime.utcnow() expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value] query = ( select(sql_func.count(distinct(User.id))) .outerjoin(Subscription, User.id == Subscription.user_id) .where( base_filter, or_( Subscription.status.in_(expired_statuses), and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value), and_(Subscription.id == None, User.has_had_paid_subscription == True), ) ) ) result = await db.execute(query) return result.scalar() or 0 if target == "expired_subscribers": # То же что и expired now = datetime.utcnow() expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value] query = ( select(sql_func.count(distinct(User.id))) .outerjoin(Subscription, User.id == Subscription.user_id) .where( base_filter, or_( Subscription.status.in_(expired_statuses), and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value), and_(Subscription.id == None, User.has_had_paid_subscription == True), ) ) ) result = await db.execute(query) return result.scalar() or 0 if target == "active_zero": # Активные платные с нулевым трафиком query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.is_trial == False, or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0), ) ) result = await db.execute(query) return result.scalar() or 0 if target == "trial_zero": # Триальные с нулевым трафиком query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.is_trial == True, Subscription.status == SubscriptionStatus.ACTIVE.value, or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0), ) ) result = await db.execute(query) return result.scalar() or 0 if target == "zero": # Все активные с нулевым трафиком query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.status == SubscriptionStatus.ACTIVE.value, or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0), ) ) result = await db.execute(query) return result.scalar() or 0 # Фильтр по тарифу if target.startswith("tariff_"): tariff_id = int(target.split("_")[1]) query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) .where( base_filter, Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.tariff_id == tariff_id, ) ) result = await db.execute(query) return result.scalar() or 0 # Для остальных фильтров (custom_ и неизвестные) - fallback на старый метод users = await get_target_users(db, target) return len(users) async def get_target_users(db: AsyncSession, target: str) -> list: # Загружаем всех активных пользователей батчами, чтобы не ограничиваться 10к users: list[User] = [] offset = 0 batch_size = 5000 while True: batch = await get_users_list( db, offset=offset, limit=batch_size, status=UserStatus.ACTIVE, ) if not batch: break users.extend(batch) offset += batch_size if target == "all": return users if target == "active": return [ user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial ] if target == "trial": return [ user for user in users if user.subscription and user.subscription.is_trial ] if target == "no": return [ user for user in users if not user.subscription or not user.subscription.is_active ] if target == "expiring": expiring_subs = await get_expiring_subscriptions(db, 3) return [sub.user for sub in expiring_subs if sub.user] if target == "expired": now = datetime.utcnow() expired_statuses = { SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value, } expired_users = [] for user in users: subscription = user.subscription if subscription: if subscription.status in expired_statuses: expired_users.append(user) continue if subscription.end_date <= now and not subscription.is_active: expired_users.append(user) continue elif user.has_had_paid_subscription: expired_users.append(user) return expired_users if target == "active_zero": return [ user for user in users if user.subscription and not user.subscription.is_trial and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] if target == "trial_zero": return [ user for user in users if user.subscription and user.subscription.is_trial and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] if target == "zero": return [ user for user in users if user.subscription and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] if target == "expiring_subscribers": expiring_subs = await get_expiring_subscriptions(db, 7) return [sub.user for sub in expiring_subs if sub.user] if target == "expired_subscribers": now = datetime.utcnow() expired_statuses = { SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value, } expired_users = [] for user in users: subscription = user.subscription if subscription: if subscription.status in expired_statuses: expired_users.append(user) continue if subscription.end_date <= now and not subscription.is_active: expired_users.append(user) continue elif user.has_had_paid_subscription: expired_users.append(user) return expired_users if target == "canceled_subscribers": return [ user for user in users if user.subscription and user.subscription.status == SubscriptionStatus.DISABLED.value ] if target == "trial_ending": now = datetime.utcnow() in_3_days = now + timedelta(days=3) return [ user for user in users if user.subscription and user.subscription.is_trial and user.subscription.is_active and user.subscription.end_date <= in_3_days ] if target == "trial_expired": now = datetime.utcnow() return [ user for user in users if user.subscription and user.subscription.is_trial and user.subscription.end_date <= now ] if target == "autopay_failed": from app.database.models import SubscriptionEvent week_ago = datetime.utcnow() - timedelta(days=7) stmt = select(SubscriptionEvent.user_id).where( and_( SubscriptionEvent.event_type == "autopay_failed", SubscriptionEvent.occurred_at >= week_ago, ) ).distinct() result = await db.execute(stmt) failed_user_ids = set(result.scalars().all()) return [user for user in users if user.id in failed_user_ids] if target == "low_balance": threshold_kopeks = 10000 # 100 рублей return [ user for user in users if (user.balance_kopeks or 0) < threshold_kopeks and (user.balance_kopeks or 0) > 0 ] if target == "inactive_30d": threshold = datetime.utcnow() - timedelta(days=30) return [ user for user in users if user.last_activity and user.last_activity < threshold ] if target == "inactive_60d": threshold = datetime.utcnow() - timedelta(days=60) return [ user for user in users if user.last_activity and user.last_activity < threshold ] if target == "inactive_90d": threshold = datetime.utcnow() - timedelta(days=90) return [ user for user in users if user.last_activity and user.last_activity < threshold ] # Фильтр по тарифу if target.startswith("tariff_"): tariff_id = int(target.split("_")[1]) return [ user for user in users if user.subscription and user.subscription.is_active and user.subscription.tariff_id == tariff_id ] return [] async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: users = await get_custom_users(db, criteria) return len(users) async def get_custom_users(db: AsyncSession, criteria: str) -> list: now = datetime.utcnow() today = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) if criteria == "today": stmt = select(User).where( and_(User.status == "active", User.created_at >= today) ) elif criteria == "week": stmt = select(User).where( and_(User.status == "active", User.created_at >= week_ago) ) elif criteria == "month": stmt = select(User).where( and_(User.status == "active", User.created_at >= month_ago) ) elif criteria == "active_today": stmt = select(User).where( and_(User.status == "active", User.last_activity >= today) ) elif criteria == "inactive_week": stmt = select(User).where( and_(User.status == "active", User.last_activity < week_ago) ) elif criteria == "inactive_month": stmt = select(User).where( and_(User.status == "active", User.last_activity < month_ago) ) elif criteria == "referrals": stmt = select(User).where( and_(User.status == "active", User.referred_by_id.isnot(None)) ) elif criteria == "direct": stmt = select(User).where( and_( User.status == "active", User.referred_by_id.is_(None) ) ) else: return [] result = await db.execute(stmt) return result.scalars().all() async def get_users_statistics(db: AsyncSession) -> dict: now = datetime.utcnow() today = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) stats = {} stats['today'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.created_at >= today) ) ) or 0 stats['week'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.created_at >= week_ago) ) ) or 0 stats['month'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.created_at >= month_ago) ) ) or 0 stats['active_today'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.last_activity >= today) ) ) or 0 stats['inactive_week'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.last_activity < week_ago) ) ) or 0 stats['inactive_month'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.last_activity < month_ago) ) ) or 0 stats['referrals'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.referred_by_id.isnot(None)) ) ) or 0 stats['direct'] = await db.scalar( select(func.count(User.id)).where( and_( User.status == "active", User.referred_by_id.is_(None) ) ) ) or 0 return stats def get_target_name(target_type: str) -> str: names = { "all": "Всем пользователям", "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", "sub": "Без подписки", "expiring": "С истекающей подпиской", "expired": "С истекшей подпиской", "active_zero": "Активная подписка, трафик 0 ГБ", "trial_zero": "Триальная подписка, трафик 0 ГБ", "zero": "Подписка, трафик 0 ГБ", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", "custom_active_today": "Активные сегодня", "custom_inactive_week": "Неактивные 7+ дней", "custom_inactive_month": "Неактивные 30+ дней", "custom_referrals": "Через рефералов", "custom_direct": "Прямая регистрация" } # Обработка фильтра по тарифу if target_type.startswith("tariff_"): tariff_id = target_type.split("_")[1] return f"По тарифу #{tariff_id}" return names.get(target_type, target_type) def get_target_display_name(target: str) -> str: return get_target_name(target) def register_handlers(dp: Dispatcher): dp.callback_query.register(show_messages_menu, F.data == "admin_messages") dp.callback_query.register(show_pinned_message_menu, F.data == "admin_pinned_message") dp.callback_query.register(toggle_pinned_message_position, F.data == "admin_pinned_message_position") dp.callback_query.register(toggle_pinned_message_start_mode, F.data == "admin_pinned_message_start_mode") dp.callback_query.register(delete_pinned_message, F.data == "admin_pinned_message_delete") dp.callback_query.register(prompt_pinned_message_update, F.data == "admin_pinned_message_edit") dp.callback_query.register(handle_pinned_broadcast_now, F.data.startswith("admin_pinned_broadcast_now:")) dp.callback_query.register(handle_pinned_broadcast_skip, F.data.startswith("admin_pinned_broadcast_skip:")) dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"])) dp.callback_query.register(show_tariff_filter, F.data == "broadcast_by_tariff") dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_")) dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast") dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history")) dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom") dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_")) dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_")) dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm") dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons") dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_")) dp.callback_query.register(handle_media_selection, F.data == "skip_media") dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"])) dp.callback_query.register(handle_change_media, F.data == "change_media") dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message) dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media) dp.message.register(process_pinned_message_update, AdminStates.editing_pinned_message)