NOTIFICATIONS 2.0/bug fixes and improvments

This commit is contained in:
Vladless
2025-02-14 00:30:06 +03:00
parent e37857da31
commit 0da8771a52
40 changed files with 3470 additions and 2001 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,6 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
from config import ADMIN_ID
-2
View File
@@ -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(
+77 -63
View File
@@ -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()
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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,
+56 -85
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+2 -2
View File
@@ -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}.")
-600
View File
@@ -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} заблокировал бота")
+10
View File
@@ -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)
+32
View File
@@ -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
View File
@@ -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,
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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
+2341 -1064
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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
+12 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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()
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -3,6 +3,7 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from config import ADMIN_ID
+1
View File
@@ -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
View File
@@ -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