refactor notifications with configurable timing / native crypto link handling / referral cache optimization

This commit is contained in:
Capybara-z
2025-10-10 23:56:34 +03:00
parent 7938a0dc4f
commit f1a5465e36
7 changed files with 172 additions and 116 deletions
+1
View File
@@ -79,6 +79,7 @@ ROUTER = "📶 Роутер"
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
RENEW_KEY = "⏳ Продлить подписку"
RENEW_KEY_NOTIFICATION = "🔄 Продлить подписку"
CHANGE_TARIFF = "🔄 Сменить тариф"
TV_CONTINUE = "▶ Продолжить"
TV_INSTRUCTIONS = "📖 Полная инструкция"
HWID_BUTTON = "♻️ Сбросить привязку"
+7 -2
View File
@@ -13,6 +13,7 @@ from config import (
DOWNLOAD_MACOS,
DOWNLOAD_PC,
SUPPORT_CHAT_URL,
WEBHOOK_HOST,
)
from database import get_key_details, get_subscription_link
from handlers.buttons import (
@@ -110,10 +111,12 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any):
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
windows_url = f"{WEBHOOK_HOST}/?url={processed_link}"
else:
processed_link = key_link
windows_url = f"{CONNECT_WINDOWS}{processed_link}"
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{processed_link}"))
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=windows_url))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -141,10 +144,12 @@ async def process_macos_menu(callback_query: CallbackQuery, session: Any):
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
macos_url = f"{WEBHOOK_HOST}/?url={processed_link}"
else:
processed_link = key_link
macos_url = f"{CONNECT_MACOS}{processed_link}"
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{processed_link}"))
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=macos_url))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
+16 -7
View File
@@ -12,6 +12,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
APP_URL,
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
@@ -108,13 +109,17 @@ async def process_callback_connect_phone(callback_query: CallbackQuery, session:
)
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
crypto_url = f"{APP_URL}/?url={processed_link}"
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=crypto_url),
InlineKeyboardButton(text=IMPORT_ANDROID, url=crypto_url),
)
else:
processed_link = key_link
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{processed_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{processed_link}"),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{processed_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{processed_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
@@ -148,10 +153,12 @@ async def process_callback_connect_ios(callback_query: CallbackQuery, session: A
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
ios_url = f"{APP_URL}/?url={processed_link}"
else:
processed_link = key_link
ios_url = f"{CONNECT_IOS}{processed_link}"
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{processed_link}"))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=ios_url))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
@@ -186,10 +193,12 @@ async def process_callback_connect_android(callback_query: CallbackQuery, sessio
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
android_url = f"{APP_URL}/?url={processed_link}"
else:
processed_link = key_link
android_url = f"{CONNECT_ANDROID}{processed_link}"
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{processed_link}"))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=android_url))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
+89 -104
View File
@@ -15,6 +15,10 @@ from config import (
NOTIFY_INACTIVE_TRAFFIC,
NOTIFY_RENEW,
NOTIFY_RENEW_EXPIRED,
NOTIFY_24H_ENABLED,
NOTIFY_24H_HOURS,
NOTIFY_10H_ENABLED,
NOTIFY_10H_HOURS,
TRIAL_TIME_DISABLE,
)
from database import (
@@ -35,10 +39,12 @@ from database import (
)
from handlers.keys.operations import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import (
build_change_tariff_kb,
build_notification_expired_kb,
build_notification_kb,
)
from handlers.texts import (
KEY_CANNOT_RENEW_CURRENT,
KEY_DELETED_MSG,
KEY_EXPIRED_DELAY_MSG,
KEY_EXPIRED_NO_DELAY_MSG,
@@ -50,7 +56,7 @@ from hooks.hooks import run_hooks
from logger import logger
from .hot_leads_notifications import notify_hot_leads
from .notify_utils import send_messages_with_limit, send_notification
from .notify_utils import prepare_key_expiry_data, send_messages_with_limit, send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
@@ -72,8 +78,6 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
logger.info("Запуск обработки уведомлений")
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
try:
keys = await get_all_keys(session=session)
@@ -88,15 +92,19 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
except Exception as e:
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
try:
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
if NOTIFY_24H_ENABLED:
try:
threshold_24h = int((datetime.now(moscow_tz) + timedelta(hours=NOTIFY_24H_HOURS)).timestamp() * 1000)
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
try:
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
if NOTIFY_10H_ENABLED:
try:
threshold_10h = int((datetime.now(moscow_tz) + timedelta(hours=NOTIFY_10H_HOURS)).timestamp() * 1000)
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
try:
await handle_expired_keys(bot, session, current_time, keys)
@@ -133,13 +141,13 @@ async def notify_24h_keys(
threshold_time_24h: int,
keys: list,
):
logger.info("Начало проверки подписок, истекающих через 24 часа.")
logger.info(f"Начало проверки подписок, истекающих через {NOTIFY_24H_HOURS} часов.")
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {NOTIFY_24H_HOURS} часов.")
tg_ids = [getattr(key, "tg_id", key["tg_id"]) for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
allowed = await check_notifications_bulk(session, "key_24h", 24, tg_ids=tg_ids, emails=emails)
allowed = await check_notifications_bulk(session, "key_24h", NOTIFY_24H_HOURS, tg_ids=tg_ids, emails=emails)
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
messages = []
@@ -151,42 +159,19 @@ async def notify_24h_keys(
continue
notification_id = f"{email}_key_24h"
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
can_notify = await check_notification_time(session, tg_id, notification_id, hours=24)
can_notify = await check_notification_time(session, tg_id, notification_id, hours=NOTIFY_24H_HOURS)
if not can_notify:
continue
tariff_name = ""
tariff_details = ""
if getattr(key, "tariff_id", None):
tariff = await get_tariff_by_id(session, key.tariff_id)
if tariff:
tariff_name = tariff.get("name") or ""
traffic_limit = tariff.get("traffic_limit") or 0
device_limit = tariff.get("device_limit") or 0
subgroup_title = tariff.get("subgroup_title", "")
traffic_text = "безлимит" if traffic_limit <= 0 else f"{traffic_limit} ГБ"
devices_text = "безлимит" if device_limit <= 0 else str(device_limit)
lines = []
if subgroup_title:
lines.append(subgroup_title)
lines.append(f"Трафик: {traffic_text}")
lines.append(f"Устройств: {devices_text}")
tariff_details = "\n" + "\n".join(lines)
expiry_data = await prepare_key_expiry_data(key, session, current_time)
notification_text = KEY_EXPIRY.format(
email=email,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
tariff_name=tariff_name,
tariff_details=tariff_details,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
if NOTIFY_RENEW:
@@ -227,9 +212,9 @@ async def notify_24h_keys(
logger.warning(
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {NOTIFY_24H_HOURS} часов.")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
logger.info(f"Обработка всех уведомлений за {NOTIFY_24H_HOURS} часов завершена.")
await asyncio.sleep(1)
@@ -240,13 +225,13 @@ async def notify_10h_keys(
threshold_time_10h: int,
keys: list,
):
logger.info("Начало проверки подписок, истекающих через 10 часов.")
logger.info(f"Начало проверки подписок, истекающих через {NOTIFY_10H_HOURS} часов.")
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {NOTIFY_10H_HOURS} часов.")
tg_ids = [key.tg_id for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
allowed = await check_notifications_bulk(session, "key_10h", 10, tg_ids=tg_ids, emails=emails)
allowed = await check_notifications_bulk(session, "key_10h", NOTIFY_10H_HOURS, tg_ids=tg_ids, emails=emails)
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
messages = []
@@ -258,42 +243,19 @@ async def notify_10h_keys(
continue
notification_id = f"{email}_key_10h"
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
can_notify = await check_notification_time(session, tg_id, notification_id, hours=10)
can_notify = await check_notification_time(session, tg_id, notification_id, hours=NOTIFY_10H_HOURS)
if not can_notify:
continue
tariff_name = ""
tariff_details = ""
if key.tariff_id:
tariff = await get_tariff_by_id(session, key.tariff_id)
if tariff:
tariff_name = tariff.get("name") or ""
traffic_limit = tariff.get("traffic_limit") or 0
device_limit = tariff.get("device_limit") or 0
subgroup_title = tariff.get("subgroup_title", "")
traffic_text = "безлимит" if traffic_limit <= 0 else f"{traffic_limit} ГБ"
devices_text = "безлимит" if device_limit <= 0 else str(device_limit)
lines = []
if subgroup_title:
lines.append(subgroup_title)
lines.append(f"Трафик: {traffic_text}")
lines.append(f"Устройств: {devices_text}")
tariff_details = "\n" + "\n".join(lines)
expiry_data = await prepare_key_expiry_data(key, session, current_time)
notification_text = KEY_EXPIRY.format(
email=email,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
tariff_name=tariff_name,
tariff_details=tariff_details,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
if NOTIFY_RENEW:
@@ -334,9 +296,9 @@ async def notify_10h_keys(
logger.warning(
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {NOTIFY_10H_HOURS} часов.")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
logger.info(f"Обработка всех уведомлений за {NOTIFY_10H_HOURS} часов завершена.")
await asyncio.sleep(1)
@@ -500,13 +462,7 @@ async def process_auto_renew_or_notify(
selected_tariff = None
if not tariff_id:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
selected_tariff = None
else:
if await check_tariff_exists(conn, tariff_id):
current_tariff = await get_tariff_by_id(conn, tariff_id)
@@ -522,28 +478,57 @@ async def process_auto_renew_or_notify(
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
if current_tariff["group_code"] in forbidden_groups:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
selected_tariff = None
elif balance >= current_tariff["price_rub"]:
selected_tariff = current_tariff
else:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
selected_tariff = None
if not selected_tariff:
keyboard = build_notification_kb(email)
expiry_data = await prepare_key_expiry_data(key, conn, int(datetime.now(moscow_tz).timestamp() * 1000))
use_change_tariff_kb = False
if tariff_id and await check_tariff_exists(conn, tariff_id):
current_tariff = await get_tariff_by_id(conn, tariff_id)
if current_tariff:
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
try:
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn)
for hook_result in hook_results:
additional_groups = hook_result.get("additional_groups", [])
forbidden_groups.extend(additional_groups)
except Exception as e:
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
if current_tariff["group_code"] in forbidden_groups:
use_change_tariff_kb = True
message_text = KEY_CANNOT_RENEW_CURRENT.format(
email=email,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
else:
use_change_tariff_kb = True
message_text = KEY_CANNOT_RENEW_CURRENT.format(
email=email,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
if use_change_tariff_kb:
keyboard = build_change_tariff_kb(email)
else:
keyboard = build_notification_kb(email)
await add_notification(conn, tg_id, notification_id)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
await send_notification(bot, tg_id, standard_photo, message_text, keyboard)
return
client_id = key.client_id
+15 -1
View File
@@ -1,6 +1,6 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from handlers.buttons import DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY_NOTIFICATION
from handlers.buttons import CHANGE_TARIFF, DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY_NOTIFICATION
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
@@ -17,6 +17,20 @@ def build_notification_kb(email: str) -> InlineKeyboardMarkup:
return builder.as_markup()
def build_change_tariff_kb(email: str) -> InlineKeyboardMarkup:
"""
Формирует inline-клавиатуру для смены тарифа.
Кнопки: "🔄 Сменить тариф" (callback_data содержит email) и "👤 Личный кабинет".
"""
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.button(text=CHANGE_TARIFF, callback_data=f"renew_key|{email}")
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1)
return builder.as_markup()
def build_notification_expired_kb() -> InlineKeyboardMarkup:
"""
Формирует inline-клавиатуру для уведомлений после удаления или продления.
+43 -1
View File
@@ -1,7 +1,9 @@
import asyncio
import os
from datetime import datetime
import aiofiles
import pytz
from aiogram import Bot
from aiogram.exceptions import (
@@ -12,7 +14,8 @@ from aiogram.exceptions import (
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from database import create_blocked_user
from database import create_blocked_user, get_tariff_by_id
from handlers.utils import format_hours
from logger import logger
@@ -157,3 +160,42 @@ async def _send_text_notification(
except Exception as e:
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
return False
async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int) -> dict:
moscow_tz = pytz.timezone("Europe/Moscow")
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
tariff_name = ""
tariff_details = ""
if getattr(key, "tariff_id", None):
tariff = await get_tariff_by_id(session, key.tariff_id)
if tariff:
tariff_name = tariff.get("name") or ""
traffic_limit = tariff.get("traffic_limit") or 0
device_limit = tariff.get("device_limit") or 0
subgroup_title = tariff.get("subgroup_title", "")
traffic_text = "безлимит" if traffic_limit <= 0 else f"{traffic_limit} ГБ"
devices_text = "безлимит" if device_limit <= 0 else str(device_limit)
lines = []
if subgroup_title:
lines.append(subgroup_title)
lines.append(f"Трафик: {traffic_text}")
lines.append(f"Устройств: {devices_text}")
tariff_details = "\n" + "\n".join(lines)
return {
"hours_left_formatted": hours_left_formatted,
"formatted_expiry_date": formatted_expiry_date,
"tariff_name": tariff_name,
"tariff_details": tariff_details,
}
+1 -1
View File
@@ -148,7 +148,7 @@ async def inline_referral_handler(inline_query: InlineQuery, session: AsyncSessi
)
)
await inline_query.answer(results=results, cache_time=86400, is_personal=True)
await inline_query.answer(results=results, cache_time=60, is_personal=True)
@router.callback_query(F.data.startswith("show_referral_qr|"))