Improved hot lead logic

This commit is contained in:
Capybara-z
2025-08-29 00:06:47 +03:00
parent cd9b92d036
commit 05c591336b
10 changed files with 305 additions and 74 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ async def get_hot_leads(session: AsyncSession):
"""
Возвращает пользователей, у которых есть успешные оплаты, но нет активных ключей.
"""
subquery = select(Key.tg_id).distinct()
subquery = select(Key.tg_id).where(Key.expiry_time > func.extract("epoch", func.now()) * 1000).distinct()
stmt = (
select(Payment.tg_id)
@@ -20,4 +20,4 @@ async def get_hot_leads(session: AsyncSession):
)
result = await session.execute(stmt)
return [row.tg_id for row in result]
return [row.tg_id for row in result]
+7
View File
@@ -5,6 +5,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, User
from database.notifications import clear_hot_lead_notifications
from logger import logger
@@ -41,6 +42,12 @@ async def store_key(
session.add(new_key)
await session.commit()
logger.info(f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
try:
await clear_hot_lead_notifications(session, tg_id)
except Exception as e:
pass
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
+52
View File
@@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from database.models import Key, Notification, User
from logger import logger
@@ -64,6 +65,57 @@ async def get_last_notification_time(session: AsyncSession, tg_id: int, notifica
return None
async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
try:
result = await session.execute(
select(Notification.notification_type, Notification.last_notification_time)
.where(Notification.tg_id == tg_id)
.where(Notification.notification_type.in_(['hot_lead_step_2', 'hot_lead_step_3']))
.order_by(Notification.last_notification_time.desc())
.limit(1)
)
row = result.first()
if not row:
return {"available": False}
notification_type, last_time = row
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
current_time = datetime.utcnow()
if current_time > expires_at:
return {"available": False}
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
return {
"available": True,
"type": notification_type,
"tariff_group": tariff_group,
"expires_at": expires_at
}
except Exception as e:
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
return {"available": False}
async def clear_hot_lead_notifications(session: AsyncSession, tg_id: int):
try:
await session.execute(
delete(Notification).where(
Notification.tg_id == tg_id,
Notification.notification_type.in_(['hot_lead_step_1', 'hot_lead_step_2', 'hot_lead_step_3', 'hot_lead_step_2_expired'])
)
)
await session.commit()
logger.info(f"✅ Уведомления о скидках горячих лидов очищены для пользователя {tg_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при очистке уведомлений о скидках для {tg_id}: {e}")
await session.rollback()
async def check_notifications_bulk(
session: AsyncSession,
notification_type: str,
+43 -3
View File
@@ -16,6 +16,7 @@ from config import (
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
DISCOUNT_ACTIVE_HOURS,
)
from database import (
add_user,
@@ -26,6 +27,7 @@ from database import (
get_tariffs_for_cluster,
get_trial,
)
from database.notifications import check_hot_lead_discount
from database.models import Admin
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.admin.panel.keyboard import AdminPanelCallback
@@ -40,7 +42,7 @@ from handlers.texts import (
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message, get_least_loaded_cluster
from handlers.utils import edit_or_send_message, get_least_loaded_cluster, format_discount_time_left
from logger import logger
from utils.modules_loader import load_module_fast_flow_handlers
@@ -142,9 +144,21 @@ async def handle_key_creation(
tariffs = await get_tariffs_for_cluster(session, cluster_name)
discount_info = None
subgroup_weights = {}
if tariffs:
group_code = tariffs[0].get("group_code")
if group_code:
from database.notifications import check_hot_lead_discount
discount_info = await check_hot_lead_discount(session, tg_id)
if discount_info and discount_info.get("available"):
group_code = discount_info["tariff_group"]
await state.update_data(discount_info=discount_info)
else:
await state.update_data(discount_info=None)
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
tariffs = [t for t in tariffs_data['tariffs'] if t.get('is_active')]
subgroup_weights = tariffs_data['subgroup_weights']
@@ -211,7 +225,7 @@ async def handle_key_creation(
sorted_subgroups = sorted(
[k for k in grouped_tariffs if k],
key=lambda x: (subgroup_weights.get(x, 999999), x)
key=lambda x: (subgroup_weights.get(x, 999999) if subgroup_weights else 999999, x)
)
for subgroup in sorted_subgroups:
@@ -227,9 +241,23 @@ async def handle_key_creation(
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
discount_message = ""
if discount_info and discount_info.get("available"):
discount_message = f"\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
if discount_info["type"] == "hot_lead_step_2":
discount_message += "💎 <b>Вам открыт доступ к специальным тарифам</b>\n"
discount_message += "🚀 <b>Эксклюзивные предложения</b> - доступны только для вас!\n"
else:
discount_message += "💎 <b>Вам открыт доступ к МАКСИМАЛЬНО выгодным тарифам</b>\n"
discount_message += "🚀 <b>VIP предложения</b> - максимальная выгода!\n"
expires_at = discount_info["expires_at"]
discount_message += f"</blockquote>\n⏰ <b>Предложение действует только: {format_discount_time_left(expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS)}</b>, не упустите свой шанс!"
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
text=SELECT_TARIFF_PLAN_MSG + discount_message,
reply_markup=builder.as_markup(),
)
@@ -313,6 +341,18 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
return
discount_info = await check_hot_lead_discount(session, tg_id)
if tariff.get("group_code") in ["discounts", "discounts_max"]:
if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback_query.message.edit_text(
"❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
reply_markup=builder.as_markup()
)
return
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
+57 -18
View File
@@ -1,15 +1,18 @@
from datetime import datetime, timedelta
from aiogram import F, Router
from aiogram.types import CallbackQuery
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from database import get_tariffs
from database import get_tariffs, get_keys
from database.models import Notification
from handlers.utils import format_discount_time_left
from handlers.notifications.notify_kb import build_tariffs_keyboard
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
from handlers.buttons import RENEW_KEY_NOTIFICATION, MAIN_MENU
from logger import logger
from .key_create import select_tariff_plan
@@ -39,15 +42,33 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
return
tariffs = await get_tariffs(session=session, group_code="discounts")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
keys = await get_keys(session, tg_id)
if keys and len(keys) > 0:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(
text=RENEW_KEY_NOTIFICATION,
callback_data=f"renew_key|{keys[0].email}"
))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback.message.edit_text(
"🔥 <b>Скидка активна!</b>\n\n"
"💎 <b>Скидка</b> на все тарифы\n"
f"⏰ Осталось: <b>{format_discount_time_left(last_time, DISCOUNT_ACTIVE_HOURS)}</b>\n\n"
"У вас есть просроченная подписка. Продлите её со скидкой!",
reply_markup=builder.as_markup()
)
else:
tariffs = await get_tariffs(session=session, group_code="discounts")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
await callback.message.edit_text(
DISCOUNT_TARIFF,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
@router.callback_query(F.data.startswith("discount_tariff|"))
@@ -89,12 +110,30 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
return
tariffs = await get_tariffs(session, group_code="discounts_max")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
keys = await get_keys(session, tg_id)
if keys and len(keys) > 0:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(
text=RENEW_KEY_NOTIFICATION,
callback_data=f"renew_key|{keys[0].email}"
))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback.message.edit_text(
"🔥 <b>Максимальная скидка активна!</b>\n\n"
"💎 <b>Максимальная скидка</b> на все тарифы\n"
f"⏰ Осталось: <b>{format_discount_time_left(last_time, DISCOUNT_ACTIVE_HOURS)}</b>\n\n"
"У вас есть просроченная подписка. Продлите её с максимальной скидкой!",
reply_markup=builder.as_markup()
)
else:
tariffs = await get_tariffs(session, group_code="discounts_max")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF_MAX,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
await callback.message.edit_text(
DISCOUNT_TARIFF_MAX,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
+57 -4
View File
@@ -13,7 +13,7 @@ from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import USE_NEW_PAYMENT_FLOW
from config import USE_NEW_PAYMENT_FLOW, DISCOUNT_ACTIVE_HOURS
from database import (
check_tariff_exists,
create_temporary_data,
@@ -26,6 +26,7 @@ from database import (
update_key_expiry,
)
from database.models import Key, Server
from database.notifications import check_hot_lead_discount
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT
from handlers.keys.operations import renew_key_in_cluster
@@ -40,7 +41,7 @@ from handlers.texts import (
PLAN_SELECTION_MSG,
get_renewal_message,
)
from handlers.utils import edit_or_send_message, get_russian_month
from handlers.utils import edit_or_send_message, get_russian_month, format_discount_time_left
from hooks.hooks import run_hooks
from hooks.hook_buttons import insert_hook_buttons
from logger import logger
@@ -99,6 +100,11 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts", "trial"]:
group_code = current_tariff["group_code"]
discount_info = await check_hot_lead_discount(session, tg_id)
if discount_info.get("available"):
group_code = discount_info["tariff_group"]
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
tariffs = [t for t in tariffs_data['tariffs'] if t.get('is_active')]
subgroup_weights = tariffs_data['subgroup_weights']
@@ -152,10 +158,24 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
final_markup = builder.as_markup()
balance = await get_balance(session, tg_id)
discount_message = ""
if discount_info.get("available"):
discount_message = f"\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
if discount_info["type"] == "hot_lead_step_2":
discount_message += "💎 <b>Вам открыт доступ к специальным тарифам</b> для продления\n"
discount_message += "🚀 <b>Эксклюзивные предложения</b> - доступны только для вас!\n"
else:
discount_message += "💎 <b>Вам открыт доступ к МАКСИМАЛЬНО выгодным тарифам</b> для продления\n"
discount_message += "🚀 <b>VIP предложения</b> - максимальная выгода!\n"
expires_at = discount_info["expires_at"]
discount_message += f"</blockquote>\n⏰ <b>Предложение действует только: {format_discount_time_left(expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS)}, не упустите свой шанс!</b>"
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
) + discount_message
await edit_or_send_message(
target_message=callback_query.message,
@@ -209,6 +229,12 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
group_code = row[0]
tg_id = callback.from_user.id
discount_info = await check_hot_lead_discount(session, tg_id)
if discount_info.get("available"):
group_code = discount_info["tariff_group"]
subgroup = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup:
await callback.message.answer("❌ Подгруппа не найдена.")
@@ -250,9 +276,22 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
logger.warning(f"[RENEW_SUBGROUP] Ошибка при применении хуков: {e}")
final_markup = builder.as_markup()
discount_message = ""
if discount_info.get("available"):
discount_message = f"\n\n🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n<blockquote>"
if discount_info["type"] == "hot_lead_step_2":
discount_message += "💎 <b>Вам открыт доступ к специальным тарифам</b> для продления\n"
discount_message += "🚀 <b>Эксклюзивные предложения</b> - доступны только для вас!\n"
else:
discount_message += "💎 <b>Вам открыт доступ к МАКСИМАЛЬНО выгодным тарифам</b> для продления\n"
discount_message += "🚀 <b>VIP предложения</b> - максимальная выгода!\n"
expires_at = discount_info["expires_at"]
discount_message += f"</blockquote>\n⏰ <b>Предложение действует только: {format_discount_time_left(expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS)}, не упустите свой шанс!</b>"
await edit_or_send_message(
target_message=callback.message,
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
text=f"<b>{subgroup}</b>\n\nВыберите тариф:{discount_message}",
reply_markup=final_markup,
)
@@ -281,6 +320,18 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
await callback_query.message.answer("❌ Тариф не найден или отключён.")
return
discount_info = await check_hot_lead_discount(session, tg_id)
if tariff.get("group_code") in ["discounts", "discounts_max"]:
if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback_query.message.answer(
"❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
reply_markup=builder.as_markup()
)
return
duration_days = tariff["duration_days"]
cost = tariff["price_rub"]
total_gb = tariff["traffic_limit"] or 0
@@ -470,3 +521,5 @@ async def complete_key_renewal(
except Exception as e:
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
+12
View File
@@ -391,6 +391,18 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
deleted += 1
await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
hook_result = await run_hooks("after_hwid_reset", chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name)
if hook_result and any("redirect_to_profile" in str(result) for result in hook_result):
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if callback_query.message.text:
await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=kb.as_markup())
else:
await callback_query.message.edit_caption(caption="✅ Устройства сброшены", reply_markup=kb.as_markup())
return
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
+6
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import delete_notification, get_servers
from database.models import Key, Server, Tariff
from database.notifications import clear_hot_lead_notifications
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import ClientConfig, add_client, extend_client_key, get_xui_instance
@@ -193,6 +194,11 @@ async def renew_key_in_cluster(
await delete_notification(session, tg_id, notification_id)
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
try:
await clear_hot_lead_notifications(session, tg_id)
except Exception as e:
logger.warning(f"Не удалось очистить уведомления о скидках для {tg_id} при продлении: {e}")
except Exception as e:
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise
+22 -1
View File
@@ -4,7 +4,7 @@ import re
import secrets
import string
from datetime import datetime
from datetime import datetime, timedelta
import aiofiles
@@ -303,3 +303,24 @@ def get_username(user) -> str:
if getattr(user, "username", None):
return "@" + html.escape(user.username)
return "Пользователь"
def format_discount_time_left(last_time: datetime, discount_hours: int) -> str:
expires_at = last_time + timedelta(hours=discount_hours)
current_time = datetime.utcnow()
time_left = expires_at - current_time
if time_left.total_seconds() <= 0:
return "⏳ Время истекло"
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
if days > 0:
return format_days(days)
elif hours > 0:
return format_hours(hours)
else:
return format_minutes(minutes)
+47 -46
View File
@@ -24,54 +24,55 @@ def insert_hook_buttons(builder: InlineKeyboardBuilder, buttons: list) -> Inline
else:
flat_buttons.append(item)
for module in flat_buttons:
if isinstance(module, dict) and ("remove" in module or "remove_prefix" in module):
removes = module.get("remove")
if isinstance(removes, str):
removes = [removes]
removes = set(removes or [])
prefix = module.get("remove_prefix")
remove_operations = [b for b in flat_buttons if isinstance(b, dict) and ("remove" in b or "remove_prefix" in b)]
for module in remove_operations:
removes = module.get("remove")
if isinstance(removes, str):
removes = [removes]
removes = set(removes or [])
prefix = module.get("remove_prefix")
filtered_rows = []
for row in new_rows:
filtered_row = []
for btn in row:
cdata = getattr(btn, "callback_data", None)
if cdata and (cdata in removes or (prefix and cdata.startswith(prefix))):
continue
filtered_row.append(btn)
if filtered_row:
filtered_rows.append(filtered_row)
new_rows = filtered_rows
filtered_rows = []
for row in new_rows:
filtered_row = []
for btn in row:
cdata = getattr(btn, "callback_data", None)
if cdata and (cdata in removes or (prefix and cdata.startswith(prefix))):
continue
filtered_row.append(btn)
if filtered_row:
filtered_rows.append(filtered_row)
new_rows = filtered_rows
for module in flat_buttons:
if isinstance(module, dict) and "after" in module and "button" in module:
after = module["after"]
button = module["button"]
insert_pos = -1
for i, row in enumerate(new_rows):
if any(getattr(btn, "callback_data", None) == after for btn in row):
insert_pos = i + 1
break
if 0 <= insert_pos <= len(new_rows):
new_rows.insert(insert_pos, [button])
else:
new_rows.append([button])
elif isinstance(module, dict) and "insert_at" in module and "button" in module:
insert_at = module["insert_at"]
button = module["button"]
if 0 <= insert_at <= len(new_rows):
new_rows.insert(insert_at, [button])
else:
new_rows.append([button])
insert_operations = [b for b in flat_buttons if isinstance(b, dict) and "insert_at" in b and "button" in b]
for module in insert_operations:
insert_at = module["insert_at"]
button = module["button"]
if 0 <= insert_at <= len(new_rows):
new_rows.insert(insert_at, [button])
else:
if isinstance(module, dict) and "button" in module:
button = module["button"]
new_rows.append([button])
elif module and not isinstance(module, dict):
new_rows.append([module])
new_rows.append([button])
after_operations = [b for b in flat_buttons if isinstance(b, dict) and "after" in b and "button" in b]
for module in after_operations:
after = module["after"]
button = module["button"]
insert_pos = -1
for i, row in enumerate(new_rows):
if any(getattr(btn, "callback_data", None) == after for btn in row):
insert_pos = i + 1
break
if 0 <= insert_pos <= len(new_rows):
new_rows.insert(insert_pos, [button])
else:
new_rows.append([button])
regular_buttons = [b for b in flat_buttons if isinstance(b, dict) and "button" in b and "insert_at" not in b and "after" not in b]
for module in regular_buttons:
button = module["button"]
new_rows.append([button])
return InlineKeyboardBuilder.from_markup(InlineKeyboardMarkup(inline_keyboard=new_rows))