From df9e36928cd55eda3810b210706c614a05a6610b Mon Sep 17 00:00:00 2001 From: machka Date: Wed, 21 May 2025 19:30:25 +0000 Subject: [PATCH] Broadcast HTML fix and subscription keyboards improvements --- bot/handlers/admin/broadcast.py | 201 +++++++++++++++++------------- bot/handlers/user/subscription.py | 140 ++++++++++----------- 2 files changed, 179 insertions(+), 162 deletions(-) diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py index bf67826..6d35275 100644 --- a/bot/handlers/admin/broadcast.py +++ b/bot/handlers/admin/broadcast.py @@ -11,16 +11,23 @@ from config.settings import Settings from db.dal import user_dal, message_log_dal from bot.states.admin_states import AdminStates -from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard +from bot.keyboards.inline.admin_keyboards import ( + get_broadcast_confirmation_keyboard, + get_back_to_admin_panel_keyboard, + get_admin_panel_keyboard, +) from bot.middlewares.i18n import JsonI18n router = Router(name="admin_broadcast_router") -async def broadcast_message_prompt_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, - session: AsyncSession): +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: @@ -35,25 +42,28 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery, try: await callback.message.edit_text( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard( - current_lang, i18n)) + 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)) + 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, F.text) -async def process_broadcast_message_handler(message: types.Message, - state: FSMContext, i18n_data: dict, - settings: Settings, - session: AsyncSession): +async def process_broadcast_message_handler( + message: types.Message, + 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: @@ -63,28 +73,35 @@ async def process_broadcast_message_handler(message: types.Message, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - broadcast_message_text = message.html_text - await state.update_data(broadcast_message=broadcast_message_text) + # Сохраняем в state исходный текст и entities + text = message.text or message.caption or "" + entities = message.entities or message.caption_entities or [] - preview_snippet = ( - broadcast_message_text[:200] + - "...") if len(broadcast_message_text) > 200 else broadcast_message_text - confirmation_prompt = _("admin_broadcast_confirm_prompt", - message_preview=preview_snippet) + await state.update_data( + broadcast_text=text, + broadcast_entities=entities, + ) - await message.answer(confirmation_prompt, - reply_markup=get_broadcast_confirmation_keyboard( - current_lang, i18n), - parse_mode="HTML") + preview_snippet = (text[:200] + "...") if len(text) > 200 else text + confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet) + + await message.answer( + confirmation_prompt, + reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n), + ) await state.set_state(AdminStates.confirming_broadcast) -@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): +@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: @@ -94,96 +111,104 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery, try: await callback.message.edit_text( - _("admin_broadcast_cancelled_nav_back"), reply_markup=None) + _("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)) + 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): +@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) + 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() - broadcast_message_to_send = user_fsm_data.get("broadcast_message") if action == "send": - if not broadcast_message_to_send: - await callback.message.edit_text( - _("admin_broadcast_error_no_message")) + text = user_fsm_data.get("broadcast_text") + entities = user_fsm_data.get("broadcast_entities", []) + + if not 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) + 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.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None) await callback.answer() - user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast( - session) + user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) sent_count = 0 failed_count = 0 - logging.info( - f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users." - ) admin_user = callback.from_user + logging.info( + f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users." + ) - for user_id_to_send in user_ids_for_broadcast: + for uid in user_ids: try: - await bot.send_message(user_id_to_send, - broadcast_message_to_send, - parse_mode="HTML") + await bot.send_message( + chat_id=uid, + text=text, + entities=entities, + ) sent_count += 1 - log_payload_sent = { - "user_id": admin_user.id, - "telegram_username": admin_user.username, - "telegram_first_name": admin_user.first_name, - "event_type": "admin_broadcast_sent", - "content": - f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...", - "is_admin_event": True, - "target_user_id": user_id_to_send - } await message_log_dal.create_message_log( - session, log_payload_sent) + session, + { + "user_id": admin_user.id, + "telegram_username": admin_user.username, + "telegram_first_name": admin_user.first_name, + "event_type": "admin_broadcast_sent", + "content": f"To user {uid}: {text[:70]}...", + "is_admin_event": True, + "target_user_id": uid, + }, + ) except Exception as e: failed_count += 1 logging.warning( - f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}" + f"Failed to send broadcast to {uid}: {type(e).__name__} – {e}" ) - log_payload_failed = { - "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 {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...", - "is_admin_event": True, - "target_user_id": user_id_to_send - } await message_log_dal.create_message_log( - session, log_payload_failed) + 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, + }, + ) await asyncio.sleep(0.05) try: @@ -192,17 +217,17 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery, await session.rollback() logging.error(f"Error committing broadcast logs: {e_commit}") - result_message = _("admin_broadcast_finished_stats", - sent_count=sent_count, - failed_count=failed_count) + result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count) await callback.message.answer( result_message, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + ) elif action == "cancel": await callback.message.edit_text( _("admin_broadcast_cancelled"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + ) await callback.answer() await state.clear() diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index af3560c..3a3144f 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -1,6 +1,7 @@ import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from typing import Optional, Dict, Any, Union from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession @@ -257,97 +258,88 @@ async def reshow_subscription_options_callback(callback: types.CallbackQuery, async def my_subscription_command_handler( - event: Union[types.Message, types.CallbackQuery], i18n_data: dict, - settings: Settings, panel_service: PanelApiService, - subscription_service: SubscriptionService, session: AsyncSession, - bot: Bot): - target_message_obj = event.message if isinstance( - event, types.CallbackQuery) else event - user = event.from_user - - if isinstance(event, types.CallbackQuery): - await event.answer() - + event: Union[types.Message, types.CallbackQuery], + i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + session: AsyncSession, + bot: Bot +): + target = event.message if isinstance(event, types.CallbackQuery) else event current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs - ) if i18n else key + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) - if not i18n or not target_message_obj: + if not i18n or not target: if isinstance(event, types.Message): await event.answer(get_text("error_occurred_try_again")) return if not panel_service or not subscription_service: - logging.error( - "PanelService or SubscriptionService is missing in my_subscription_command_handler." - ) - await target_message_obj.answer(get_text("error_service_unavailable")) + await target.answer(get_text("error_service_unavailable")) return - active_sub_details = await subscription_service.get_active_subscription_details( - session, user.id) + active = await subscription_service.get_active_subscription_details(session, event.from_user.id) - sub_info_text_content = "" - if active_sub_details: - end_date_obj = active_sub_details.get('end_date') - days_left = 0 - if end_date_obj: - if end_date_obj.tzinfo is None: - end_date_obj = end_date_obj.replace(tzinfo=timezone.utc) - days_left = (end_date_obj.date() - datetime.now().date()).days + if not active: + text = get_text("subscription_not_active") - actual_config_link = active_sub_details.get('config_link') or get_text( - "config_link_not_available") + buy_button = InlineKeyboardButton( + text=get_text("menu_subscribe_inline", default="Купить"), + callback_data="main_action:subscribe" + ) + back_markup = get_back_to_main_menu_markup(current_lang, i18n) - traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes') - traffic_used_bytes = active_sub_details.get('traffic_used_bytes') + kb = InlineKeyboardMarkup( + inline_keyboard=[ + [buy_button], + *back_markup.inline_keyboard + ] + ) - traffic_limit_gb_str = get_text("traffic_unlimited") - if traffic_limit_bytes and traffic_limit_bytes > 0: - traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB" + if isinstance(event, types.CallbackQuery): + await event.answer() + try: + await event.message.edit_text(text, reply_markup=kb) + except: + await event.message.answer(text, reply_markup=kb) + else: + await event.answer(text, reply_markup=kb) + return - traffic_used_gb_str = get_text("traffic_na") - if traffic_used_bytes is not None: - traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB" + end_date = active.get("end_date") + days_left = ( + (end_date.date() - datetime.now().date()).days + if end_date else 0 + ) + text = get_text( + "my_subscription_details", + end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", + days_left=max(0, days_left), + status=active.get("status_from_panel", get_text("status_active")).capitalize(), + config_link=active.get("config_link") or get_text("config_link_not_available"), + traffic_limit=( + f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" + if active.get("traffic_limit_bytes") + else get_text("traffic_unlimited") + ), + traffic_used=( + f"{active['traffic_used_bytes'] / 2**30:.2f} GB" + if active.get("traffic_used_bytes") is not None + else get_text("traffic_na") + ) + ) + markup = get_back_to_main_menu_markup(current_lang, i18n) - sub_info_text_content = get_text( - "my_subscription_details", - end_date=end_date_obj.strftime("%Y-%m-%d") - if end_date_obj else "N/A", - days_left=max(0, days_left), - status=active_sub_details.get( - 'status_from_panel', get_text('status_active')).capitalize(), - config_link=actual_config_link, - traffic_limit=traffic_limit_gb_str, - traffic_used=traffic_used_gb_str) - else: - sub_info_text_content = get_text("subscription_not_active") - logging.info( - f"User {user.id} no active sub details for 'my_subscription'.") - - reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n) - - if isinstance(event, types.CallbackQuery) and event.message: + if isinstance(event, types.CallbackQuery): + await event.answer() try: - await event.message.edit_text(sub_info_text_content, - reply_markup=reply_markup_val, - parse_mode="HTML", - disable_web_page_preview=True) - except Exception as e_edit: - logging.warning( - f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}." - ) - await bot.send_message(chat_id=target_message_obj.chat.id, - text=sub_info_text_content, - reply_markup=reply_markup_val, - parse_mode="HTML", - disable_web_page_preview=True) + await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + except: + await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) else: - await target_message_obj.answer(sub_info_text_content, - reply_markup=reply_markup_val, - parse_mode="HTML", - disable_web_page_preview=True) + await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) @router.message(Command("connect"))