refactoring and optimization
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+23
-17
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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|"))
|
||||
|
||||
+5
-9
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user