import asyncio import logging from typing import Optional from aiogram import Bot, F, Router, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.admin_keyboards import ( get_admin_panel_keyboard, get_back_to_admin_panel_keyboard, get_broadcast_confirmation_keyboard, ) from bot.middlewares.i18n import JsonI18n from bot.states.admin_states import AdminStates from bot.utils import ( MessageContent, get_message_content, send_message_by_type, send_message_via_queue, ) from bot.utils.message_queue import get_queue_manager from config.settings import Settings from db.dal import message_log_dal, user_dal router = Router(name="admin_broadcast_router") async def broadcast_message_prompt_handler( callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings, session: AsyncSession, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: logging.error("i18n missing in broadcast_message_prompt_handler") await callback.answer("Language service error.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) prompt_text = _("admin_broadcast_enter_message") if callback.message: try: await callback.message.edit_text( prompt_text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), ) except Exception as e: logging.warning(f"Could not edit message for broadcast prompt: {e}. Sending new.") await callback.message.answer( prompt_text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), ) await callback.answer() await state.set_state(AdminStates.waiting_for_broadcast_message) @router.message(AdminStates.waiting_for_broadcast_message) async def process_broadcast_message_handler( message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings, session: AsyncSession, bot: Bot, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: logging.error("i18n missing in process_broadcast_message_handler") await message.reply("Language service error.") return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) # Определяем тип содержимого и сохраняем данные в state entities = message.entities or message.caption_entities or [] content = get_message_content(message) # Если нет ни текста, ни медиа — ошибка if not content.text and not content.file_id: await message.answer(_("admin_broadcast_error_no_message")) return # Сохраняем данные для рассылки await state.update_data( broadcast_text=content.text, broadcast_entities=entities, broadcast_content_type=content.content_type, broadcast_file_id=content.file_id, broadcast_target="all", ) # Отправляем превью-копию того, что будет разослано try: # Для медиа-сообщений используем caption_entities, для текста - entities if content.content_type == "text": await send_message_by_type( bot, chat_id=message.chat.id, content=content, parse_mode="HTML", entities=entities, disable_web_page_preview=True, disable_notification=True, ) else: await send_message_by_type( bot, chat_id=message.chat.id, content=content, parse_mode="HTML", caption_entities=entities, disable_web_page_preview=True, disable_notification=True, ) except TelegramBadRequest as e: await message.answer( _( "admin_broadcast_invalid_html", error=str(e), ) ) return # Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью confirmation_prompt = _("admin_broadcast_confirm_prompt_short") await message.answer( confirmation_prompt, reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target="all"), ) await state.set_state(AdminStates.confirming_broadcast) @router.callback_query( F.data.startswith("broadcast_target:"), AdminStates.confirming_broadcast, ) async def change_broadcast_target_handler( callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n or not callback.message: await callback.answer("Error updating selection.", show_alert=True) return new_target = callback.data.split(":")[1] if new_target not in {"all", "active", "inactive"}: await callback.answer("Unknown target.", show_alert=True) return await state.update_data(broadcast_target=new_target) await state.get_data() _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) confirmation_prompt = _("admin_broadcast_confirm_prompt_short") try: await callback.message.edit_text( confirmation_prompt, reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target=new_target), ) except Exception: pass await callback.answer() @router.callback_query(F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message) async def cancel_broadcast_at_prompt_stage( callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n or not callback.message: await callback.answer("Error cancelling.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) try: await callback.message.edit_text(_("admin_broadcast_cancelled_nav_back"), reply_markup=None) except Exception: await callback.message.answer(_("admin_broadcast_cancelled_nav_back")) await callback.answer(_("admin_broadcast_cancelled_alert")) await state.clear() await callback.message.answer( _(key="admin_panel_title"), reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings), ) @router.callback_query( F.data.startswith("broadcast_final_action:"), AdminStates.confirming_broadcast, ) async def confirm_broadcast_callback_handler( callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, bot: Bot, settings: Settings, session: AsyncSession, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n or not callback.message: await callback.answer("Error processing broadcast confirmation.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) action = callback.data.split(":")[1] user_fsm_data = await state.get_data() if action == "send": # Создаем объект контента из сохраненных данных content = MessageContent( content_type=user_fsm_data.get("broadcast_content_type", "text"), file_id=user_fsm_data.get("broadcast_file_id"), text=user_fsm_data.get("broadcast_text"), ) entities = user_fsm_data.get("broadcast_entities", []) if not content.text and content.content_type == "text": await callback.message.edit_text(_("admin_broadcast_error_no_message")) await state.clear() await callback.answer(_("admin_broadcast_error_no_message_alert"), show_alert=True) return await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None) await callback.answer() target = user_fsm_data.get("broadcast_target", "all") if target == "active": user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) sent_count = 0 failed_count = 0 admin_user = callback.from_user logging.info( f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." # noqa: E501 ) # Get message queue manager queue_manager = get_queue_manager() if not queue_manager: await callback.message.edit_text( "❌ Ошибка: система очередей не инициализирована", reply_markup=None ) return # Queue all messages for sending for uid in user_ids: try: # Для медиа-сообщений используем caption_entities, для текста - entities if content.content_type == "text": await send_message_via_queue( queue_manager, uid, content, parse_mode="HTML", entities=entities, disable_web_page_preview=True, ) else: await send_message_via_queue( queue_manager, uid, content, parse_mode="HTML", caption_entities=entities, disable_web_page_preview=True, ) sent_count += 1 # Log successful queuing await message_log_dal.create_message_log( session, { "user_id": admin_user.id, "telegram_username": admin_user.username, "telegram_first_name": admin_user.first_name, "event_type": "admin_broadcast_queued", "content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", # noqa: E501 "is_admin_event": True, "target_user_id": uid, }, ) except Exception as e: failed_count += 1 logging.warning(f"Failed to queue broadcast to {uid}: {type(e).__name__} – {e}") await message_log_dal.create_message_log( session, { "user_id": admin_user.id, "telegram_username": admin_user.username, "telegram_first_name": admin_user.first_name, "event_type": "admin_broadcast_failed", "content": f"For user {uid}: {type(e).__name__} – {str(e)[:70]}...", "is_admin_event": True, "target_user_id": uid, }, ) try: await session.commit() except Exception as e_commit: await session.rollback() logging.error(f"Error committing broadcast logs: {e_commit}") # Prepare queue stats presentation queue_stats = queue_manager.get_queue_stats() back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n) initial_user_failed = queue_stats.get("user_failed_messages", 0) initial_group_failed = queue_stats.get("group_failed_messages", 0) def build_queue_status(stats: dict) -> str: dynamic_failed = max( 0, stats.get("user_failed_messages", 0) - initial_user_failed ) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed) total_failed = failed_count + dynamic_failed return _( "broadcast_queue_result", sent_count=sent_count, failed_count=total_failed, user_queue_size=stats["user_queue_size"], group_queue_size=stats["group_queue_size"], ) result_message = build_queue_status(queue_stats) status_message = await callback.message.answer( result_message, reply_markup=back_keyboard, ) async def auto_update_queue_status() -> None: """Refresh queue stats message twice per second via message edit.""" last_text = result_message # Update for up to 2 minutes (240 iterations at 0.5s intervals) max_iterations = 240 for _ in range(max_iterations): await asyncio.sleep(0.5) stats = queue_manager.get_queue_stats() new_text = build_queue_status(stats) queues_drained = ( stats["user_queue_size"] == 0 and stats["group_queue_size"] == 0 and not stats.get("user_queue_processing") and not stats.get("group_queue_processing") ) if new_text != last_text: try: await status_message.edit_text( new_text, reply_markup=back_keyboard, ) last_text = new_text except TelegramBadRequest as e: if "message is not modified" in str(e): last_text = new_text else: logging.debug("Broadcast queue auto-update stopped: %s", e) break except Exception as e: logging.debug("Broadcast queue auto-update unexpected error: %s", e) break if queues_drained: # Final refresh already attempted; exit loop. break else: logging.debug("Broadcast queue auto-update reached time limit.") asyncio.create_task(auto_update_queue_status()) elif action == "cancel": await callback.message.edit_text( _("admin_broadcast_cancelled"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), ) await callback.answer() await state.clear()