diff --git a/core/redis_cache.py b/core/redis_cache.py index 71a384c4..4df2770c 100644 --- a/core/redis_cache.py +++ b/core/redis_cache.py @@ -24,7 +24,12 @@ async def _get_redis() -> Any | None: try: redis_from_url = import_module("redis.asyncio").from_url - client = redis_from_url(REDIS_URL, encoding="utf-8", decode_responses=True) + client = redis_from_url( + REDIS_URL, + encoding="utf-8", + decode_responses=True, + max_connections=64, + ) await client.ping() _REDIS_CLIENT = client return _REDIS_CLIENT diff --git a/database/notifications.py b/database/notifications.py index eebd858f..0b9c8e59 100644 --- a/database/notifications.py +++ b/database/notifications.py @@ -1,3 +1,4 @@ +from collections import defaultdict from datetime import datetime, timedelta from sqlalchemy import and_, delete, func, select, tuple_ @@ -47,6 +48,43 @@ async def delete_notification(session: AsyncSession, tg_id: int, notification_ty logger.debug(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено") +async def bulk_add_notifications( + session: AsyncSession, items: list[tuple[int, str]], *, commit: bool = False +) -> None: + """Один запрос: вставка/обновление многих (tg_id, notification_type). Без commit, если commit=False.""" + if not items: + return + now = datetime.utcnow() + stmt = insert(Notification).values( + [ + {"tg_id": tg_id, "notification_type": ntype, "last_notification_time": now} + for tg_id, ntype in items + ] + ).on_conflict_do_update( + index_elements=[Notification.tg_id, Notification.notification_type], + set_={"last_notification_time": now}, + ) + await session.execute(stmt) + if commit: + await session.commit() + logger.info(f"✅ Bulk: добавлено/обновлено {len(items)} уведомлений") + + +async def bulk_delete_notifications( + session: AsyncSession, items: list[tuple[int, str]], *, commit: bool = False +) -> None: + """Один запрос: удаление многих (tg_id, notification_type). Без commit, если commit=False.""" + if not items: + return + stmt = delete(Notification).where( + tuple_(Notification.tg_id, Notification.notification_type).in_(items) + ) + await session.execute(stmt) + if commit: + await session.commit() + logger.debug(f"🗑 Bulk: удалено {len(items)} уведомлений") + + async def check_notification_time(session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12) -> bool: stmt = select(Notification.last_notification_time).where( Notification.tg_id == tg_id, Notification.notification_type == notification_type @@ -135,6 +173,34 @@ async def get_last_notification_times_bulk( return out +_HOT_LEAD_NOTIFICATION_TYPES = ( + "hot_lead_step_1", + "hot_lead_step_2", + "hot_lead_step_3", + "hot_lead_step_2_expired", +) + + +async def get_hot_lead_notification_flags( + session: AsyncSession, tg_ids: list[int] +) -> dict[int, set[str]]: + """ + Один запрос: для каждого tg_id возвращает множество типов уведомлений hot_lead_*, + которые у него уже есть. Используется в notify_hot_leads для устранения N+1. + """ + if not tg_ids: + return {} + stmt = select(Notification.tg_id, Notification.notification_type).where( + Notification.tg_id.in_(tg_ids), + Notification.notification_type.in_(_HOT_LEAD_NOTIFICATION_TYPES), + ) + result = await session.execute(stmt) + out = defaultdict(set) + for tg_id, ntype in result.all(): + out[tg_id].add(ntype) + return dict(out) + + async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict: try: result = await session.execute( diff --git a/database/tariffs.py b/database/tariffs.py index bbe1b7ed..f06f43a4 100644 --- a/database/tariffs.py +++ b/database/tariffs.py @@ -253,6 +253,32 @@ async def check_tariff_exists(session: AsyncSession, tariff_id: int): return False +async def get_vless_enabled(session: AsyncSession, tariff_id: int | None) -> bool: + """Возвращает, включён ли VLESS у тарифа (по кэшированному get_tariff_by_id).""" + if not tariff_id: + return False + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + return False + return bool(tariff.get("vless")) + + +async def get_vless_enabled_batch( + session: AsyncSession, tariff_ids: list[int] +) -> dict[int, bool]: + """ + Один запрос: для списка tariff_id возвращает dict[tariff_id -> vless]. + Использовать в списках ключей вместо N вызовов get_vless_enabled. + """ + if not tariff_ids: + return {} + unique_ids = list(dict.fromkeys(tariff_ids)) + result = await session.execute( + select(Tariff.id, Tariff.vless).where(Tariff.id.in_(unique_ids)) + ) + return {row[0]: bool(row[1]) for row in result.all()} + + async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int: try: result = await session.execute(select(Tariff.sort_order).where(Tariff.id == tariff_id)) diff --git a/handlers/admin/coupons/coupons_handler.py b/handlers/admin/coupons/coupons_handler.py index 9d60f9ff..f34b57e8 100644 --- a/handlers/admin/coupons/coupons_handler.py +++ b/handlers/admin/coupons/coupons_handler.py @@ -17,7 +17,7 @@ from config import INLINE_MODE, USERNAME_BOT from database import create_coupon, delete_coupon, get_all_coupons from filters.admin import IsAdminFilter from handlers.buttons import BACK -from handlers.utils import format_days +from handlers.utils import format_days, safe_answer_inline_query from logger import logger from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb @@ -432,7 +432,8 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any): coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None) if not coupon: - await inline_query.answer( + await safe_answer_inline_query( + inline_query, results=[], switch_pm_text="Купон не найден", switch_pm_parameter="coupons", @@ -442,7 +443,8 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any): percent_value = coupon.get("percent") if percent_value is not None and int(percent_value) > 0: - await inline_query.answer( + await safe_answer_inline_query( + inline_query, results=[], switch_pm_text="Процентные купоны не публикуются ссылкой", switch_pm_parameter="coupons", @@ -485,4 +487,4 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any): reply_markup=builder.as_markup(), ) - await inline_query.answer(results=[result], cache_time=86400, is_personal=True) + await safe_answer_inline_query(inline_query, results=[result], cache_time=86400, is_personal=True) diff --git a/handlers/keys/key_mode/key_cluster_mode.py b/handlers/keys/key_mode/key_cluster_mode.py index c7c32408..f2c5779c 100644 --- a/handlers/keys/key_mode/key_cluster_mode.py +++ b/handlers/keys/key_mode/key_cluster_mode.py @@ -33,11 +33,11 @@ from handlers.buttons import ( TV_BUTTON, ) from handlers.keys.operations import create_key_on_cluster +from database import get_vless_enabled from handlers.tariffs.tariff_display import ( build_key_created_message, get_effective_limits_for_key, resolve_price_to_charge, - resolve_vless_enabled, ) from handlers.utils import ( edit_or_send_message, @@ -202,9 +202,9 @@ async def key_cluster_mode( vless_enabled = False try: if plan: - vless_enabled = await resolve_vless_enabled(session, plan) + vless_enabled = await get_vless_enabled(session, plan) elif key_record.get("tariff_id"): - vless_enabled = await resolve_vless_enabled(session, key_record["tariff_id"]) + vless_enabled = await get_vless_enabled(session, key_record["tariff_id"]) except Exception: vless_enabled = False diff --git a/handlers/keys/key_view.py b/handlers/keys/key_view.py index 01afe332..ae35eacc 100644 --- a/handlers/keys/key_view.py +++ b/handlers/keys/key_view.py @@ -52,6 +52,7 @@ from handlers.buttons import ( TV_BUTTON, UNFREEZE, ) +from database import get_vless_enabled_batch from handlers.tariffs.tariff_display import GB, get_key_tariff_addons_state from handlers.texts import ( DAYS_LEFT_MESSAGE, @@ -70,6 +71,7 @@ from handlers.utils import ( format_minutes, get_russian_month, is_full_remnawave_cluster, + safe_answer_callback, ) from hooks.hook_buttons import insert_hook_buttons from hooks.processors import ( @@ -153,6 +155,16 @@ async def build_keys_response(records: list[Key] | None, session: AsyncSession, end = start + page_size page_records = records[start:end] + tariff_ids = [] + for record in page_records: + tid = getattr(record, "tariff_id", None) + if tid is not None: + try: + tariff_ids.append(int(tid)) + except (TypeError, ValueError): + pass + vless_by_tariff = await get_vless_enabled_batch(session, tariff_ids) if tariff_ids else {} + for record in page_records: alias = record.alias email = record.email @@ -167,14 +179,8 @@ async def build_keys_response(records: list[Key] | None, session: AsyncSession, else: formatted_date_full = "без срока действия" - is_vless = False - if getattr(record, "tariff_id", None): - try: - from handlers.tariffs.tariff_display import resolve_vless_enabled - - is_vless = await resolve_vless_enabled(session, int(record.tariff_id)) - except Exception: - is_vless = False + tid = getattr(record, "tariff_id", None) + is_vless = vless_by_tariff.get(int(tid), False) if tid is not None else False icon = "📶" if is_vless else "🔑" @@ -232,7 +238,7 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext, session: client_id = callback.data.split("|")[1] key_row = (await session.execute(select(Key).where(Key.client_id == client_id))).scalar_one_or_none() if not key_row or key_row.tg_id != callback.from_user.id: - await callback.answer("Доступ запрещён.", show_alert=True) + await safe_answer_callback(callback, "Доступ запрещён.", show_alert=True) return await state.set_state(RenameKeyState.waiting_for_new_alias) await state.update_data(client_id=client_id) @@ -289,7 +295,7 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Asyn key_name = callback_query.data.split("|")[1] record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): - await callback_query.answer("Доступ запрещён.", show_alert=True) + await safe_answer_callback(callback_query, "Доступ запрещён.", show_alert=True) return image_path = os.path.join("img", "pic_view.jpg") await render_key_info(callback_query.message, session, key_name, image_path) @@ -489,20 +495,20 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: AsyncSession record = await get_key_details(session, key_name) if not record: - await callback_query.answer("❌ Ключ не найден.", show_alert=True) + await safe_answer_callback(callback_query, "❌ Ключ не найден.", show_alert=True) return if not key_owned_by_user(record, callback_query.from_user.id): - await callback_query.answer("Доступ запрещён.", show_alert=True) + await safe_answer_callback(callback_query, "Доступ запрещён.", show_alert=True) return client_id = record.get("client_id") if not client_id: - await callback_query.answer("❌ У ключа отсутствует client_id.", show_alert=True) + await safe_answer_callback(callback_query, "❌ У ключа отсутствует client_id.", show_alert=True) return remna_api_url = await resolve_remnawave_api_url(session, str(record.get("server_id") or ""), fallback_any=True) if not remna_api_url: - await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True) + await safe_answer_callback(callback_query, "❌ Remnawave-сервер не найден.", show_alert=True) return async def _reset_devices(api): @@ -523,7 +529,7 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: AsyncSession timeout_sec=12.0, ) if reset_result is None: - await callback_query.answer("❌ Авторизация в Remnawave не удалась.", show_alert=True) + await safe_answer_callback(callback_query, "❌ Авторизация в Remnawave не удалась.", show_alert=True) return total, deleted = reset_result @@ -534,9 +540,9 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: AsyncSession fallback_any=True, ) if total == 0: - await callback_query.answer("✅ Устройства не были привязаны.", show_alert=True) + await safe_answer_callback(callback_query, "✅ Устройства не были привязаны.", show_alert=True) else: - await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True) + await safe_answer_callback(callback_query, f"✅ Устройства сброшены ({deleted})", show_alert=True) if await process_after_hwid_reset( chat_id=callback_query.from_user.id, diff --git a/handlers/notifications/general_notifications.py b/handlers/notifications/general_notifications.py index 79228ba9..635926e5 100644 --- a/handlers/notifications/general_notifications.py +++ b/handlers/notifications/general_notifications.py @@ -26,6 +26,8 @@ from config import ( from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG from database import ( add_notification, + bulk_add_notifications, + bulk_delete_notifications, check_notification_time, check_notification_time_bulk, check_notifications_bulk, @@ -144,34 +146,51 @@ async def preload_notification_data(session: AsyncSession) -> dict[str, Any]: async def execute_bulk_updates(session: AsyncSession, bulk_updates: dict[str, Any]) -> None: try: - if bulk_updates["balance_changes"]: - for tg_id, balance_change in bulk_updates["balance_changes"].items(): - await session.execute( - text("UPDATE users SET balance = balance + :change WHERE tg_id = :tg_id"), - {"change": balance_change, "tg_id": tg_id}, + balance_changes = bulk_updates.get("balance_changes") or {} + if balance_changes: + tg_ids = list(balance_changes.keys()) + changes = [balance_changes[tg_id] for tg_id in tg_ids] + await session.execute( + text( + "UPDATE users SET balance = balance + v.change FROM " + "(SELECT unnest(CAST(:tg_ids AS bigint[])) AS tg_id, unnest(CAST(:changes AS double precision[])) AS change) AS v " + "WHERE users.tg_id = v.tg_id" + ), + {"tg_ids": tg_ids, "changes": changes}, + ) + logger.info(f"Bulk: обновлено {len(balance_changes)} балансов") + + key_expiry = bulk_updates.get("key_expiry_updates") or [] + if key_expiry: + await session.run_sync( + lambda sync_sess: sync_sess.bulk_update_mappings( + Key, + [{"client_id": cid, "expiry_time": exp} for cid, exp in key_expiry], ) - logger.info(f"Bulk: обновлено {len(bulk_updates['balance_changes'])} балансов") + ) + logger.info(f"Bulk: обновлено {len(key_expiry)} сроков действия ключей") - if bulk_updates["key_expiry_updates"]: - for client_id, new_expiry in bulk_updates["key_expiry_updates"]: - await session.execute(update(Key).where(Key.client_id == client_id).values(expiry_time=new_expiry)) - logger.info(f"Bulk: обновлено {len(bulk_updates['key_expiry_updates'])} сроков действия ключей") + key_tariff = bulk_updates.get("key_tariff_updates") or [] + if key_tariff: + await session.run_sync( + lambda sync_sess: sync_sess.bulk_update_mappings( + Key, + [{"client_id": cid, "tariff_id": tid} for cid, tid in key_tariff], + ) + ) + logger.info(f"Bulk: обновлено {len(key_tariff)} тарифов ключей") - if bulk_updates["key_tariff_updates"]: - for client_id, new_tariff_id in bulk_updates["key_tariff_updates"]: - await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=new_tariff_id)) - logger.info(f"Bulk: обновлено {len(bulk_updates['key_tariff_updates'])} тарифов ключей") + to_add = bulk_updates.get("notifications_to_add") or [] + if to_add: + await bulk_add_notifications(session, to_add, commit=False) - for tg_id, notification_type in bulk_updates["notifications_to_add"]: - await add_notification(session, tg_id, notification_type) + to_delete = bulk_updates.get("notifications_to_delete") or [] + if to_delete: + await bulk_delete_notifications(session, to_delete, commit=False) - for tg_id, notification_type in bulk_updates["notifications_to_delete"]: - await delete_notification(session, tg_id, notification_type) - - if bulk_updates["notifications_to_add"] or bulk_updates["notifications_to_delete"]: + if to_add or to_delete: logger.info( - f"Bulk: обработано {len(bulk_updates['notifications_to_add'])} добавлений " - f"и {len(bulk_updates['notifications_to_delete'])} удалений уведомлений" + f"Bulk: обработано {len(to_add)} добавлений и {len(to_delete)} удалений уведомлений" ) await session.commit() diff --git a/handlers/notifications/hot_leads_notifications.py b/handlers/notifications/hot_leads_notifications.py index 26217f64..f94903da 100644 --- a/handlers/notifications/hot_leads_notifications.py +++ b/handlers/notifications/hot_leads_notifications.py @@ -1,13 +1,16 @@ from aiogram import Bot from aiogram.types import InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from config import DISCOUNT_ACTIVE_HOURS, HOT_LEAD_INTERVAL_HOURS from core.bootstrap import NOTIFICATIONS_CONFIG -from database import add_notification, check_notification_time, get_hot_leads -from database.models import Notification +from database import ( + add_notification, + check_notification_time_bulk, + get_hot_lead_notification_flags, + get_hot_leads, +) from database.tariffs import get_tariffs from handlers.buttons import MAIN_MENU from handlers.notifications.notify_kb import build_hot_lead_kb @@ -28,38 +31,48 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession): try: leads = await get_hot_leads(session) + if not leads: + logger.info("Нет горячих лидов для уведомлений.") + return + + flags = await get_hot_lead_notification_flags(session, leads) + can_send_after_step1 = await check_notification_time_bulk( + session, [(tid, "hot_lead_step_1") for tid in leads], hot_lead_interval_hours + ) + step2_expired_can_send = await check_notification_time_bulk( + session, [(tid, "hot_lead_step_2") for tid in leads], discount_active_hours + ) + can_send_after_step2 = await check_notification_time_bulk( + session, [(tid, "hot_lead_step_2") for tid in leads], hot_lead_interval_hours + ) + + discount_tariffs = await get_tariffs(session, group_code="discounts") + active_discount_tariffs = [t for t in discount_tariffs if t.get("is_active")] + discount_max_tariffs = await get_tariffs(session, group_code="discounts_max") + active_discount_max_tariffs = [t for t in discount_max_tariffs if t.get("is_active")] + notified = 0 for tg_id in leads: - has_step_1 = await session.scalar( - select(select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_1").exists()) - ) + step_flags = flags.get(tg_id, set()) + has_step_1 = "hot_lead_step_1" in step_flags + has_step_2 = "hot_lead_step_2" in step_flags + has_step_3 = "hot_lead_step_3" in step_flags + has_expired_notification = "hot_lead_step_2_expired" in step_flags + if not has_step_1: await add_notification(session, tg_id, "hot_lead_step_1") logger.info(f"[HOT LEAD] Шаг 1 — зафиксировано без отправки: {tg_id}") continue - has_step_2 = await session.scalar( - select(select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_2").exists()) - ) if not has_step_2: - can_send = await check_notification_time( - session, - tg_id=tg_id, - notification_type="hot_lead_step_1", - hours=hot_lead_interval_hours, - ) - if not can_send: + if (tg_id, "hot_lead_step_1") not in can_send_after_step1: continue - - discount_tariffs = await get_tariffs(session, group_code="discounts") - active_discount_tariffs = [t for t in discount_tariffs if t.get("is_active")] if not active_discount_tariffs: logger.warning( f"[HOT LEAD] Пропуск шага 2 для {tg_id}: нет активных тарифов со скидкой (discounts)" ) continue - keyboard = build_hot_lead_kb() result = await send_notification(bot, tg_id, None, HOT_LEAD_MESSAGE, keyboard) if result: @@ -68,25 +81,10 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession): notified += 1 continue - has_step_3 = await session.scalar( - select(select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_3").exists()) - ) - has_expired_notification = await session.scalar( - select( - select(Notification).filter_by(tg_id=tg_id, notification_type="hot_lead_step_2_expired").exists() - ) - ) if not has_step_3 and not has_expired_notification: - expired = await check_notification_time( - session, - tg_id=tg_id, - notification_type="hot_lead_step_2", - hours=discount_active_hours, - ) - if expired: + if (tg_id, "hot_lead_step_2") in step2_expired_can_send: builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - result = await send_notification( bot, tg_id, @@ -97,26 +95,16 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession): if result: await add_notification(session, tg_id, "hot_lead_step_2_expired") logger.info(f"📭 Скидка упущена — отправлено уведомление: {tg_id}") - continue + continue if not has_step_3: - can_send = await check_notification_time( - session, - tg_id=tg_id, - notification_type="hot_lead_step_2", - hours=hot_lead_interval_hours, - ) - if not can_send: + if (tg_id, "hot_lead_step_2") not in can_send_after_step2: continue - - discount_max_tariffs = await get_tariffs(session, group_code="discounts_max") - active_discount_max_tariffs = [t for t in discount_max_tariffs if t.get("is_active")] if not active_discount_max_tariffs: logger.warning( f"[HOT LEAD] Пропуск шага 3 для {tg_id}: нет активных тарифов с максимальной скидкой (discounts_max)" ) continue - keyboard = build_hot_lead_kb(final=True) result = await send_notification(bot, tg_id, None, HOT_LEAD_FINAL_MESSAGE, keyboard) if result: diff --git a/handlers/notifications/notify_utils.py b/handlers/notifications/notify_utils.py index 59c4f940..dc6e2323 100644 --- a/handlers/notifications/notify_utils.py +++ b/handlers/notifications/notify_utils.py @@ -358,7 +358,7 @@ async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int) device_limit = 0 try: - name, subgroup_title, traffic_limit_gb, device_limit, _ = await get_key_tariff_display( + name, subgroup_title, traffic_limit_gb, device_limit, _, _ = await get_key_tariff_display( session=session, key_record=record, ) diff --git a/handlers/payments/fast_payment_flow.py b/handlers/payments/fast_payment_flow.py index 983e4ea4..9eedacb9 100644 --- a/handlers/payments/fast_payment_flow.py +++ b/handlers/payments/fast_payment_flow.py @@ -390,11 +390,11 @@ async def fastflow_apply_coupon(message: Message, state: FSMContext, session: An required_amount_new = int(max(0, ceil(float(new_price) - float(balance_now)))) + percent_value = int(getattr(coupon, "percent", 0) or 0) + await create_coupon_usage(session, coupon.id, message.from_user.id) await update_coupon_usage_count(session, coupon.id) - percent_value = int(getattr(coupon, "percent", 0) or 0) - temp_payload_updated = dict(temp_payload) temp_payload_updated["required_amount"] = int(required_amount_new) if "selected_price_rub" in temp_payload_updated: diff --git a/handlers/refferal.py b/handlers/refferal.py index cd01eb4f..475ef29c 100644 --- a/handlers/refferal.py +++ b/handlers/refferal.py @@ -43,7 +43,7 @@ from handlers.texts import ( from logger import logger from .texts import get_referral_link -from .utils import edit_or_send_message, format_days +from .utils import edit_or_send_message, format_days, safe_answer_inline_query router = Router() @@ -139,7 +139,7 @@ async def inline_referral_handler(inline_query: InlineQuery, session: AsyncSessi trial_tariffs = await get_tariffs(session, group_code="trial") if not trial_tariffs: - await inline_query.answer(results=[], cache_time=0) + await safe_answer_inline_query(inline_query, results=[], cache_time=0) return trial_days = trial_tariffs[0]["duration_days"] @@ -168,7 +168,7 @@ async def inline_referral_handler(inline_query: InlineQuery, session: AsyncSessi ) ) - await inline_query.answer(results=results, cache_time=60, is_personal=True) + await safe_answer_inline_query(inline_query, results=results, cache_time=60, is_personal=True) @router.callback_query(F.data.startswith("show_referral_qr|")) diff --git a/handlers/start.py b/handlers/start.py index 6aebb2b5..6a7c31c1 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -66,7 +66,7 @@ from middlewares.session import release_session_early from .admin.panel.keyboard import AdminPanelCallback from .refferal import handle_referral_link -from .utils import edit_or_send_message, extract_user_data +from .utils import edit_or_send_message, extract_user_data, safe_answer_callback router = Router() @@ -127,7 +127,7 @@ async def check_subscription_callback(callback: CallbackQuery, state: FSMContext if member.status not in ["member", "administrator", "creator"]: await prompt_subscription(callback) return - await callback.answer(SUBSCRIPTION_CONFIRMED_MSG) + await safe_answer_callback(callback, SUBSCRIPTION_CONFIRMED_MSG) data = await state.get_data() original_text = data.get("original_text") or callback.message.text user_data = data.get("user_data") or extract_user_data(callback.from_user) @@ -135,7 +135,7 @@ async def check_subscription_callback(callback: CallbackQuery, state: FSMContext await process_start_logic(callback.message, state, session, admin, original_text, user_data) except Exception as e: logger.error(f"[CALLBACK] Ошибка подписки: {e}", exc_info=True) - await callback.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True) + await safe_answer_callback(callback, SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True) async def process_start_logic( @@ -229,11 +229,7 @@ async def process_start_logic( async def handle_coupon_link(part, message, state, session, admin, user_data): code = part.split("coupons")[1].strip("_") - coupon = await get_coupon_by_code(session, code) - if coupon: - await activate_coupon(message, state, session, code, admin=admin, user_data=user_data) - if getattr(coupon, "days", None): - return + await activate_coupon(message, state, session, code, admin=admin, user_data=user_data) async def handle_gift(part, message, state, session, user_data): @@ -268,7 +264,7 @@ async def handle_referral_link_safe(part, message, state, session, user_data): async def prompt_subscription(callback: CallbackQuery): - await callback.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True) + await safe_answer_callback(callback, NOT_SUBSCRIBED_YET_MSG, show_alert=True) kb = InlineKeyboardBuilder() kb.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL)) kb.row(InlineKeyboardButton(text=SUB_CHANELL_DONE, callback_data="check_subscription")) diff --git a/handlers/tariffs/tariff_display.py b/handlers/tariffs/tariff_display.py index 9a1239eb..950e8af5 100644 --- a/handlers/tariffs/tariff_display.py +++ b/handlers/tariffs/tariff_display.py @@ -18,9 +18,11 @@ async def get_effective_limits_for_key( tariff_id: int | None, selected_device_limit: int | None, selected_traffic_gb: int | None, + tariff: dict | None = None, ) -> tuple[int, int]: - """Возвращает лимиты устройств и трафика с учётом выбранных значений.""" - tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None + """Возвращает лимиты устройств и трафика с учётом выбранных значений. tariff опционален — если передан, get_tariff_by_id не вызывается.""" + if tariff is None and tariff_id: + tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff: base_devices = tariff.get("device_limit") @@ -137,18 +139,6 @@ async def resolve_price_to_charge(session: AsyncSession, state_data: dict[str, A return int(base_price + device_add_rub + traffic_add_rub) -async def resolve_vless_enabled(session: AsyncSession, tariff_id: int | None) -> bool: - """Проверяет, включён ли VLESS в тарифе.""" - if not tariff_id: - return False - - tariff = await get_tariff_by_id(session, int(tariff_id)) - if not tariff: - return False - - return bool(tariff.get("vless")) - - async def get_key_tariff_display( session: AsyncSession, key_record: dict[str, Any], @@ -158,8 +148,9 @@ async def get_key_tariff_display( """Возвращает отображение тарифа и эффективные лимиты, приоритет — данные панели.""" tariff_id = key_record.get("tariff_id") if not tariff_id: - return "", "", 0, 0, False + return "", "", 0, 0, False, None + tariff = await get_tariff_by_id(session, int(tariff_id)) selected_device_limit = selected_device_limit_override selected_traffic_gb = selected_traffic_gb_override @@ -184,6 +175,7 @@ async def get_key_tariff_display( tariff_id=int(tariff_id), selected_device_limit=selected_device_limit, selected_traffic_gb=selected_traffic_gb, + tariff=tariff, ) server_cluster_id = key_record.get("server_id") @@ -216,7 +208,6 @@ async def get_key_tariff_display( traffic_limit_gb = int(traffic_limit_bytes / GB) if traffic_limit_bytes else 0 - tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff: tariff_name = tariff.get("name", "—") subgroup_title = tariff.get("subgroup_title") or "" @@ -226,7 +217,7 @@ async def get_key_tariff_display( subgroup_title = "" vless_enabled = False - return tariff_name, subgroup_title, traffic_limit_gb, device_limit, vless_enabled + return tariff_name, subgroup_title, traffic_limit_gb, device_limit, vless_enabled, tariff async def get_key_tariff_addons_state( @@ -260,6 +251,7 @@ async def get_key_tariff_addons_state( traffic_limit_gb, device_limit, vless_enabled, + tariff, ) = await get_key_tariff_display( session=session, key_record=key_record, @@ -282,7 +274,6 @@ async def get_key_tariff_addons_state( addons_devices_enabled = False addons_traffic_enabled = False - tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff and tariff.get("configurable"): is_tariff_configurable = True diff --git a/handlers/utils.py b/handlers/utils.py index 067e02cd..bc73fe9a 100644 --- a/handlers/utils.py +++ b/handlers/utils.py @@ -13,6 +13,7 @@ from aiogram.types import ( BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, + InlineQuery, InputMediaAnimation, InputMediaPhoto, InputMediaVideo, @@ -58,6 +59,19 @@ async def safe_answer_callback(callback_query: CallbackQuery, text: str | None = raise +async def safe_answer_inline_query(inline_query: InlineQuery, *args: object, **kwargs: object) -> None: + """ + Вызывает inline_query.answer(), не поднимая исключение при устаревшем запросе + (query is too old / response timeout). При нагрузке inline может обрабатываться с задержкой. + """ + try: + await inline_query.answer(*args, **kwargs) + except TelegramBadRequest as e: + msg = str(e).lower() + if not any(phrase in msg for phrase in _CALLBACK_ANSWER_IGNORE): + raise + + async def generate_random_email( length: int = 8, session: AsyncSession | None = None, @@ -391,7 +405,8 @@ async def edit_or_send_message( edit_or_send_message.cache.popitem(last=False) return - if not force_text and target_message.caption is not None: + caption = getattr(target_message, "caption", None) + if not force_text and caption is not None: try: await target_message.edit_caption(caption=text, reply_markup=reply_markup) return