mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-04-29 01:00:03 +00:00
1846 lines
73 KiB
Python
1846 lines
73 KiB
Python
import asyncio
|
||
import html
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
|
||
from aiogram import Dispatcher, F, types
|
||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||
from aiogram.fsm.context import FSMContext
|
||
from sqlalchemy import and_, func, or_, select
|
||
from sqlalchemy.exc import InterfaceError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.crud.subscription import get_expiring_subscriptions
|
||
from app.database.crud.tariff import get_all_tariffs
|
||
from app.database.crud.user import get_users_list
|
||
from app.database.database import AsyncSessionLocal
|
||
from app.database.models import (
|
||
BroadcastHistory,
|
||
Subscription,
|
||
SubscriptionStatus,
|
||
User,
|
||
UserStatus,
|
||
)
|
||
from app.keyboards.admin import (
|
||
BROADCAST_BUTTON_ROWS,
|
||
DEFAULT_BROADCAST_BUTTONS,
|
||
get_admin_messages_keyboard,
|
||
get_broadcast_button_config,
|
||
get_broadcast_button_labels,
|
||
get_broadcast_history_keyboard,
|
||
get_broadcast_media_keyboard,
|
||
get_broadcast_target_keyboard,
|
||
get_custom_criteria_keyboard,
|
||
get_media_confirm_keyboard,
|
||
get_pinned_message_keyboard,
|
||
get_updated_message_buttons_selector_keyboard_with_media,
|
||
)
|
||
from app.localization.texts import get_texts
|
||
from app.services.pinned_message_service import (
|
||
broadcast_pinned_message,
|
||
get_active_pinned_message,
|
||
set_active_pinned_message,
|
||
unpin_active_pinned_message,
|
||
)
|
||
from app.states import AdminStates
|
||
from app.utils.decorators import admin_required, error_handler
|
||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||
|
||
|
||
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') -> types.InlineKeyboardMarkup | None:
|
||
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:
|
||
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
|
||
|
||
# Сохраняем ID и время завершения в локальные переменные ДО операций с БД,
|
||
# чтобы избежать обращения к атрибутам отсоединенного объекта при потере соединения
|
||
broadcast_id = broadcast_history.id
|
||
completed_at = datetime.utcnow()
|
||
|
||
broadcast_history.sent_count = sent_count
|
||
broadcast_history.failed_count = failed_count
|
||
broadcast_history.status = status
|
||
broadcast_history.completed_at = completed_at
|
||
|
||
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_id)
|
||
if not retry_history:
|
||
logger.critical(
|
||
'Не удалось найти запись BroadcastHistory #%s для повторной записи результатов',
|
||
broadcast_id,
|
||
)
|
||
return
|
||
|
||
retry_history.sent_count = sent_count
|
||
retry_history.failed_count = failed_count
|
||
retry_history.status = status
|
||
retry_history.completed_at = completed_at
|
||
await retry_session.commit()
|
||
logger.info(
|
||
'Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)',
|
||
broadcast_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 = """
|
||
📨 <b>Управление рассылками</b>
|
||
|
||
Выберите тип рассылки:
|
||
|
||
- <b>Всем пользователям</b> - рассылка всем активным пользователям
|
||
- <b>По подпискам</b> - фильтрация по типу подписки
|
||
- <b>По критериям</b> - настраиваемые фильтры
|
||
- <b>История</b> - просмотр предыдущих рассылок
|
||
|
||
⚠️ Будьте осторожны с массовыми рассылками!
|
||
"""
|
||
|
||
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 = (
|
||
'📌 <b>Закрепленное сообщение</b>\n\n'
|
||
'📝 Текущий текст:\n'
|
||
f'<code>{content_preview}</code>\n\n'
|
||
f'{media_line}'
|
||
f'{position_line}\n'
|
||
f'{start_mode_line}\n'
|
||
f'🕒 Обновлено: {timestamp_text}'
|
||
)
|
||
else:
|
||
body = (
|
||
'📌 <b>Закрепленное сообщение</b>\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(
|
||
'✏️ <b>Новое закрепленное сообщение</b>\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(
|
||
'🗑️ <b>Удаление закрепленного сообщения</b>\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(
|
||
'✅ <b>Закрепленное сообщение удалено</b>\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: str | None = None
|
||
media_file_id: str | None = 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',
|
||
'📌 <b>Сообщение сохранено!</b>\n\n'
|
||
'Выберите, как доставить сообщение пользователям:\n\n'
|
||
'• <b>Разослать сейчас</b> — отправит и закрепит у всех активных пользователей\n'
|
||
'• <b>Только при /start</b> — пользователи увидят при следующем запуске бота',
|
||
),
|
||
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',
|
||
'✅ <b>Закрепленное сообщение обновлено</b>\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',
|
||
'✅ <b>Закрепленное сообщение сохранено</b>\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(
|
||
'🎯 <b>Выбор целевой аудитории</b>\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(
|
||
'❌ <b>Нет доступных тарифов</b>\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(
|
||
'📦 <b>Рассылка по тарифу</b>\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 = """
|
||
📋 <b>История рассылок</b>
|
||
|
||
❌ История рассылок пуста.
|
||
Отправьте первую рассылку, чтобы увидеть её здесь.
|
||
"""
|
||
keyboard = [[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_messages')]]
|
||
else:
|
||
text = f'📋 <b>История рассылок</b> (страница {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} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||
📊 Отправлено: {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"""
|
||
📝 <b>Рассылка по критериям</b>
|
||
|
||
📊 <b>Доступные фильтры:</b>
|
||
|
||
👥 <b>По регистрации:</b>
|
||
• Сегодня: {stats['today']} чел.
|
||
• За неделю: {stats['week']} чел.
|
||
• За месяц: {stats['month']} чел.
|
||
|
||
💼 <b>По активности:</b>
|
||
• Активные сегодня: {stats['active_today']} чел.
|
||
• Неактивные 7+ дней: {stats['inactive_week']} чел.
|
||
• Неактивные 30+ дней: {stats['inactive_month']} чел.
|
||
|
||
🔗 <b>По источнику:</b>
|
||
• Через рефералов: {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'📨 <b>Создание рассылки</b>\n\n'
|
||
f'🎯 <b>Критерий:</b> {criteria_names.get(criteria, criteria)}\n'
|
||
f'👥 <b>Получателей:</b> {user_count}\n\n'
|
||
f'Введите текст сообщения для рассылки:\n\n'
|
||
f'<i>Поддерживается HTML разметка</i>',
|
||
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'📨 <b>Создание рассылки</b>\n\n'
|
||
f'🎯 <b>Аудитория:</b> {target_name}\n'
|
||
f'👥 <b>Получателей:</b> {user_count}\n\n'
|
||
f'Введите текст сообщения для рассылки:\n\n'
|
||
f'<i>Поддерживается HTML разметка</i>',
|
||
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(
|
||
'🖼️ <b>Добавление медиафайла</b>\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<i>Размер файла не должен превышать 50 МБ</i>'
|
||
)
|
||
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'🖼️ <b>Медиафайл добавлен</b>\n\n'
|
||
f'📎 <b>Тип:</b> {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()
|
||
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,
|
||
'🖼️ <b>Изменение медиафайла</b>\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🖼️ <b>Медиафайл:</b> {media_type} добавлен'
|
||
|
||
text = f"""
|
||
📘 <b>Выбор дополнительных кнопок</b>
|
||
|
||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||
|
||
💰 <b>Пополнить баланс</b> — откроет методы пополнения
|
||
🤝 <b>Партнерка</b> — откроет реферальную программу
|
||
🎫 <b>Промокод</b> — откроет форму ввода промокода
|
||
🔗 <b>Подключиться</b> — поможет подключить приложение
|
||
📱 <b>Подписка</b> — покажет состояние подписки
|
||
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
|
||
|
||
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.{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 = """
|
||
📘 <b>Выбор дополнительных кнопок</b>
|
||
|
||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||
|
||
💰 <b>Пополнить баланс</b> — откроет методы пополнения
|
||
🤝 <b>Партнерка</b> — откроет реферальную программу
|
||
🎫 <b>Промокод</b> — откроет форму ввода промокода
|
||
🔗 <b>Подключиться</b> — поможет подключить приложение
|
||
📱 <b>Подписка</b> — покажет состояние подписки
|
||
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
|
||
|
||
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.
|
||
|
||
Выберите нужные кнопки и нажмите "Продолжить":
|
||
"""
|
||
|
||
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🖼️ <b>Медиафайл:</b> {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📘 <b>Кнопки:</b> {", ".join(selected_names)}'
|
||
else:
|
||
buttons_info = '\n📘 <b>Кнопки:</b> отсутствуют'
|
||
|
||
preview_text = f"""
|
||
📨 <b>Предварительный просмотр рассылки</b>
|
||
|
||
🎯 <b>Аудитория:</b> {target_display}
|
||
👥 <b>Получателей:</b> {user_count}
|
||
|
||
📝 <b>Сообщение:</b>
|
||
{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):
|
||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||
# Skip email-only users (no telegram_id)
|
||
if not user.telegram_id:
|
||
logger.debug('Пропуск email-пользователя %s при рассылке', user.id)
|
||
return False, None
|
||
|
||
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🖼️ <b>Медиафайл:</b> {media_type}'
|
||
|
||
result_text = f"""
|
||
✅ <b>Рассылка завершена!</b>
|
||
|
||
📊 <b>Результат:</b>
|
||
- Отправлено: {sent_count}
|
||
- Не доставлено: {failed_count}
|
||
- Всего пользователей: {len(users)}
|
||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||
|
||
<b>Администратор:</b> {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 datetime import datetime, timedelta
|
||
|
||
from sqlalchemy import distinct, func as sql_func
|
||
|
||
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 filters — быстрый COUNT вместо загрузки всех пользователей
|
||
if target.startswith('custom_'):
|
||
now = datetime.utcnow()
|
||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
criteria = target[len('custom_') :]
|
||
|
||
if criteria == 'today':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.created_at >= today)
|
||
elif criteria == 'week':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.created_at >= now - timedelta(days=7))
|
||
elif criteria == 'month':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.created_at >= now - timedelta(days=30))
|
||
elif criteria == 'active_today':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.last_activity >= today)
|
||
elif criteria == 'inactive_week':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.last_activity < now - timedelta(days=7))
|
||
elif criteria == 'inactive_month':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.last_activity < now - timedelta(days=30))
|
||
elif criteria == 'referrals':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.referred_by_id.isnot(None))
|
||
elif criteria == 'direct':
|
||
query = select(sql_func.count(User.id)).where(base_filter, User.referred_by_id.is_(None))
|
||
else:
|
||
return 0
|
||
|
||
result = await db.execute(query)
|
||
return result.scalar() or 0
|
||
|
||
return 0
|
||
|
||
|
||
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)
|