NOTIFICATIONS 2.0/bug fixes and improvments
This commit is contained in:
@@ -8,8 +8,8 @@ from aiogram.filters import ExceptionTypeFilter
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.types import BufferedInputFile, ErrorEvent
|
||||
from aiogram.utils.markdown import hbold
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
from filters.private import IsPrivateFilter
|
||||
from logger import logger
|
||||
from middlewares import register_middleware
|
||||
@@ -18,7 +18,7 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
version = "4.0.0-Alpha(06-dev)"
|
||||
version = "4.0.0-Alpha(13-dev)"
|
||||
|
||||
register_middleware(dp)
|
||||
|
||||
|
||||
+106
-9
@@ -5,7 +5,7 @@ from typing import Any
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from config import CASHBACK, DATABASE_URL, REFERRAL_BONUS_PERCENTAGES
|
||||
from config import CASHBACK, CHECK_REFERRAL_REWARD_ISSUED, DATABASE_URL, REFERRAL_BONUS_PERCENTAGES
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -528,7 +528,6 @@ async def get_balance(tg_id: int) -> float:
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
balance = await conn.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
|
||||
logger.info(f"Получен баланс для пользователя {tg_id}: {balance}")
|
||||
return round(balance, 1) if balance is not None else 0.0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении баланса для пользователя {tg_id}: {e}")
|
||||
@@ -693,7 +692,7 @@ async def handle_referral_on_balance_update(tg_id: int, amount: float):
|
||||
|
||||
referral = await conn.fetchrow(
|
||||
"""
|
||||
SELECT referrer_tg_id
|
||||
SELECT referrer_tg_id, reward_issued
|
||||
FROM referrals
|
||||
WHERE referred_tg_id = $1
|
||||
""",
|
||||
@@ -710,6 +709,10 @@ async def handle_referral_on_balance_update(tg_id: int, amount: float):
|
||||
logger.warning(f"Реферер {referrer_tg_id} уже обработан. Пропуск.")
|
||||
break
|
||||
|
||||
if CHECK_REFERRAL_REWARD_ISSUED and referral["reward_issued"]:
|
||||
logger.info(f"Реферальный бонус уже выдан для пользователя {current_tg_id}. Прекращение начисления.")
|
||||
break
|
||||
|
||||
referral_chain.append({"tg_id": referrer_tg_id, "level": level})
|
||||
current_tg_id = referrer_tg_id
|
||||
|
||||
@@ -728,6 +731,16 @@ async def handle_referral_on_balance_update(tg_id: int, amount: float):
|
||||
logger.info(f"Начисление бонуса {bonus} рублей рефереру {referrer_tg_id} на уровне {level}.")
|
||||
await update_balance(referrer_tg_id, bonus)
|
||||
|
||||
if CHECK_REFERRAL_REWARD_ISSUED:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE referrals
|
||||
SET reward_issued = TRUE
|
||||
WHERE referred_tg_id = $1
|
||||
""",
|
||||
tg_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке многоуровневой реферальной системы для {tg_id}: {e}")
|
||||
finally:
|
||||
@@ -795,9 +808,52 @@ async def get_referral_stats(referrer_tg_id: int):
|
||||
}
|
||||
logger.debug(f"Получена статистика рефералов по уровням: {referrals_by_level}")
|
||||
|
||||
total_referral_bonus = await conn.fetchval(
|
||||
f"""
|
||||
WITH RECURSIVE referral_levels AS (
|
||||
if CHECK_REFERRAL_REWARD_ISSUED:
|
||||
bonus_cte = f"""
|
||||
WITH RECURSIVE
|
||||
referral_levels AS (
|
||||
SELECT
|
||||
referred_tg_id,
|
||||
referrer_tg_id,
|
||||
1 AS level
|
||||
FROM referrals
|
||||
WHERE referrer_tg_id = $1 AND reward_issued = TRUE
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
r.referred_tg_id,
|
||||
r.referrer_tg_id,
|
||||
rl.level + 1
|
||||
FROM referrals r
|
||||
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||
WHERE rl.level < {MAX_REFERRAL_LEVELS} AND r.reward_issued = TRUE
|
||||
),
|
||||
earliest_payments AS (
|
||||
SELECT DISTINCT ON (tg_id) tg_id, amount, created_at
|
||||
FROM payments
|
||||
WHERE status = 'success'
|
||||
ORDER BY tg_id, created_at
|
||||
)
|
||||
"""
|
||||
total_referral_bonus_query = (
|
||||
bonus_cte
|
||||
+ f"""
|
||||
SELECT
|
||||
COALESCE(SUM(ep.amount * (
|
||||
CASE
|
||||
{" ".join([f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}" for level in REFERRAL_BONUS_PERCENTAGES])}
|
||||
ELSE 0
|
||||
END)), 0) AS total_bonus
|
||||
FROM referral_levels rl
|
||||
JOIN earliest_payments ep ON rl.referred_tg_id = ep.tg_id
|
||||
WHERE rl.level <= {MAX_REFERRAL_LEVELS}
|
||||
"""
|
||||
)
|
||||
else:
|
||||
bonus_cte = f"""
|
||||
WITH RECURSIVE
|
||||
referral_levels AS (
|
||||
SELECT
|
||||
referred_tg_id,
|
||||
referrer_tg_id,
|
||||
@@ -815,6 +871,10 @@ async def get_referral_stats(referrer_tg_id: int):
|
||||
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||
WHERE rl.level < {MAX_REFERRAL_LEVELS}
|
||||
)
|
||||
"""
|
||||
total_referral_bonus_query = (
|
||||
bonus_cte
|
||||
+ f"""
|
||||
SELECT
|
||||
COALESCE(SUM(p.amount * (
|
||||
CASE
|
||||
@@ -824,10 +884,10 @@ async def get_referral_stats(referrer_tg_id: int):
|
||||
FROM referral_levels rl
|
||||
JOIN payments p ON rl.referred_tg_id = p.tg_id
|
||||
WHERE p.status = 'success' AND rl.level <= {MAX_REFERRAL_LEVELS}
|
||||
""",
|
||||
referrer_tg_id,
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
total_referral_bonus = await conn.fetchval(total_referral_bonus_query, referrer_tg_id)
|
||||
logger.debug(f"Получена общая сумма бонусов от рефералов: {total_referral_bonus}")
|
||||
|
||||
return {
|
||||
@@ -1163,6 +1223,43 @@ async def check_notification_time(tg_id: int, notification_type: str, hours: int
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def get_last_notification_time(tg_id: int, notification_type: str, session: Any = None) -> int:
|
||||
"""
|
||||
Возвращает время последнего уведомления в миллисекундах (UTC).
|
||||
|
||||
Args:
|
||||
tg_id (int): Telegram ID пользователя.
|
||||
notification_type (str): Тип уведомления.
|
||||
session (Any): Сессия базы данных.
|
||||
|
||||
Returns:
|
||||
int: Время последнего уведомления в миллисекундах, или None, если уведомления не было.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = session if session is not None else await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
last_notification_time = await conn.fetchval(
|
||||
"""
|
||||
SELECT EXTRACT(EPOCH FROM MAX(last_notification_time AT TIME ZONE 'Europe/Moscow' AT TIME ZONE 'UTC')) * 1000
|
||||
FROM notifications
|
||||
WHERE tg_id = $1 AND notification_type = $2
|
||||
""",
|
||||
tg_id,
|
||||
notification_type,
|
||||
)
|
||||
|
||||
return int(last_notification_time) if last_notification_time is not None else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении времени последнего уведомления для пользователя {tg_id}: {e}")
|
||||
return None
|
||||
|
||||
finally:
|
||||
if conn is not None and session is None:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def get_servers(session: Any = None):
|
||||
conn = None
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message
|
||||
|
||||
from config import ADMIN_ID
|
||||
|
||||
|
||||
|
||||
@@ -115,8 +115,6 @@ async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
|
||||
per_page = 10
|
||||
result = await get_all_coupons(session, page, per_page)
|
||||
coupons = result["coupons"]
|
||||
total_pages = result["pages"]
|
||||
current_page = result["current_page"]
|
||||
|
||||
if not coupons:
|
||||
await callback_query.message.edit_text(
|
||||
|
||||
@@ -33,7 +33,9 @@ async def handle_sender(callback_query: CallbackQuery):
|
||||
AdminSenderCallback.filter(F.type != "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender_callback(callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext):
|
||||
async def handle_sender_callback_text(
|
||||
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите текст сообщения для рассылки:",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
@@ -54,73 +56,85 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
|
||||
)
|
||||
|
||||
|
||||
@router.message(
|
||||
AdminSender.waiting_for_message,
|
||||
IsAdminFilter(),
|
||||
)
|
||||
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
|
||||
async def handle_message_input(message: Message, state: FSMContext, session: Any):
|
||||
text_message = message.text
|
||||
"""
|
||||
Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
|
||||
"""
|
||||
text_message = message.html_text if message.text else None
|
||||
photo = message.photo[-1].file_id if message.photo else None
|
||||
photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
|
||||
|
||||
try:
|
||||
state_data = await state.get_data()
|
||||
send_to = state_data.get("type", "all")
|
||||
if not text_message and message.caption:
|
||||
text_message = message.caption
|
||||
|
||||
if send_to == "subscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT c.tg_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
WHERE k.expiry_time > $1
|
||||
""",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
elif send_to == "unsubscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT c.tg_id
|
||||
FROM connections c
|
||||
LEFT JOIN keys k ON c.tg_id = k.tg_id
|
||||
GROUP BY c.tg_id
|
||||
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
|
||||
""",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
elif send_to == "cluster":
|
||||
cluster_name = state_data.get("cluster_name")
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT c.tg_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
JOIN servers s ON k.server_id = s.cluster_name
|
||||
WHERE s.cluster_name = $1
|
||||
if not text_message and not photo and not photo_url:
|
||||
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
|
||||
return
|
||||
|
||||
state_data = await state.get_data()
|
||||
send_to = state_data.get("type", "all")
|
||||
|
||||
if send_to == "subscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT c.tg_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
WHERE k.expiry_time > $1
|
||||
""",
|
||||
cluster_name,
|
||||
)
|
||||
else:
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
|
||||
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record["tg_id"]
|
||||
try:
|
||||
await message.bot.send_message(chat_id=tg_id, text=text_message)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
text = (
|
||||
f"📤 Рассылка завершена!"
|
||||
f"\n\n👥 Всего пользователей: {total_users}"
|
||||
f"\n✅ Доставлено: {success_count}"
|
||||
f"\n❌ Не доставлено: {total_users - success_count}"
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
elif send_to == "unsubscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT c.tg_id
|
||||
FROM connections c
|
||||
LEFT JOIN keys k ON c.tg_id = k.tg_id
|
||||
GROUP BY c.tg_id
|
||||
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
|
||||
""",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
elif send_to == "cluster":
|
||||
cluster_name = state_data.get("cluster_name")
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT c.tg_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
JOIN servers s ON k.server_id = s.cluster_name
|
||||
WHERE s.cluster_name = $1
|
||||
""",
|
||||
cluster_name,
|
||||
)
|
||||
else:
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("stats"))
|
||||
except Exception as e:
|
||||
logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record["tg_id"]
|
||||
try:
|
||||
if photo or photo_url:
|
||||
await message.bot.send_photo(
|
||||
chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
|
||||
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
|
||||
|
||||
text = (
|
||||
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||
f"👥 <b>Всего пользователей:</b> {total_users}\n"
|
||||
f"✅ <b>Доставлено:</b> {success_count}\n"
|
||||
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("stats"), parse_mode="HTML")
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -397,7 +397,7 @@ async def handle_clusters_backup(
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_sync"), IsAdminFilter())
|
||||
async def handle_clusters_backup(
|
||||
async def handle_clusters_sync(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
@@ -690,12 +690,17 @@ async def handle_user_traffic(
|
||||
await callback_query.message.edit_text(traffic_data["message"], reply_markup=build_editor_kb(tg_id, True))
|
||||
return
|
||||
|
||||
result_text = f"📊 Трафик ключа {email}:\n\n"
|
||||
total_traffic = 0
|
||||
|
||||
result_text = f"📊 <b>Трафик подписки {email}:</b>\n\n"
|
||||
|
||||
for server, traffic in traffic_data["traffic"].items():
|
||||
if isinstance(traffic, str):
|
||||
result_text += f"❌ {server}: {traffic}\n"
|
||||
else:
|
||||
result_text += f"🌍 {server}: {traffic} ГБ\n"
|
||||
result_text += f"🌍 {server}: <b>{traffic} ГБ</b>\n"
|
||||
total_traffic += traffic
|
||||
|
||||
result_text += f"\n🔢 <b>Общий трафик:</b> {total_traffic:.2f} ГБ"
|
||||
|
||||
await callback_query.message.edit_text(result_text, reply_markup=build_editor_kb(tg_id, True))
|
||||
|
||||
+1
-6
@@ -6,8 +6,8 @@ from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from config import CAPTCHA_EMOJIS
|
||||
|
||||
from config import CAPTCHA_EMOJIS
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
@@ -26,21 +26,16 @@ async def generate_captcha(message: Message, state: FSMContext):
|
||||
...
|
||||
}
|
||||
"""
|
||||
# Выбираем случайный эмодзи и его описание из конфига
|
||||
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
|
||||
|
||||
# Получаем 3 случайных неправильных эмодзи
|
||||
wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
|
||||
|
||||
# Создаем список всех эмодзи и перемешиваем их
|
||||
all_emojis = [correct_emoji] + wrong_emojis
|
||||
random.shuffle(all_emojis)
|
||||
|
||||
# Сохраняем правильный ответ в состоянии
|
||||
await state.update_data(correct_emoji=correct_emoji)
|
||||
await state.update_data(message=message)
|
||||
|
||||
# Создаем клавиатуру
|
||||
builder = InlineKeyboardBuilder()
|
||||
for emoji in all_emojis:
|
||||
builder.button(text=emoji, callback_data=f"captcha_{emoji}")
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from config import RUB_TO_XTR
|
||||
|
||||
from config import RUB_TO_XTR
|
||||
from logger import logger
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import aiofiles
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from config import CONNECT_MACOS, CONNECT_WINDOWS, SUPPORT_CHAT_URL
|
||||
|
||||
from config import CONNECT_MACOS, CONNECT_WINDOWS, SUPPORT_CHAT_URL
|
||||
from database import get_key_details
|
||||
from handlers.texts import (
|
||||
CONNECT_TV_TEXT,
|
||||
|
||||
@@ -19,14 +19,18 @@ from config import (
|
||||
CONNECT_IOS,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
PUBLIC_LINK,
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
TRIAL_TIME,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
add_connection,
|
||||
check_connection_exists,
|
||||
create_temporary_data,
|
||||
delete_key,
|
||||
get_balance,
|
||||
@@ -34,6 +38,7 @@ from database import (
|
||||
get_trial,
|
||||
store_key,
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from handlers.buttons.add_subscribe import (
|
||||
DOWNLOAD_ANDROID_BUTTON,
|
||||
@@ -65,8 +70,6 @@ class Form(FSMContext):
|
||||
@router.callback_query(F.data == "create_key")
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} confirmed creation of a new key.")
|
||||
logger.info(f"Balance for user {tg_id} is sufficient. Proceeding with key creation.")
|
||||
await handle_key_creation(tg_id, state, session, callback_query)
|
||||
|
||||
|
||||
@@ -78,35 +81,46 @@ async def handle_key_creation(
|
||||
):
|
||||
"""Создание ключа с учётом выбора тарифного плана."""
|
||||
current_time = datetime.now(moscow_tz)
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
|
||||
if trial_status == 0:
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME)
|
||||
logger.info(f"Assigned {TRIAL_TIME}-дневный пробный период пользователю {tg_id}.")
|
||||
await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", tg_id)
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
|
||||
discount_text = ""
|
||||
if plan_id in DISCOUNTS:
|
||||
discount_percentage = DISCOUNTS[plan_id]
|
||||
discount_text = f" ({discount_percentage}% скидка)"
|
||||
if index == len(RENEWAL_PRICES) - 1:
|
||||
discount_text = f" ({discount_percentage}% 🔥)"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {plan_id} мес. - {price}₽{discount_text}",
|
||||
callback_data=f"select_plan_{plan_id}",
|
||||
)
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
|
||||
if trial_status in [0, -1]:
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
|
||||
|
||||
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
|
||||
|
||||
updated = await update_trial(tg_id, 1, session)
|
||||
if updated:
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
else:
|
||||
logger.error(f"Не удалось обновить статус триального периода для пользователя {tg_id}.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
|
||||
discount_text = ""
|
||||
if plan_id in DISCOUNTS:
|
||||
discount_percentage = DISCOUNTS[plan_id]
|
||||
discount_text = f" ({discount_percentage}% скидка)"
|
||||
if index == len(RENEWAL_PRICES) - 1:
|
||||
discount_text = f" ({discount_percentage}% 🔥)"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {plan_id} мес. - {price}₽{discount_text}",
|
||||
callback_data=f"select_plan_{plan_id}",
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
await message_or_query.message.answer(
|
||||
"💳 Выберите тарифный план для создания нового ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
await message_or_query.message.answer(
|
||||
"💳 Выберите тарифный план для создания нового ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_plan_"))
|
||||
@@ -161,12 +175,16 @@ async def create_key(
|
||||
):
|
||||
"""Создаёт ключ с заданным сроком действия."""
|
||||
|
||||
if not await check_connection_exists(tg_id):
|
||||
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
||||
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен.")
|
||||
logger.info("[Country Selection] Получение наименее загруженного кластера.")
|
||||
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
logger.info(f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}")
|
||||
logger.info(f"[Country Selection] Получение списка серверов для кластера {least_loaded_cluster}.")
|
||||
logger.info(
|
||||
f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов"
|
||||
)
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name FROM servers WHERE cluster_name = $1",
|
||||
least_loaded_cluster,
|
||||
@@ -182,36 +200,27 @@ async def create_key(
|
||||
else:
|
||||
callback_data = f"select_country|{country}|{ts}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
logger.info(f"[Country Selection] Добавлена кнопка для страны: {country} с callback_data: {callback_data}")
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="profile"))
|
||||
logger.info("[Country Selection] Добавлена кнопка '⬅️ Назад'.")
|
||||
|
||||
if isinstance(message_or_query, Message):
|
||||
logger.info("[Country Selection] Сообщение пользователя - тип Message.")
|
||||
await message_or_query.answer(
|
||||
"🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info("[Country Selection] Сообщение отправлено с выбором страны.")
|
||||
elif isinstance(message_or_query, CallbackQuery):
|
||||
logger.info("[Country Selection] Сообщение пользователя - тип CallbackQuery.")
|
||||
await message_or_query.message.answer(
|
||||
"🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info("[Country Selection] Сообщение отправлено с выбором страны.")
|
||||
elif tg_id is not None:
|
||||
logger.info("[Country Selection] Использование tg_id для отправки сообщения.")
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info(f"[Country Selection] Сообщение отправлено напрямую в чат {tg_id}.")
|
||||
else:
|
||||
logger.error("[Country Selection] Невозможно определить идентификатор чата. Сообщение не отправлено.")
|
||||
|
||||
logger.info("[Country Selection] Возврат из функции.")
|
||||
return
|
||||
|
||||
while True:
|
||||
@@ -245,6 +254,7 @@ async def create_key(
|
||||
least_loaded_cluster,
|
||||
session,
|
||||
)
|
||||
await update_trial(tg_id, 1, session)
|
||||
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
@@ -288,48 +298,6 @@ async def create_key(
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
logger.info(f"[FSM] Состояние пользователя {tg_id} очищено")
|
||||
|
||||
if old_key_name:
|
||||
try:
|
||||
old_record = await get_key_details(old_key_name, session)
|
||||
if old_record is not None:
|
||||
old_client_id = old_record["client_id"]
|
||||
old_email = old_record["email"]
|
||||
server_name = old_record.get("server_id")
|
||||
|
||||
if server_name:
|
||||
server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
|
||||
server_name,
|
||||
)
|
||||
if server_info:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
deletion_success = await delete_client(
|
||||
xui,
|
||||
server_info["inbound_id"],
|
||||
old_email,
|
||||
old_client_id,
|
||||
)
|
||||
if deletion_success:
|
||||
logger.info(f"Клиент с ID {old_client_id} успешно удалён с сервера.")
|
||||
else:
|
||||
logger.warning(f"Не удалось удалить клиента с ID {old_client_id} с сервера.")
|
||||
else:
|
||||
logger.warning(f"Информация о сервере {server_name} не найдена в БД.")
|
||||
else:
|
||||
logger.warning("Имя сервера для старого ключа не указано.")
|
||||
|
||||
await delete_key(old_client_id, session)
|
||||
logger.info(f"Старый ключ {old_key_name} (client_id: {old_client_id}) удалён для пользователя {tg_id}.")
|
||||
else:
|
||||
logger.warning(f"Запись для старого ключа {old_key_name} не найдена.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении старого ключа {old_key_name} для пользователя {tg_id}: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_location|"))
|
||||
@@ -348,7 +316,6 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
expiry_timestamp = record["expiry_time"]
|
||||
ts = int(expiry_timestamp / 1000)
|
||||
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
||||
|
||||
servers = await session.fetch("SELECT server_name FROM servers")
|
||||
countries = [row["server_name"] for row in servers]
|
||||
@@ -411,11 +378,15 @@ async def finalize_key_creation(
|
||||
"""Финализирует создание ключа с выбранной страной.
|
||||
Если old_key_name передан, после создания нового ключа старый будет удалён.
|
||||
"""
|
||||
|
||||
if not await check_connection_exists(tg_id):
|
||||
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
||||
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
|
||||
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
logger.info(f"Generated random key name for user {tg_id}: {key_name}")
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
if not existing_key:
|
||||
break
|
||||
|
||||
+50
-23
@@ -104,7 +104,15 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
found_servers = []
|
||||
for _key, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
@@ -135,18 +143,27 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось продлить ключ {client_id} в кластере {cluster_id}: {e}")
|
||||
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
"""Удаление ключа с серверов в кластере"""
|
||||
"""Удаление ключа с серверов в кластере или с конкретного сервера"""
|
||||
try:
|
||||
servers = await get_servers()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
found_servers = []
|
||||
for _, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
@@ -175,7 +192,7 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить ключ {client_id} в кластере {cluster_id}: {e}")
|
||||
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
@@ -291,7 +308,6 @@ async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, An
|
||||
Returns:
|
||||
dict[str, Any]: Структура с данными о трафике.
|
||||
"""
|
||||
logger.info(f"🔍 Получаем ключи для пользователя {email} (TG ID: {tg_id})")
|
||||
|
||||
query = "SELECT client_id, server_id FROM keys WHERE tg_id = $1 AND email = $2"
|
||||
rows = await session.fetch(query, tg_id, email)
|
||||
@@ -300,33 +316,27 @@ async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, An
|
||||
return {"status": "error", "message": "❌ У пользователя нет активных ключей."}
|
||||
|
||||
server_ids = {row["server_id"] for row in rows}
|
||||
logger.info(f"🖥️ Серверы/Кластеры пользователя: {server_ids}")
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
query_servers = "SELECT server_name, api_url FROM servers WHERE server_name = ANY($1)"
|
||||
filter_ids = list(server_ids)
|
||||
else:
|
||||
query_servers = "SELECT server_name, api_url FROM servers WHERE cluster_name = ANY($1)"
|
||||
filter_ids = list(server_ids)
|
||||
|
||||
server_rows = await session.fetch(query_servers, filter_ids)
|
||||
query_servers = """
|
||||
SELECT server_name, api_url FROM servers
|
||||
WHERE server_name = ANY($1) OR cluster_name = ANY($1)
|
||||
"""
|
||||
server_rows = await session.fetch(query_servers, list(server_ids))
|
||||
|
||||
if not server_rows:
|
||||
logger.error(f"❌ Не найдено серверов для: {server_ids}")
|
||||
return {"status": "error", "message": f"❌ Серверы не найдены: {', '.join(server_ids)}"}
|
||||
|
||||
servers_map = {row["server_name"]: row["api_url"] for row in server_rows}
|
||||
logger.info(f"✅ Найденные серверы: {list(servers_map.keys())}")
|
||||
|
||||
user_traffic_data = {}
|
||||
|
||||
for row in rows:
|
||||
client_id = row["client_id"]
|
||||
server_id = row["server_id"]
|
||||
|
||||
for server, api_url in servers_map.items():
|
||||
if not USE_COUNTRY_SELECTION and server not in servers_map:
|
||||
continue
|
||||
|
||||
if server_id in servers_map:
|
||||
api_url = servers_map[server_id]
|
||||
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
|
||||
|
||||
try:
|
||||
@@ -335,11 +345,28 @@ async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, An
|
||||
if traffic_info["status"] == "success" and traffic_info["traffic"]:
|
||||
client_data = traffic_info["traffic"][0]
|
||||
used_gb = (client_data.up + client_data.down) / 1073741824
|
||||
user_traffic_data[server] = round(used_gb, 2)
|
||||
user_traffic_data[server_id] = round(used_gb, 2)
|
||||
else:
|
||||
user_traffic_data[server] = "Ошибка получения трафика"
|
||||
user_traffic_data[server_id] = "Ошибка получения трафика"
|
||||
|
||||
except Exception as e:
|
||||
user_traffic_data[server] = f"Ошибка: {e}"
|
||||
user_traffic_data[server_id] = f"Ошибка: {e}"
|
||||
|
||||
else:
|
||||
for server, api_url in servers_map.items():
|
||||
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
|
||||
|
||||
try:
|
||||
traffic_info = await get_client_traffic(xui, client_id)
|
||||
|
||||
if traffic_info["status"] == "success" and traffic_info["traffic"]:
|
||||
client_data = traffic_info["traffic"][0]
|
||||
used_gb = (client_data.up + client_data.down) / 1073741824
|
||||
user_traffic_data[server] = round(used_gb, 2)
|
||||
else:
|
||||
user_traffic_data[server] = "Ошибка получения трафика"
|
||||
|
||||
except Exception as e:
|
||||
user_traffic_data[server] = f"Ошибка: {e}"
|
||||
|
||||
return {"status": "success", "traffic": user_traffic_data}
|
||||
|
||||
+63
-23
@@ -10,9 +10,12 @@ import pytz
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
DATABASE_URL,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
@@ -24,8 +27,6 @@ from config import (
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
|
||||
from bot import bot
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
create_temporary_data,
|
||||
@@ -57,6 +58,7 @@ from handlers.texts import (
|
||||
DISCOUNTS,
|
||||
KEY_NOT_FOUND_MSG,
|
||||
PLAN_SELECTION_MSG,
|
||||
SUBSCRIPTION_DESCRIPTION,
|
||||
SUCCESS_RENEWAL_MSG,
|
||||
key_message,
|
||||
)
|
||||
@@ -175,10 +177,7 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
|
||||
days_left_message = (
|
||||
f"<b>🕒 Статус подписки:</b>\n"
|
||||
f"Осталось: <b>{days}</b> дней, <b>{hours}</b> часов, <b>{minutes}</b> минут"
|
||||
)
|
||||
days_left_message = f"Осталось: <b>{days}</b> дней, <b>{hours}</b> часов, <b>{minutes}</b> минут"
|
||||
|
||||
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
|
||||
|
||||
@@ -196,15 +195,19 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key}"),
|
||||
)
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}")
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_name}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_name}"),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
|
||||
@@ -217,7 +220,7 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
InlineKeyboardButton(text="❌ Удалить", callback_data=f"delete_key|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text="⏳ Продлить", callback_data=f"renew_key|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text="⏳ Продлить подписку", callback_data=f"renew_key|{key_name}"))
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
builder.row(
|
||||
@@ -254,6 +257,50 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_phone|"))
|
||||
async def process_callback_connect_phone(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow(
|
||||
"""
|
||||
SELECT key FROM keys WHERE email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="📖 Ручная установка", callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{email}"))
|
||||
|
||||
await callback_query.message.answer(description, reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("update_subscription|"))
|
||||
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
@@ -470,21 +517,16 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g
|
||||
)
|
||||
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(months=plan)
|
||||
logger.info(f"[RENEW] Сформировано сообщение: {response_message}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
if callback_query:
|
||||
logger.info("[RENEW] Отправка ответа через callback_query.message.answer()")
|
||||
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
|
||||
else:
|
||||
logger.info("[RENEW] Отправка ответа через bot.send_message()")
|
||||
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
|
||||
|
||||
logger.info("[RENEW] Подключение к базе данных...")
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
logger.info("[RENEW] Подключение к базе данных установлено.")
|
||||
|
||||
logger.info(f"[RENEW] Получение данных о ключе для email: {email}")
|
||||
key_info = await get_key_details(email, conn)
|
||||
@@ -492,10 +534,8 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g
|
||||
logger.error(f"[RENEW] Ключ с client_id {client_id} для пользователя {tg_id} не найден.")
|
||||
await conn.close()
|
||||
return
|
||||
logger.info(f"[RENEW] Данные о ключе получены: {key_info}")
|
||||
|
||||
server_id = key_info["server_id"]
|
||||
logger.info(f"[RENEW] Используется server_id: {server_id}")
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
logger.info(f"[RENEW] USE_COUNTRY_SELECTION включён. Проверяю информацию о сервере {server_id}")
|
||||
|
||||
@@ -5,8 +5,8 @@ from datetime import datetime
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
from aiohttp import web
|
||||
from config import DATABASE_URL, PROJECT_NAME, SUB_MESSAGE, SUPERNODE, TRANSITION_DATE_STR, USE_COUNTRY_SELECTION
|
||||
|
||||
from config import DATABASE_URL, PROJECT_NAME, SUB_MESSAGE, SUPERNODE, TRANSITION_DATE_STR, USE_COUNTRY_SELECTION
|
||||
from database import get_key_details, get_servers
|
||||
from logger import logger
|
||||
|
||||
@@ -98,7 +98,7 @@ async def handle_subscription(request, old_subscription=False):
|
||||
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
|
||||
|
||||
stored_tg_id = client_data.get("tg_id")
|
||||
server_id = client_data["server_id"] # В режиме выбора стран — это server_name, иначе — cluster_name
|
||||
server_id = client_data["server_id"]
|
||||
|
||||
if not old_subscription and str(tg_id) != str(stored_tg_id):
|
||||
logger.warning(f"Неверный tg_id для клиента с email {email}.")
|
||||
|
||||
@@ -1,600 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiofiles
|
||||
import asyncpg
|
||||
import pytz
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.types import BufferedInputFile
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
AUTO_DELETE_EXPIRED_KEYS,
|
||||
AUTO_RENEW_KEYS,
|
||||
DATABASE_URL,
|
||||
DELETE_KEYS_DELAY,
|
||||
DEV_MODE,
|
||||
EXPIRED_KEYS_CHECK_INTERVAL,
|
||||
RENEWAL_PLANS,
|
||||
SUPPORT_CHAT_URL,
|
||||
TOTAL_GB,
|
||||
TRIAL_TIME,
|
||||
)
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
create_blocked_user,
|
||||
delete_key,
|
||||
get_balance,
|
||||
get_servers,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons.profile import ADD_SUB
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
|
||||
from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED
|
||||
from logger import logger
|
||||
|
||||
from .utils import format_time_until_deletion
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def periodic_expired_keys_check(bot: Bot):
|
||||
"""Периодическая проверка истекших ключей с кастомным интервалом."""
|
||||
while True:
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
current_time = int(datetime.utcnow().timestamp() * 1000)
|
||||
await handle_expired_keys(bot, conn, current_time)
|
||||
logger.info("✅ Проверка истекших ключей выполнена.")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в periodic_expired_keys_check: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
await asyncio.sleep(EXPIRED_KEYS_CHECK_INTERVAL)
|
||||
|
||||
|
||||
async def notify_expiring_keys(bot: Bot):
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
logger.info("Подключение к базе данных успешно.")
|
||||
|
||||
current_time = int(datetime.utcnow().timestamp() * 1000)
|
||||
threshold_time_10h = int((datetime.utcnow() + timedelta(hours=10)).timestamp() * 1000)
|
||||
threshold_time_24h = int((datetime.utcnow() + timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
logger.info("Начало обработки уведомлений.")
|
||||
|
||||
await notify_inactive_trial_users(bot, conn)
|
||||
await asyncio.sleep(0.5)
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h)
|
||||
await asyncio.sleep(0.5)
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("Соединение с базой данных закрыто.")
|
||||
|
||||
|
||||
async def notify_10h_keys(
|
||||
bot: Bot,
|
||||
conn: asyncpg.Connection,
|
||||
current_time: float,
|
||||
threshold_time_10h: float,
|
||||
):
|
||||
records = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified = FALSE
|
||||
""",
|
||||
threshold_time_10h,
|
||||
current_time,
|
||||
)
|
||||
|
||||
logger.info(f"Найдено {len(records)} ключей для уведомления за 10 часов.")
|
||||
|
||||
for record in records:
|
||||
await process_10h_record(record, bot, conn)
|
||||
|
||||
logger.info("Обработка всех уведомлений за 10 часов завершена.")
|
||||
|
||||
|
||||
async def process_10h_record(record, bot, conn):
|
||||
tg_id = record["tg_id"]
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
can_notify = await check_notification_time(tg_id, "expiry_10h", hours=10, session=conn)
|
||||
if not can_notify:
|
||||
return
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
current_date = datetime.now(moscow_tz)
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
days_left_message = (
|
||||
"Ключ истек"
|
||||
if time_left.total_seconds() <= 0
|
||||
else f"{time_left.days}"
|
||||
if time_left.days > 0
|
||||
else f"{time_left.seconds // 3600}"
|
||||
)
|
||||
|
||||
message = KEY_EXPIRY_10H.format(
|
||||
email=email,
|
||||
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
days_left_message=days_left_message,
|
||||
price=RENEWAL_PLANS["1"]["price"],
|
||||
)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
if AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]:
|
||||
try:
|
||||
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"], conn)
|
||||
new_expiry_time = int((datetime.utcnow() + timedelta(days=30)).timestamp() * 1000)
|
||||
await update_key_expiry(record["client_id"], new_expiry_time, conn)
|
||||
|
||||
servers = await get_servers(conn)
|
||||
for cluster_id in servers:
|
||||
await renew_key_in_cluster(cluster_id, email, record["client_id"], new_expiry_time, TOTAL_GB)
|
||||
logger.info(f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}.")
|
||||
|
||||
await conn.execute("UPDATE keys SET notified = TRUE WHERE client_id = $1", record["client_id"])
|
||||
|
||||
image_path = os.path.join("img", "notify_10h.jpg")
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")]]
|
||||
)
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
async with aiofiles.open(image_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_data, filename="notify_10h.jpg"),
|
||||
caption=KEY_RENEWED.format(email=email),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED.format(email=email), reply_markup=keyboard)
|
||||
|
||||
logger.info(f"Уведомление об успешном продлении отправлено клиенту {tg_id}.")
|
||||
|
||||
await add_notification(tg_id, "expiry_10h", session=conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при продлении подписки для клиента {tg_id}: {e}")
|
||||
else:
|
||||
await send_renewal_notification(bot, tg_id, email, message, conn, record["client_id"], "notified")
|
||||
await add_notification(tg_id, "expiry_10h", session=conn)
|
||||
|
||||
|
||||
async def notify_24h_keys(
|
||||
bot: Bot,
|
||||
conn: asyncpg.Connection,
|
||||
current_time: float,
|
||||
threshold_time_24h: float,
|
||||
):
|
||||
logger.info("Проверка истекших ключей...")
|
||||
|
||||
records_24h = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified_24h = FALSE
|
||||
""",
|
||||
threshold_time_24h,
|
||||
current_time,
|
||||
)
|
||||
|
||||
logger.info(f"Найдено {len(records_24h)} ключей для уведомления за 24 часа.")
|
||||
|
||||
for record in records_24h:
|
||||
await process_24h_record(record, bot, conn)
|
||||
|
||||
logger.info("Обработка всех уведомлений за 24 часа завершена.")
|
||||
|
||||
|
||||
async def process_24h_record(record, bot, conn):
|
||||
tg_id = record["tg_id"]
|
||||
|
||||
can_notify = await check_notification_time(tg_id, "expiry_24h", hours=24, session=conn)
|
||||
if not can_notify:
|
||||
return
|
||||
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
client_id = record["client_id"]
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
current_date = datetime.now(moscow_tz)
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
days_left_message = (
|
||||
"Ключ истек"
|
||||
if time_left.total_seconds() <= 0
|
||||
else f"{time_left.days}"
|
||||
if time_left.days > 0
|
||||
else f"{time_left.seconds // 3600}"
|
||||
)
|
||||
|
||||
message_24h = KEY_EXPIRY_24H.format(
|
||||
email=email,
|
||||
days_left_message=days_left_message,
|
||||
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
if AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]:
|
||||
try:
|
||||
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"], conn)
|
||||
new_expiry_time = int((datetime.utcnow() + timedelta(days=30)).timestamp() * 1000)
|
||||
await update_key_expiry(record["client_id"], new_expiry_time, conn)
|
||||
|
||||
servers = await get_servers(conn)
|
||||
for cluster_id in servers:
|
||||
await renew_key_in_cluster(cluster_id, email, record["client_id"], new_expiry_time, TOTAL_GB)
|
||||
logger.info(f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}.")
|
||||
|
||||
await conn.execute("UPDATE keys SET notified_24h = TRUE WHERE client_id = $1", client_id)
|
||||
|
||||
image_path = os.path.join("img", "notify_24h.jpg")
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")]]
|
||||
)
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
async with aiofiles.open(image_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_data, filename="notify_24h.jpg"),
|
||||
caption=KEY_RENEWED.format(email=email),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED.format(email=email), reply_markup=keyboard)
|
||||
|
||||
logger.info(f"Уведомление об успешном продлении отправлено клиенту {tg_id}.")
|
||||
|
||||
await add_notification(tg_id, "expiry_24h", session=conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при продлении подписки для клиента {tg_id}: {e}")
|
||||
|
||||
image_path = os.path.join("img", "notify_24h.jpg")
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")]]
|
||||
)
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
async with aiofiles.open(image_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_data, filename="notify_24h.jpg"),
|
||||
caption=KEY_RENEWED.format(email=email),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED.format(email=email), reply_markup=keyboard)
|
||||
|
||||
logger.info(f"Уведомление об успешном продлении отправлено клиенту {tg_id}.")
|
||||
|
||||
await add_notification(tg_id, "expiry_24h", session=conn)
|
||||
|
||||
else:
|
||||
await send_renewal_notification(bot, tg_id, email, message_24h, conn, client_id, "notified_24h")
|
||||
await add_notification(tg_id, "expiry_24h", session=conn)
|
||||
|
||||
|
||||
async def send_renewal_notification(bot, tg_id, email, message, conn, client_id, flag):
|
||||
try:
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
keyboard.row(types.InlineKeyboardButton(text="🔄 Продлить VPN", callback_data=f"renew_key|{email}"))
|
||||
keyboard.row(types.InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
|
||||
keyboard.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
image_path = os.path.join("img", "notify_24h.jpg")
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
async with aiofiles.open(image_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(image_data, filename="notify_24h.jpg"),
|
||||
caption=message,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=message, reply_markup=keyboard.as_markup())
|
||||
|
||||
logger.info(f"Уведомление отправлено пользователю {tg_id}.")
|
||||
|
||||
if flag == "notified_24h":
|
||||
await conn.execute("UPDATE keys SET notified_24h = TRUE WHERE client_id = $1", client_id)
|
||||
elif flag == "notified":
|
||||
await conn.execute("UPDATE keys SET notified = TRUE WHERE client_id = $1", client_id)
|
||||
else:
|
||||
logger.warning(f"Неизвестный флаг обновления уведомления: {flag}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
|
||||
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
|
||||
inactive_trial_users = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, username, first_name, last_name FROM users
|
||||
WHERE tg_id IN (
|
||||
SELECT tg_id FROM connections
|
||||
WHERE trial = 0
|
||||
) AND tg_id NOT IN (
|
||||
SELECT DISTINCT tg_id FROM keys
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info(f"Найдено {len(inactive_trial_users)} неактивных пользователей.")
|
||||
|
||||
for user in inactive_trial_users:
|
||||
tg_id = user["tg_id"]
|
||||
|
||||
username = user["username"]
|
||||
first_name = user["first_name"]
|
||||
last_name = user["last_name"]
|
||||
display_name = username or first_name or last_name or "Пользователь"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, "inactive_trial", hours=24, session=conn)
|
||||
|
||||
if can_notify:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
message = (
|
||||
f"👋 Привет, {display_name}!\n\n"
|
||||
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
|
||||
"🕒 Не упусти возможность попробовать наш VPN прямо сейчас.\n\n"
|
||||
"💡 Нажми на кнопку ниже, чтобы активировать пробный доступ."
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке пользователя {tg_id}: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: float):
|
||||
logger.info("Проверка подписок, срок действия которых скоро истекает...")
|
||||
|
||||
threshold_time = int((datetime.utcnow() + timedelta(seconds=EXPIRED_KEYS_CHECK_INTERVAL * 1.5)).timestamp() * 1000)
|
||||
|
||||
expiring_keys = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, client_id, expiry_time, email, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2
|
||||
""",
|
||||
threshold_time,
|
||||
current_time,
|
||||
)
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, срок действия которых скоро истекает.")
|
||||
|
||||
for record in expiring_keys:
|
||||
await process_key(record, bot, conn, current_time)
|
||||
|
||||
expired_keys_query = """
|
||||
SELECT tg_id, client_id, email, server_id, expiry_time FROM keys
|
||||
WHERE expiry_time <= $1
|
||||
"""
|
||||
params = (current_time,)
|
||||
|
||||
expired_keys = await conn.fetch(expired_keys_query, *params)
|
||||
logger.info(f"Найдено {len(expired_keys)} истёкших подписок.")
|
||||
|
||||
for record in expired_keys:
|
||||
try:
|
||||
balance = await get_balance(record["tg_id"])
|
||||
expiry_time_value = record["expiry_time"]
|
||||
current_time_utc = int(datetime.utcnow().timestamp() * 1000)
|
||||
time_since_expiry = current_time_utc - expiry_time_value
|
||||
|
||||
if AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]:
|
||||
await process_key(record, bot, conn, current_time, renew=True)
|
||||
else:
|
||||
await process_key(record, bot, conn, current_time)
|
||||
if time_since_expiry >= DELETE_KEYS_DELAY * 1000:
|
||||
await delete_key_from_cluster(
|
||||
cluster_id=record["server_id"], email=record["email"], client_id=record["client_id"]
|
||||
)
|
||||
await delete_key(record["client_id"], conn)
|
||||
logger.info(f"Подписка {record['client_id']} удалена")
|
||||
|
||||
message = (
|
||||
f"🔔 <b>Уведомление:</b>\n\n"
|
||||
f"📅 Ваша подписка: {record['email']} была удалена из-за истечения срока действия.\n\n"
|
||||
f"⏳ Чтобы продолжить использовать наши услуги, пожалуйста, создайте новую подписку.\n\n"
|
||||
f"💬 Если у вас возникли вопросы, не стесняйтесь обращаться в поддержку!"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
keyboard.row(types.InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
keyboard.row(types.InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
keyboard.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
image_path = os.path.join("img", "notify_expired.jpg")
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
async with aiofiles.open(image_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
await bot.send_photo(
|
||||
record["tg_id"],
|
||||
photo=BufferedInputFile(image_data, filename="notify_expired.jpg"),
|
||||
caption=message,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(record["tg_id"], text=message, reply_markup=keyboard.as_markup())
|
||||
|
||||
logger.info(f"Уведомление об удалении отправлено пользователю {record['tg_id']}")
|
||||
|
||||
await add_notification(record["tg_id"], "expired_key", session=conn)
|
||||
|
||||
else:
|
||||
remaining_time = (DELETE_KEYS_DELAY * 1000 - time_since_expiry) // 1000
|
||||
logger.info(
|
||||
f"Подписка {record['client_id']} не удалена. Осталось времени до удаления: {remaining_time} сек. (Удаление через {DELETE_KEYS_DELAY} сек после истечения)"
|
||||
)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"Бот заблокирован пользователем {record['tg_id']}. Уведомление не отправлено.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении подписки {record['client_id']}: {e}")
|
||||
|
||||
|
||||
async def process_key(record, bot, conn, current_time, renew=False):
|
||||
tg_id = record["tg_id"]
|
||||
|
||||
can_notify = await check_notification_time(tg_id, "expired_key", hours=24, session=conn)
|
||||
if not can_notify:
|
||||
return
|
||||
|
||||
client_id = record["client_id"]
|
||||
email = record["email"]
|
||||
balance = await get_balance(tg_id)
|
||||
expiry_time_value = record["expiry_time"]
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
expiry_date = datetime.fromtimestamp(expiry_time_value / 1000, tz=moscow_tz)
|
||||
current_date = datetime.now(moscow_tz)
|
||||
|
||||
logger.info(
|
||||
f"Время истечения подписки: {expiry_time_value} (МСК: {expiry_date}), Текущее время (МСК): {current_date}"
|
||||
)
|
||||
|
||||
current_time_utc = int(datetime.utcnow().timestamp() * 1000)
|
||||
time_since_expiry = current_time_utc - expiry_time_value
|
||||
|
||||
try:
|
||||
if not renew:
|
||||
if current_time_utc >= expiry_time_value:
|
||||
if time_since_expiry <= DELETE_KEYS_DELAY * 500:
|
||||
message = (
|
||||
f"🔔 <b>Уведомление:</b>\n\n"
|
||||
f"📅 Ваша подписка: {record['email']} истекла. Пополните баланс для продления.\n\n"
|
||||
)
|
||||
remaining_time = (expiry_time_value + DELETE_KEYS_DELAY * 1000) - current_time_utc
|
||||
|
||||
if remaining_time > 0:
|
||||
message += (
|
||||
f"⏳ Подписка будет удалена через {format_time_until_deletion(remaining_time // 1000)}."
|
||||
)
|
||||
|
||||
await send_notification(bot, tg_id, message, "notify_expired.jpg", email)
|
||||
await add_notification(tg_id, "expired_key", session=conn)
|
||||
else:
|
||||
if (expiry_time_value - current_time_utc) <= (EXPIRED_KEYS_CHECK_INTERVAL * 1000):
|
||||
await send_notification(
|
||||
bot,
|
||||
tg_id,
|
||||
f"Ваша подписка {email} скоро истечет. Пополните баланс для продления.",
|
||||
"notify_expiring.jpg",
|
||||
email,
|
||||
)
|
||||
await add_notification(tg_id, "expired_key", session=conn)
|
||||
|
||||
elif renew and AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]:
|
||||
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"], conn)
|
||||
new_expiry_time = int((datetime.now(moscow_tz) + timedelta(days=30)).timestamp() * 1000)
|
||||
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
servers = await get_servers(conn)
|
||||
|
||||
for cluster_id in servers:
|
||||
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, TOTAL_GB)
|
||||
logger.info(f"Подписка {tg_id} продлена в кластере {cluster_id}.")
|
||||
|
||||
try:
|
||||
image_path = os.path.join("img", "notify_expired.jpg")
|
||||
caption = KEY_RENEWED.format(email=email)
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
async with aiofiles.open(image_path, "rb") as f:
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(await f.read(), filename="notify_expired.jpg"),
|
||||
caption=caption,
|
||||
reply_markup=InlineKeyboardBuilder().as_markup(),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=caption)
|
||||
|
||||
logger.info(f"Уведомление о продлении отправлено {tg_id}")
|
||||
|
||||
await add_notification(tg_id, "expired_key", session=conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления {tg_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки подписки {tg_id}: {e}")
|
||||
|
||||
|
||||
async def send_notification(bot, tg_id, message, image_name, email):
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
if DELETE_KEYS_DELAY > 0:
|
||||
keyboard.row(types.InlineKeyboardButton(text="🔄 Продлить", callback_data=f"renew_key|{email}"))
|
||||
keyboard.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
image_path = os.path.join("img", "notify_expired.jpg")
|
||||
|
||||
try:
|
||||
if os.path.isfile(image_path):
|
||||
async with aiofiles.open(image_path, "rb") as f:
|
||||
await bot.send_photo(
|
||||
tg_id,
|
||||
photo=BufferedInputFile(await f.read(), filename="notify_expired.jpg"),
|
||||
caption=message,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, text=message, reply_markup=keyboard.as_markup())
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"Пользователь {tg_id} заблокировал бота")
|
||||
@@ -0,0 +1,10 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .general_notifications import router as general_notifications_router
|
||||
from .special_notifications import router as special_notifications_router
|
||||
|
||||
router = Router(name="notifications_main_router")
|
||||
|
||||
router.include_routers(general_notifications_router, special_notifications_router)
|
||||
@@ -0,0 +1,355 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
from aiogram import Bot, Router
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
NOTIFICATION_TIME,
|
||||
NOTIFY_DELETE_DELAY,
|
||||
NOTIFY_DELETE_KEY,
|
||||
NOTIFY_MAXPRICE,
|
||||
NOTIFY_RENEW,
|
||||
NOTIFY_RENEW_EXPIRED,
|
||||
RENEWAL_PRICES,
|
||||
TOTAL_GB,
|
||||
TRIAL_TIME_DISABLE,
|
||||
)
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
delete_key,
|
||||
get_all_keys,
|
||||
get_balance,
|
||||
get_last_notification_time,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
|
||||
from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED
|
||||
from keyboards.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
|
||||
from logger import logger
|
||||
|
||||
from .notify_utils import send_notification
|
||||
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def periodic_notifications(bot: Bot):
|
||||
"""
|
||||
Обработчик, который:
|
||||
1. Получает список всех ключей.
|
||||
2. Отправляет уведомления пользователям о неактивном пробном периоде (если триал включен).
|
||||
3. Отправляет уведомления об истекающих ключах (10h и 24h).
|
||||
4. Проверяет истекшие ключи.
|
||||
5. Проверяет пользователей с нулевым трафиком.
|
||||
"""
|
||||
while True:
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
|
||||
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
logger.info("Начало обработки уведомлений.")
|
||||
|
||||
try:
|
||||
keys = await get_all_keys(session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключей: {e}")
|
||||
keys = []
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
await notify_inactive_trial_users(bot, conn)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await handle_expired_keys(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
await notify_users_no_traffic(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в periodic_notifications: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("Соединение с базой данных закрыто.")
|
||||
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
|
||||
|
||||
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_24h: int, keys: list):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 24 часа.")
|
||||
|
||||
expiring_keys = [
|
||||
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_24h
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
notification_id = f"{email}_key_24h"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
days_left_message = (
|
||||
f"⏳ Осталось времени: {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 (МСК)")
|
||||
|
||||
notification_text = KEY_EXPIRY_24H.format(
|
||||
email=email,
|
||||
days_left_message=days_left_message,
|
||||
formatted_expiry_date=formatted_expiry_date,
|
||||
)
|
||||
|
||||
if NOTIFY_RENEW:
|
||||
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
await send_notification(bot, tg_id, "notify_24h.jpg", notification_text, keyboard)
|
||||
logger.info(f"Отправлено уведомление об истечении подписки через 24 часа для пользователя {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
logger.info("✅ Обработка всех уведомлений за 24 часа завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_10h: int, keys: list):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 10 часов.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 10 часов.")
|
||||
|
||||
expiring_keys = [
|
||||
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_10h
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
notification_id = f"{email}_key_10h"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_message = (
|
||||
f"⏳ Осталось времени: {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 (МСК)")
|
||||
|
||||
notification_text = KEY_EXPIRY_10H.format(
|
||||
email=email,
|
||||
hours_left_message=hours_left_message,
|
||||
formatted_expiry_date=formatted_expiry_date,
|
||||
)
|
||||
|
||||
if NOTIFY_RENEW:
|
||||
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_10h.jpg", notification_text)
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
await send_notification(bot, tg_id, "notify_10h.jpg", notification_text, keyboard)
|
||||
logger.info(f"Отправлено уведомление об истечении подписки через 10 часов для пользователя {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
logger.info("✅ Обработка всех уведомлений за 10 часов завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
|
||||
"""
|
||||
Обрабатывает истекшие ключи, проверяя продление или удаление.
|
||||
"""
|
||||
logger.info("Начало обработки истекших ключей.")
|
||||
|
||||
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
|
||||
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
||||
|
||||
for key in expired_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
client_id = key.get("client_id")
|
||||
server_id = key.get("server_id")
|
||||
notification_id = f"{email}_key_expired"
|
||||
|
||||
try:
|
||||
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения времени последнего уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if NOTIFY_RENEW_EXPIRED:
|
||||
try:
|
||||
balance = await get_balance(tg_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения баланса для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
renewal_period_months = 1
|
||||
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
|
||||
|
||||
if balance >= renewal_cost:
|
||||
await process_auto_renew_or_notify(
|
||||
bot, conn, key, notification_id, 1, "notify_expired.jpg", "Ваш ключ продлён!"
|
||||
)
|
||||
continue
|
||||
|
||||
if NOTIFY_DELETE_KEY:
|
||||
delete_immediately = NOTIFY_DELETE_DELAY == 0
|
||||
delete_after_delay = False
|
||||
|
||||
if last_notification_time is not None:
|
||||
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= NOTIFY_DELETE_DELAY
|
||||
logger.info(
|
||||
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
|
||||
f"NOTIFY_DELETE_DELAY={NOTIFY_DELETE_DELAY}"
|
||||
)
|
||||
|
||||
if delete_immediately or delete_after_delay:
|
||||
try:
|
||||
await delete_key_from_cluster(server_id, email, client_id)
|
||||
await delete_key(client_id, conn)
|
||||
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
await send_notification(
|
||||
bot,
|
||||
tg_id,
|
||||
"notify_expired.jpg",
|
||||
f"Ваша подписка {email} была удалена, так как вы не продлили её действие.\n\n"
|
||||
"Перейдите в личный кабинет и получите новую!",
|
||||
keyboard,
|
||||
)
|
||||
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if last_notification_time is None:
|
||||
keyboard = build_notification_kb(email)
|
||||
await send_notification(
|
||||
bot,
|
||||
tg_id,
|
||||
"notify_expired.jpg",
|
||||
f"⚠ Ваша подписка {email} истекла!\n\nПродлите доступ, чтобы возобновить услуги.",
|
||||
keyboard,
|
||||
)
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
logger.info(f"📢 Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}.")
|
||||
|
||||
logger.info("✅ Обработка истекших ключей завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def process_auto_renew_or_notify(
|
||||
bot, conn, key: dict, notification_id: str, renewal_period_months: int, standard_photo: str, standard_caption: str
|
||||
):
|
||||
"""
|
||||
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
|
||||
иначе отправляет стандартное уведомление.
|
||||
"""
|
||||
tg_id = key.get("tg_id")
|
||||
email = key.get("email", "")
|
||||
renew_notification_id = f"{email}_renew"
|
||||
|
||||
try:
|
||||
can_renew = await check_notification_time(tg_id, renew_notification_id, hours=24, session=conn)
|
||||
if not can_renew:
|
||||
logger.info(
|
||||
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
|
||||
)
|
||||
return
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных для пользователя {tg_id}: {e}")
|
||||
return
|
||||
|
||||
if NOTIFY_MAXPRICE:
|
||||
renewal_period_months = max(
|
||||
(int(months) for months, price in RENEWAL_PRICES.items() if balance >= price), default=None
|
||||
)
|
||||
else:
|
||||
renewal_period_months = 1 if balance >= RENEWAL_PRICES["1"] else None
|
||||
|
||||
if renewal_period_months:
|
||||
renewal_period_months = int(renewal_period_months)
|
||||
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
|
||||
client_id = key.get("client_id")
|
||||
server_id = key.get("server_id")
|
||||
current_expiry = key.get("expiry_time")
|
||||
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
|
||||
|
||||
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
|
||||
|
||||
logger.info(
|
||||
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
|
||||
)
|
||||
|
||||
try:
|
||||
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, TOTAL_GB)
|
||||
await update_balance(tg_id, -renewal_cost, session=conn)
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
|
||||
await add_notification(tg_id, renew_notification_id, session=conn)
|
||||
|
||||
logger.info(
|
||||
f"✅ Ключ {client_id} продлён на {renewal_period_months} мес. для пользователя {tg_id}. Списано {renewal_cost}."
|
||||
)
|
||||
|
||||
renewed_message = KEY_RENEWED.format(
|
||||
email=email, months=renewal_period_months, expiry_date=formatted_expiry_date
|
||||
)
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
|
||||
except KeyError as e:
|
||||
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
from aiogram import Bot, types
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def send_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
image_filename: str,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup,
|
||||
):
|
||||
"""
|
||||
Отправляет уведомление с изображением, если файл существует, иначе отправляет текстовое сообщение.
|
||||
"""
|
||||
photo_path = os.path.join("img", image_filename)
|
||||
if os.path.isfile(photo_path):
|
||||
try:
|
||||
async with aiofiles.open(photo_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
|
||||
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
else:
|
||||
logger.error(f"Файл с изображением не найден: {photo_path}")
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
@@ -0,0 +1,198 @@
|
||||
import asyncio
|
||||
|
||||
import asyncpg
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
create_blocked_user,
|
||||
)
|
||||
from handlers.keys.key_utils import get_user_traffic
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
"""
|
||||
Проверяет пользователей, не активировавших пробный период, и отправляет им напоминания.
|
||||
Первое уведомление — стандартное.
|
||||
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
|
||||
"""
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
|
||||
inactive_trial_users = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, username, first_name, last_name FROM users
|
||||
WHERE tg_id IN (
|
||||
SELECT tg_id FROM connections
|
||||
WHERE trial = 0
|
||||
)
|
||||
AND tg_id NOT IN (
|
||||
SELECT tg_id FROM blocked_users
|
||||
)
|
||||
AND tg_id NOT IN (
|
||||
SELECT DISTINCT tg_id FROM keys
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info(f"👥 Найдено {len(inactive_trial_users)} неактивных пользователей.")
|
||||
|
||||
for user in inactive_trial_users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user["username"]
|
||||
first_name = user["first_name"]
|
||||
last_name = user["last_name"]
|
||||
display_name = username or first_name or last_name or "Пользователь"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, "inactive_trial", hours=NOTIFY_INACTIVE, session=conn)
|
||||
|
||||
if can_notify:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
trial_extended = await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notifications
|
||||
WHERE tg_id = $1 AND notification_type = 'inactive_trial'
|
||||
)
|
||||
""",
|
||||
tg_id,
|
||||
)
|
||||
|
||||
if trial_extended:
|
||||
message = (
|
||||
f"<b>{display_name}</b>, у нас для тебя подарок! 🎁\n\n"
|
||||
"<blockquote>"
|
||||
f"Мы добавили тебе +{NOTIFY_EXTRA_DAYS} дополнительных дня к пробному периоду!\n"
|
||||
f"Теперь у тебя есть еще шанс протестировать наш VPN целых {NOTIFY_EXTRA_DAYS + TRIAL_TIME} дня!\n"
|
||||
"</blockquote>"
|
||||
"Нажми на кнопку ниже, чтобы активировать доступ с бонусом +2 дня! 👇"
|
||||
)
|
||||
|
||||
await conn.execute("UPDATE connections SET trial = -1 WHERE tg_id = $1", tg_id)
|
||||
else:
|
||||
message = (
|
||||
f"👋 <b>Привет, {display_name}!</b>\n\n"
|
||||
"<blockquote>"
|
||||
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
|
||||
"Не упусти возможность попробовать наш VPN прямо сейчас.\n"
|
||||
"</blockquote>"
|
||||
"Нажми на кнопку ниже, чтобы активировать пробный доступ! 👇"
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при обработке пользователя {tg_id}: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
|
||||
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
|
||||
"""
|
||||
Проверяет трафик пользователей, у которых ещё не отправлялось уведомление о нулевом трафике.
|
||||
Если трафик 0 ГБ и прошло более 2 часов с момента создания ключа, отправляет уведомление и
|
||||
обновляет запись в БД (notified = TRUE).
|
||||
"""
|
||||
logger.info("Проверка пользователей с нулевым трафиком...")
|
||||
|
||||
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
|
||||
|
||||
for key in keys:
|
||||
tg_id = key.get("tg_id")
|
||||
email = key.get("email")
|
||||
created_at = key.get("created_at")
|
||||
client_id = key.get("client_id")
|
||||
notified = key.get("notified")
|
||||
|
||||
logger.info(f"Обработка ключа для {email}: created_at = {created_at}")
|
||||
|
||||
if created_at is None:
|
||||
logger.warning(f"Для {email} нет значения created_at. Пропускаем.")
|
||||
continue
|
||||
|
||||
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
|
||||
created_at_plus_2 = created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
|
||||
|
||||
if current_dt < created_at_plus_2:
|
||||
continue
|
||||
|
||||
if notified:
|
||||
logger.info(f"Уведомление для {email} уже отправлено, пропускаем.")
|
||||
continue
|
||||
|
||||
try:
|
||||
traffic_data = await get_user_traffic(conn, tg_id, email)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения трафика для {email}: {e}")
|
||||
continue
|
||||
|
||||
if traffic_data.get("status") != "success":
|
||||
logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
||||
continue
|
||||
|
||||
total_traffic = sum(
|
||||
value if isinstance(value, (int, float)) else 0 for value in traffic_data.get("traffic", {}).values()
|
||||
)
|
||||
logger.info(f"Ключ для {email}: общий трафик: {total_traffic} ГБ")
|
||||
|
||||
if total_traffic == 0:
|
||||
logger.info(f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
|
||||
builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
message = (
|
||||
"⚠ <b>Ваша VPN-подписка активна, но трафик не используется.</b>\n\n"
|
||||
"<blockquote>Если у вас возникли сложности с подключением, "
|
||||
"нажмите кнопку ниже, чтобы связаться с поддержкой.</blockquote>\n\n"
|
||||
"🛠 Мы поможем вам разобраться! 💡"
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
||||
@@ -1,6 +1,7 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
DONATIONS_ENABLE,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Generated by Cython 3.0.11 */
|
||||
/* Generated by Cython 3.0.12 */
|
||||
|
||||
/* BEGIN: Cython Metadata
|
||||
{
|
||||
@@ -43,10 +43,10 @@ END: Cython Metadata */
|
||||
#else
|
||||
#define __PYX_EXTRA_ABI_MODULE_NAME ""
|
||||
#endif
|
||||
#define CYTHON_ABI "3_0_11" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define CYTHON_ABI "3_0_12" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define __PYX_ABI_MODULE_NAME "_cython_" CYTHON_ABI
|
||||
#define __PYX_TYPE_MODULE_PREFIX __PYX_ABI_MODULE_NAME "."
|
||||
#define CYTHON_HEX_VERSION 0x03000BF0
|
||||
#define CYTHON_HEX_VERSION 0x03000CF0
|
||||
#define CYTHON_FUTURE_DIVISION 1
|
||||
#include <stddef.h>
|
||||
#ifndef offsetof
|
||||
@@ -1875,7 +1875,7 @@ static PyObject *__Pyx_PyFunction_FastCallDict(PyObject *func, PyObject **args,
|
||||
#if !CYTHON_VECTORCALL
|
||||
#if PY_VERSION_HEX >= 0x03080000
|
||||
#include "frameobject.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -13203,7 +13203,7 @@ static CYTHON_INLINE PyObject* __Pyx_PyObject_CallNoArg(PyObject *func) {
|
||||
|
||||
/* CoroutineBase */
|
||||
#include <frameobject.h>
|
||||
#if PY_VERSION_HEX >= 0x030b00a6
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -17361,7 +17361,7 @@ static void __pyx_insert_code_object(int code_line, PyCodeObject* code_object) {
|
||||
#include "compile.h"
|
||||
#include "frameobject.h"
|
||||
#include "traceback.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
/* Generated by Cython 3.0.11 */
|
||||
/* Generated by Cython 3.0.12 */
|
||||
|
||||
/* BEGIN: Cython Metadata
|
||||
{
|
||||
@@ -43,10 +43,10 @@ END: Cython Metadata */
|
||||
#else
|
||||
#define __PYX_EXTRA_ABI_MODULE_NAME ""
|
||||
#endif
|
||||
#define CYTHON_ABI "3_0_11" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define CYTHON_ABI "3_0_12" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define __PYX_ABI_MODULE_NAME "_cython_" CYTHON_ABI
|
||||
#define __PYX_TYPE_MODULE_PREFIX __PYX_ABI_MODULE_NAME "."
|
||||
#define CYTHON_HEX_VERSION 0x03000BF0
|
||||
#define CYTHON_HEX_VERSION 0x03000CF0
|
||||
#define CYTHON_FUTURE_DIVISION 1
|
||||
#include <stddef.h>
|
||||
#ifndef offsetof
|
||||
@@ -1916,7 +1916,7 @@ static PyObject *__Pyx_PyFunction_FastCallDict(PyObject *func, PyObject **args,
|
||||
#if !CYTHON_VECTORCALL
|
||||
#if PY_VERSION_HEX >= 0x03080000
|
||||
#include "frameobject.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -15506,7 +15506,7 @@ static CYTHON_INLINE PyObject* __Pyx_PyObject_CallNoArg(PyObject *func) {
|
||||
|
||||
/* CoroutineBase */
|
||||
#include <frameobject.h>
|
||||
#if PY_VERSION_HEX >= 0x030b00a6
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -19705,7 +19705,7 @@ static void __pyx_insert_code_object(int code_line, PyCodeObject* code_object) {
|
||||
#include "compile.h"
|
||||
#include "frameobject.h"
|
||||
#include "traceback.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
/* Generated by Cython 3.0.11 */
|
||||
/* Generated by Cython 3.0.12 */
|
||||
|
||||
/* BEGIN: Cython Metadata
|
||||
{
|
||||
@@ -43,10 +43,10 @@ END: Cython Metadata */
|
||||
#else
|
||||
#define __PYX_EXTRA_ABI_MODULE_NAME ""
|
||||
#endif
|
||||
#define CYTHON_ABI "3_0_11" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define CYTHON_ABI "3_0_12" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define __PYX_ABI_MODULE_NAME "_cython_" CYTHON_ABI
|
||||
#define __PYX_TYPE_MODULE_PREFIX __PYX_ABI_MODULE_NAME "."
|
||||
#define CYTHON_HEX_VERSION 0x03000BF0
|
||||
#define CYTHON_HEX_VERSION 0x03000CF0
|
||||
#define CYTHON_FUTURE_DIVISION 1
|
||||
#include <stddef.h>
|
||||
#ifndef offsetof
|
||||
@@ -1866,7 +1866,7 @@ static PyObject *__Pyx_PyFunction_FastCallDict(PyObject *func, PyObject **args,
|
||||
#if !CYTHON_VECTORCALL
|
||||
#if PY_VERSION_HEX >= 0x03080000
|
||||
#include "frameobject.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -12512,7 +12512,7 @@ static CYTHON_INLINE PyObject* __Pyx_PyObject_CallNoArg(PyObject *func) {
|
||||
|
||||
/* CoroutineBase */
|
||||
#include <frameobject.h>
|
||||
#if PY_VERSION_HEX >= 0x030b00a6
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -16634,7 +16634,7 @@ static void __pyx_insert_code_object(int code_line, PyCodeObject* code_object) {
|
||||
#include "compile.h"
|
||||
#include "frameobject.h"
|
||||
#include "traceback.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
|
||||
Binary file not shown.
+2341
-1064
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
/* Generated by Cython 3.0.11 */
|
||||
/* Generated by Cython 3.0.12 */
|
||||
|
||||
/* BEGIN: Cython Metadata
|
||||
{
|
||||
@@ -43,10 +43,10 @@ END: Cython Metadata */
|
||||
#else
|
||||
#define __PYX_EXTRA_ABI_MODULE_NAME ""
|
||||
#endif
|
||||
#define CYTHON_ABI "3_0_11" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define CYTHON_ABI "3_0_12" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define __PYX_ABI_MODULE_NAME "_cython_" CYTHON_ABI
|
||||
#define __PYX_TYPE_MODULE_PREFIX __PYX_ABI_MODULE_NAME "."
|
||||
#define CYTHON_HEX_VERSION 0x03000BF0
|
||||
#define CYTHON_HEX_VERSION 0x03000CF0
|
||||
#define CYTHON_FUTURE_DIVISION 1
|
||||
#include <stddef.h>
|
||||
#ifndef offsetof
|
||||
@@ -1886,7 +1886,7 @@ static PyObject *__Pyx_PyFunction_FastCallDict(PyObject *func, PyObject **args,
|
||||
#if !CYTHON_VECTORCALL
|
||||
#if PY_VERSION_HEX >= 0x03080000
|
||||
#include "frameobject.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -14540,7 +14540,7 @@ static CYTHON_INLINE PyObject* __Pyx_PyObject_CallNoArg(PyObject *func) {
|
||||
|
||||
/* CoroutineBase */
|
||||
#include <frameobject.h>
|
||||
#if PY_VERSION_HEX >= 0x030b00a6
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -18917,7 +18917,7 @@ static void __pyx_insert_code_object(int code_line, PyCodeObject* code_object) {
|
||||
#include "compile.h"
|
||||
#include "frameobject.h"
|
||||
#include "traceback.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
/* Generated by Cython 3.0.11 */
|
||||
/* Generated by Cython 3.0.12 */
|
||||
|
||||
/* BEGIN: Cython Metadata
|
||||
{
|
||||
@@ -43,10 +43,10 @@ END: Cython Metadata */
|
||||
#else
|
||||
#define __PYX_EXTRA_ABI_MODULE_NAME ""
|
||||
#endif
|
||||
#define CYTHON_ABI "3_0_11" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define CYTHON_ABI "3_0_12" __PYX_EXTRA_ABI_MODULE_NAME
|
||||
#define __PYX_ABI_MODULE_NAME "_cython_" CYTHON_ABI
|
||||
#define __PYX_TYPE_MODULE_PREFIX __PYX_ABI_MODULE_NAME "."
|
||||
#define CYTHON_HEX_VERSION 0x03000BF0
|
||||
#define CYTHON_HEX_VERSION 0x03000CF0
|
||||
#define CYTHON_FUTURE_DIVISION 1
|
||||
#include <stddef.h>
|
||||
#ifndef offsetof
|
||||
@@ -1851,7 +1851,7 @@ static PyObject *__Pyx_PyFunction_FastCallDict(PyObject *func, PyObject **args,
|
||||
#if !CYTHON_VECTORCALL
|
||||
#if PY_VERSION_HEX >= 0x03080000
|
||||
#include "frameobject.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -11253,7 +11253,7 @@ static CYTHON_INLINE PyObject* __Pyx_PyObject_CallNoArg(PyObject *func) {
|
||||
|
||||
/* CoroutineBase */
|
||||
#include <frameobject.h>
|
||||
#if PY_VERSION_HEX >= 0x030b00a6
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
@@ -15481,7 +15481,7 @@ static void __pyx_insert_code_object(int code_line, PyCodeObject* code_object) {
|
||||
#include "compile.h"
|
||||
#include "frameobject.h"
|
||||
#include "traceback.h"
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API
|
||||
#if PY_VERSION_HEX >= 0x030b00a6 && !CYTHON_COMPILING_IN_LIMITED_API && !defined(PYPY_VERSION)
|
||||
#ifndef Py_BUILD_CORE
|
||||
#define Py_BUILD_CORE 1
|
||||
#endif
|
||||
|
||||
Binary file not shown.
+12
-3
@@ -7,8 +7,8 @@ from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from config import DATABASE_URL, INSTRUCTIONS_BUTTON, NEWS_MESSAGE, RENEWAL_PLANS
|
||||
|
||||
from config import DATABASE_URL, INSTRUCTIONS_BUTTON, NEWS_MESSAGE, RENEWAL_PLANS
|
||||
from database import get_balance, get_key_count, get_last_payments, get_referral_stats, get_trial
|
||||
from handlers.buttons.profile import (
|
||||
ADD_SUB,
|
||||
@@ -117,13 +117,22 @@ async def process_callback_view_profile(
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance")
|
||||
async def balance_handler(callback_query: CallbackQuery):
|
||||
async def balance_handler(callback_query: CallbackQuery, session: Any):
|
||||
result = await session.fetchrow(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1",
|
||||
callback_query.from_user.id,
|
||||
)
|
||||
balance = result["balance"] if result else 0.0
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await callback_query.message.answer("💰 Управление балансом:", reply_markup=builder.as_markup())
|
||||
await callback_query.message.answer(
|
||||
f"<b>Управление вашим балансом 💰</b>\n\nВаш баланс: {balance}",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance_history")
|
||||
|
||||
+26
-9
@@ -27,7 +27,6 @@ from database import (
|
||||
add_connection,
|
||||
add_referral,
|
||||
check_connection_exists,
|
||||
get_coupon_details,
|
||||
get_referral_by_referred_id,
|
||||
get_trial,
|
||||
update_balance,
|
||||
@@ -54,8 +53,9 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
|
||||
try:
|
||||
await state.clear()
|
||||
logger.info(f"Состояние для пользователя {message.chat.id} успешно очищено.")
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"Состояние для пользователя {message.chat.id} не было очищено.")
|
||||
|
||||
if CAPTCHA_ENABLE and captcha:
|
||||
captcha_data = await generate_captcha(message, state)
|
||||
@@ -66,14 +66,19 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
try:
|
||||
member = await bot.get_chat_member(CHANNEL_ID, message.chat.id)
|
||||
if member.status not in ["member", "administrator", "creator"]:
|
||||
original_text = message.text
|
||||
await state.update_data(original_text=original_text)
|
||||
builder = InlineKeyboardBuilder()
|
||||
await state.update_data(original_text=message.text)
|
||||
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
|
||||
await message.answer(
|
||||
f"Для использования бота, пожалуйста, подпишитесь на наш канал: {CHANNEL_URL}",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.info(
|
||||
f"Пользователь {message.chat.id} подписан на канал (статус: {member.status}). Продолжаем работу."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписки пользователя {message.chat.id}: {e}")
|
||||
await state.update_data(start_text=message.text)
|
||||
@@ -155,10 +160,13 @@ async def process_start_logic(
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
if "gift_" in text:
|
||||
logger.info(f"Обнаружена ссылка на подарок: {message.text}")
|
||||
parts = message.text.split("gift_")[1].split("_")
|
||||
logger.info(f"Обнаружена ссылка на подарок: {text}")
|
||||
parts = text.split("gift_")[1].split("_")
|
||||
if len(parts) < 2:
|
||||
logger.error("Неверный формат ссылки на подарок: недостаточно частей после 'gift_'")
|
||||
await message.answer("❌ Неверный формат ссылки на подарок.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
gift_id = parts[0]
|
||||
|
||||
recipient_tg_id = message.chat.id
|
||||
|
||||
gift_info = await session.fetchrow(
|
||||
@@ -197,6 +205,13 @@ async def process_start_logic(
|
||||
f"Пользователь {recipient_tg_id} теперь является рефералом отправителя {gift_info['sender_tg_id']}."
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE connections SET trial = 1 WHERE tg_id = $1
|
||||
""",
|
||||
recipient_tg_id,
|
||||
)
|
||||
|
||||
selected_months = gift_info["selected_months"]
|
||||
expiry_time = gift_info["expiry_time"].replace(tzinfo=None)
|
||||
|
||||
@@ -223,8 +238,7 @@ async def process_start_logic(
|
||||
|
||||
elif "referral_" in text:
|
||||
try:
|
||||
referrer_tg_id = int(message.text.split("referral_")[1])
|
||||
|
||||
referrer_tg_id = int(text.split("referral_")[1])
|
||||
if connection_exists:
|
||||
logger.info(f"Пользователь {message.chat.id} уже зарегистрирован и не может стать рефералом.")
|
||||
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
|
||||
@@ -242,6 +256,7 @@ async def process_start_logic(
|
||||
|
||||
await add_referral(message.chat.id, referrer_tg_id, session)
|
||||
logger.info(f"Реферал {message.chat.id} использовал ссылку от пользователя {referrer_tg_id}")
|
||||
await message.answer(f"Вы стали рефералом пользователя с ID {referrer_tg_id}")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
@@ -279,7 +294,9 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
|
||||
else:
|
||||
await callback_query.answer("Подписка подтверждена!")
|
||||
data = await state.get_data()
|
||||
original_text = data.get("original_text", callback_query.message.text)
|
||||
original_text = data.get("original_text")
|
||||
if not original_text:
|
||||
original_text = callback_query.message.text
|
||||
await process_start_logic(callback_query.message, state, session, admin, text_to_process=original_text)
|
||||
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
|
||||
except Exception as e:
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ import string
|
||||
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
from config import DATABASE_URL
|
||||
|
||||
from bot import bot
|
||||
from config import DATABASE_URL
|
||||
from database import get_all_keys, get_servers
|
||||
from logger import logger
|
||||
|
||||
@@ -20,10 +20,10 @@ async def get_usd_rate():
|
||||
data = await response.text()
|
||||
usd = float(json.loads(data)["Valute"]["USD"]["Value"])
|
||||
else:
|
||||
usd = float(100) # Default value if request fails
|
||||
usd = float(100)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error fetching USD rate: {e}")
|
||||
usd = float(100) # Default value if an exception occurs
|
||||
usd = float(100)
|
||||
return usd
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
|
||||
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Формирует inline-клавиатуру для уведомлений.
|
||||
Кнопки: "🔄 Продлить VPN" (callback_data содержит email) и "👤 Личный кабинет".
|
||||
"""
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔄 Продлить VPN", callback_data=f"renew_key|{email}")
|
||||
builder.button(text="👤 Личный кабинет", callback_data="profile")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_notification_expired_kb() -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Формирует inline-клавиатуру для уведомлений после удаления или продления.
|
||||
Кнопка: "👤 Личный кабинет"
|
||||
"""
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="👤 Личный кабинет", callback_data="profile")
|
||||
return builder.as_markup()
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
from config import ADMIN_ID
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
import asyncpg
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
from config import DATABASE_URL
|
||||
|
||||
|
||||
|
||||
+64
-71
@@ -5,10 +5,10 @@ from datetime import datetime, timedelta
|
||||
import asyncpg
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from config import ADMIN_ID, DATABASE_URL, PING_TIME
|
||||
from ping3 import ping
|
||||
|
||||
from bot import bot
|
||||
from config import ADMIN_ID, DATABASE_URL, PING_TIME
|
||||
from database import check_unique_server_name, create_server, get_servers
|
||||
from logger import logger
|
||||
|
||||
@@ -16,13 +16,12 @@ try:
|
||||
from config import CLUSTERS
|
||||
except ImportError:
|
||||
CLUSTERS = None
|
||||
logger.warning("Переменная CLUSTERS не найдена в конфигурации. Добавьте сервера через админ-панель!")
|
||||
logger.info("Переменная CLUSTERS не найдена в конфигурации. Добавьте сервера через админ-панель в боте!")
|
||||
|
||||
|
||||
async def sync_servers_with_db():
|
||||
"""
|
||||
Синхронизирует сервера из конфигурации CLUSTERS с базой данных.
|
||||
Если CLUSTERS не найден, синхронизация не будет выполнена.
|
||||
"""
|
||||
if CLUSTERS is None:
|
||||
logger.info("Конфигурация CLUSTERS не найдена. Синхронизация не будет выполнена.")
|
||||
@@ -30,7 +29,6 @@ async def sync_servers_with_db():
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
logger.info("Подключение к базе данных для синхронизации серверов успешно.")
|
||||
|
||||
for cluster_name, servers in CLUSTERS.items():
|
||||
for _server_key, server_info in servers.items():
|
||||
@@ -45,9 +43,7 @@ async def sync_servers_with_db():
|
||||
inbound_id=server_info["INBOUND_ID"],
|
||||
session=conn,
|
||||
)
|
||||
logger.info(f"Сервер {server_info['name']} из кластера {cluster_name} добавлен в базу данных.")
|
||||
else:
|
||||
logger.info(f"Сервер {server_info['name']} из кластера {cluster_name} уже существует.")
|
||||
logger.info("✅ Синхронизация серверов завершена.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации серверов: {e}")
|
||||
@@ -58,104 +54,101 @@ async def sync_servers_with_db():
|
||||
|
||||
last_ping_times = {}
|
||||
last_notification_times = {}
|
||||
PING_SEMAPHORE = asyncio.Semaphore(3)
|
||||
|
||||
|
||||
async def ping_server(server_ip: str) -> bool:
|
||||
"""
|
||||
Функция пинга сервера.
|
||||
Возвращает True, если сервер доступен, иначе False.
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Пингуем сервер {server_ip}...")
|
||||
response = ping(server_ip, timeout=3)
|
||||
if response is False:
|
||||
logger.warning(f"Сервер {server_ip} не отвечает.")
|
||||
"""Пингует сервер через ICMP или TCP 443, если ICMP недоступен."""
|
||||
async with PING_SEMAPHORE:
|
||||
try:
|
||||
response = ping(server_ip, timeout=3)
|
||||
return response is not None and response is not False
|
||||
except PermissionError:
|
||||
return await check_tcp_connection(server_ip, 443)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def check_tcp_connection(host: str, port: int) -> bool:
|
||||
"""Проверяет доступность сервера через TCP (порт 443)."""
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при пинге сервера {server_ip}: {e}")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def notify_admin(server_name: str):
|
||||
"""
|
||||
Отправляет уведомление всем администраторам о недоступности сервера.
|
||||
Уведомления отправляются не чаще чем раз в 3 минуты.
|
||||
"""
|
||||
try:
|
||||
current_time = datetime.now()
|
||||
last_notification_time = last_notification_times.get(server_name)
|
||||
"""Отправляет уведомление администраторам о недоступности сервера (не чаще чем раз в 3 минуты)."""
|
||||
current_time = datetime.now()
|
||||
last_notification_time = last_notification_times.get(server_name)
|
||||
|
||||
if last_notification_time and current_time - last_notification_time < timedelta(minutes=3):
|
||||
logger.info(f"Не отправляем уведомление для сервера {server_name}, так как прошло менее 3 минут.")
|
||||
return
|
||||
if last_notification_time and current_time - last_notification_time < timedelta(minutes=3):
|
||||
return
|
||||
|
||||
logger.info(f"Отправка уведомлений администратору о недоступности сервера {server_name}...")
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="Управление сервером", callback_data=f"manage_server|{server_name}"))
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="Управление сервером", callback_data=f"manage_server|{server_name}"))
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_message(
|
||||
admin_id,
|
||||
(
|
||||
f"❌ <b>Сервер '{server_name}'</b> не отвечает более {PING_TIME * 3} секунд.\n\n"
|
||||
"Проверьте соединение к серверу или удалите его из списка, чтобы не выдавать подписки на неработающий сервер."
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_message(
|
||||
admin_id,
|
||||
(
|
||||
f"❌ <b>Сервер '{server_name}'</b> не отвечает более 3 минут.\n\n"
|
||||
"Проверьте соединение к серверу, подключение к панели или удалите его из таблицы серверов в боте, "
|
||||
"чтобы не выдать подписку к неработающему серверу."
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info(f"Уведомление отправлено администратору с ID {admin_id} о сервере {server_name}.")
|
||||
|
||||
last_notification_times[server_name] = current_time
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления администраторам: {e}")
|
||||
last_notification_times[server_name] = current_time
|
||||
|
||||
|
||||
async def check_servers():
|
||||
"""
|
||||
Периодическая проверка серверов с учетом извлечения хоста из `api_url`.
|
||||
Периодическая проверка серверов.
|
||||
Использует `asyncio.gather()` для ускорения.
|
||||
"""
|
||||
while True:
|
||||
servers = await get_servers()
|
||||
current_time = datetime.now()
|
||||
|
||||
logger.info(f"Начинаю проверку серверов: {current_time}")
|
||||
tasks = []
|
||||
server_info_list = []
|
||||
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
logger.debug(f"Проверка кластеров: {cluster_name}")
|
||||
for server in cluster_servers:
|
||||
original_api_url = server["api_url"]
|
||||
server_name = server["server_name"]
|
||||
|
||||
server_host = extract_host(original_api_url)
|
||||
logger.debug(f"Проверка доступности сервера '{server_name}' с хостом {server_host}")
|
||||
|
||||
is_online = await ping_server(server_host)
|
||||
server_info_list.append((server_name, server_host))
|
||||
tasks.append(ping_server(server_host))
|
||||
|
||||
if is_online:
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
offline_servers = []
|
||||
|
||||
for (server_name, _), is_online in zip(server_info_list, results, strict=False):
|
||||
if is_online:
|
||||
last_ping_times[server_name] = current_time
|
||||
else:
|
||||
last_ping_time = last_ping_times.get(server_name)
|
||||
if last_ping_time and current_time - last_ping_time > timedelta(seconds=PING_TIME * 3):
|
||||
offline_servers.append(server_name)
|
||||
await notify_admin(server_name)
|
||||
elif not last_ping_time:
|
||||
last_ping_times[server_name] = current_time
|
||||
else:
|
||||
last_ping_time = last_ping_times.get(server_name)
|
||||
if last_ping_time and current_time - last_ping_time > timedelta(minutes=3):
|
||||
logger.warning(f"Сервер {server_name} не отвечает более 3 минут. Отправляю уведомление.")
|
||||
await notify_admin(server_name)
|
||||
elif not last_ping_time:
|
||||
last_ping_times[server_name] = current_time
|
||||
logger.info(f"Сервер {server_name} не отвечал ранее, но теперь зарегистрирован.")
|
||||
|
||||
logger.info("Завершена проверка всех серверов.")
|
||||
online_servers = [name for name, _ in server_info_list if name not in offline_servers]
|
||||
logger.info(f"Проверка серверов завершена. Онлайн: {len(online_servers)}, Оффлайн: {len(offline_servers)}")
|
||||
if offline_servers:
|
||||
logger.warning(f"🚨 Не отвечает {len(offline_servers)} серверов: {', '.join(offline_servers)}")
|
||||
|
||||
await asyncio.sleep(PING_TIME)
|
||||
|
||||
|
||||
def extract_host(api_url: str) -> str:
|
||||
"""
|
||||
Извлекает только хост из `api_url` (без путей, портов и параметров).
|
||||
"""
|
||||
"""Извлекает хост из `api_url`."""
|
||||
match = re.match(r"(https?://)?([^:/]+)", api_url)
|
||||
if match:
|
||||
host = match.group(2)
|
||||
logger.debug(f"Извлечён хост: {host} из URL: {api_url}")
|
||||
return host
|
||||
logger.error(f"Не удалось извлечь хост из URL: {api_url}")
|
||||
return api_url
|
||||
return match.group(2) if match else api_url
|
||||
|
||||
Reference in New Issue
Block a user