tariff customization/traffic for each tariff/ruff

This commit is contained in:
Vladless
2025-05-16 06:02:18 +03:00
parent d520f02c03
commit 9a042c638a
30 changed files with 1190 additions and 488 deletions
+14
View File
@@ -193,6 +193,20 @@ CREATE TABLE IF NOT EXISTS tracking_sources (
ALTER TABLE users ADD COLUMN IF NOT EXISTS source_code TEXT REFERENCES tracking_sources (code);
CREATE TABLE IF NOT EXISTS tariffs (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
group_code TEXT NOT NULL,
duration_days INTEGER NOT NULL CHECK (duration_days > 0),
price_rub INTEGER NOT NULL CHECK (price_rub >= 0),
traffic_limit BIGINT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
ALTER TABLE servers ADD COLUMN IF NOT EXISTS tariff_group TEXT;
DO $$
BEGIN
+85
View File
@@ -1504,3 +1504,88 @@ async def get_tracking_source_stats(code: str, session) -> dict:
code,
)
return dict(result) if result else {}
async def get_tariffs(session, tariff_id: int = None, group_code: str = None):
"""
Возвращает список всех тарифов или конкретный тариф по ID или группе.
"""
query = "SELECT * FROM tariffs"
params = []
if tariff_id is not None:
query += " WHERE id = $1"
params = [tariff_id]
elif group_code is not None:
query += " WHERE group_code = $1"
params = [group_code]
result = await session.fetch(query, *params) if params else await session.fetch(query)
return [dict(record) for record in result]
async def update_tariff(session, tariff_id: int, updates: dict):
"""
Обновляет указанные поля в тарифе по его ID.
Пример updates: {"price_rub": 199, "is_active": False}
"""
if not updates:
return False
set_clause = ", ".join(f"{key} = ${i + 2}" for i, key in enumerate(updates))
values = list(updates.values())
query = f"""
UPDATE tariffs
SET {set_clause}, updated_at = NOW()
WHERE id = $1
"""
await session.execute(query, tariff_id, *values)
return True
async def create_tariff(session, data: dict):
"""
Создаёт новый тариф. Ожидает словарь с полями таблицы (без id, created_at).
"""
keys = ", ".join(data.keys())
values_placeholders = ", ".join(f"${i + 1}" for i in range(len(data)))
values = list(data.values())
query = f"""
INSERT INTO tariffs ({keys}, created_at, updated_at)
VALUES ({values_placeholders}, NOW(), NOW())
RETURNING *
"""
result = await session.fetchrow(query, *values)
return dict(result) if result else None
async def delete_tariff(session, tariff_id: int):
"""
Удаляет тариф по ID.
"""
query = "DELETE FROM tariffs WHERE id = $1"
await session.execute(query, tariff_id)
return True
async def get_tariffs_for_cluster(session, cluster_name: str) -> list[dict]:
row = await session.fetchrow(
"SELECT tariff_group FROM servers WHERE cluster_name = $1 LIMIT 1",
cluster_name,
)
if not row or not row["tariff_group"]:
return []
group_code = row["tariff_group"]
rows = await session.fetch(
"SELECT * FROM tariffs WHERE group_code = $1 AND is_active = TRUE ORDER BY duration_days",
group_code,
)
return [dict(r) for r in rows]
async def get_tariff_by_id(session, tariff_id: int) -> dict | None:
row = await session.fetchrow("SELECT * FROM tariffs WHERE id = $1", tariff_id)
return dict(row) if row else None
+2
View File
@@ -13,6 +13,7 @@ from .restart import router as restart_router
from .sender import router as sender_router
from .servers import router as servers_router
from .stats import router as stats_router
from .tariffs import router as tariffs_router
from .users import router as users_router
@@ -31,4 +32,5 @@ router.include_routers(
restart_router,
bans_router,
ads_router,
tariffs_router,
)
+65 -7
View File
@@ -1,6 +1,6 @@
import asyncio
import time
from datetime import datetime
from typing import Any
import asyncpg
@@ -18,7 +18,6 @@ from config import (
DATABASE_URL,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
TOTAL_GB,
USE_COUNTRY_SELECTION,
)
from database import check_unique_server_name, get_servers, update_key_expiry
@@ -41,6 +40,7 @@ from .keyboard import (
build_manage_cluster_kb,
build_panel_type_kb,
build_sync_cluster_kb,
build_tariff_group_selection_kb,
)
@@ -563,9 +563,32 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
now = int(time.time() * 1000)
add_ms = days * 86400 * 1000
total_gb = int((days / 30) * TOTAL_GB * 1024**3)
row = await session.fetchrow("SELECT tariff_group FROM servers WHERE cluster_name = $1 LIMIT 1", cluster_name)
if not row or not row["tariff_group"]:
await message.answer("❌ Не удалось определить тарифную группу для этого кластера.")
await state.clear()
return
group_code = row["tariff_group"]
tariff = await session.fetchrow(
"""
SELECT * FROM tariffs
WHERE group_code = $1 AND is_active = TRUE AND duration_days >= $2
ORDER BY duration_days ASC
LIMIT 1
""",
group_code,
days,
)
if not tariff:
await message.answer("❌ Нет активных тарифов, подходящих по сроку.")
await state.clear()
return
total_gb = tariff["traffic_limit"] or 0
keys = await session.fetch(
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
@@ -578,7 +601,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
return
for key in keys:
new_expiry = (key["expiry_time"] or now) + add_ms
new_expiry = key["expiry_time"] + add_ms
await renew_key_in_cluster(
cluster_name,
email=key["email"],
@@ -588,14 +611,15 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
)
await update_key_expiry(key["client_id"], new_expiry, session)
logger.info(f"[Cluster Extend] {key['email']} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
)
except ValueError:
await message.answer("❌ Введите корректное число дней.")
return
except Exception as e:
logger.error(f"Ошибка при добавлении дней: {e}")
logger.error(f"[Cluster Extend] Ошибка при добавлении дней: {e}")
await message.answer("❌ Произошла ошибка при продлении времени.")
finally:
await state.clear()
@@ -855,3 +879,37 @@ async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMConte
finally:
await conn.close()
await state.clear()
@router.callback_query(AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter())
async def show_tariff_group_selection(callback: CallbackQuery, callback_data: AdminClusterCallback, session):
cluster_name = callback_data.data
rows = await session.fetch(
"SELECT DISTINCT group_code FROM tariffs WHERE group_code IS NOT NULL ORDER BY group_code"
)
groups = [r["group_code"] for r in rows]
if not groups:
await callback.message.edit_text("❌ Нет доступных тарифных групп.")
return
await callback.message.edit_text(
f"<b>💸 Выберите тарифную группу для кластера <code>{cluster_name}</code>:</b>",
reply_markup=build_tariff_group_selection_kb(cluster_name, groups),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter())
async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminClusterCallback, session):
try:
cluster_name, group_code = callback_data.data.split("|", 1)
except ValueError:
await callback.message.edit_text("❌ Неверные данные.")
return
await session.execute("UPDATE servers SET tariff_group = $1 WHERE cluster_name = $2", group_code, cluster_name)
await callback.message.edit_text(
f"✅ Для кластера <code>{cluster_name}</code> установлена тарифная группа: <b>{group_code}</b>",
reply_markup=build_cluster_management_kb(cluster_name),
)
+22 -2
View File
@@ -2,8 +2,6 @@ from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
@@ -96,6 +94,12 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💸 Тариф: [установить/изменить]",
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
@@ -130,3 +134,19 @@ def build_panel_type_kb() -> InlineKeyboardMarkup:
builder.button(text="🌀 Remnawave", callback_data=AdminClusterCallback(action="panel_remnawave").pack())
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_tariff_group_selection_kb(cluster_name: str, groups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group in groups:
builder.button(
text=group,
callback_data=AdminClusterCallback(action="apply_tariff_group", data=f"{cluster_name}|{group}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack()
)
)
builder.adjust(2, 1)
return builder.as_markup()
+2 -1
View File
@@ -29,12 +29,13 @@ def build_panel_kb() -> InlineKeyboardMarkup:
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.row(InlineKeyboardButton(text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()))
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
)
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1, 1, 2, 2, 2, 1)
builder.adjust(1, 1, 2, 2, 1, 2, 1)
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .tariffs_handler import router
+85
View File
@@ -0,0 +1,85 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback
class AdminTariffCallback(CallbackData, prefix="tariff"):
action: str
def build_tariff_menu_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🆕 Новый тариф", callback_data=AdminTariffCallback(action="create").pack()))
builder.row(InlineKeyboardButton(text="📋 Мои тарифы", callback_data=AdminTariffCallback(action="list").pack()))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()))
return builder.as_markup()
def build_cancel_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text="❌ Отменить", callback_data="cancel_tariff_creation")]]
)
def build_tariff_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group in groups:
builder.button(text=group, callback_data=AdminTariffCallback(action=f"group|{group}").pack())
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminPanelCallback(action="tariffs").pack()))
return builder.as_markup()
def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if tariffs:
group_code = tariffs[0]["group_code"]
else:
group_code = "unknown"
for t in tariffs:
title = f"{t['name']}{t['price_rub']}"
builder.row(
InlineKeyboardButton(text=title, callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack())
)
builder.row(
InlineKeyboardButton(
text=" Добавить тариф", callback_data=AdminTariffCallback(action=f"create|{group_code}").pack()
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack()))
return builder.as_markup()
def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="✏️ Редактировать", callback_data=AdminTariffCallback(action=f"edit|{tariff_id}").pack()
),
InlineKeyboardButton(
text="🗑 Удалить", callback_data=AdminTariffCallback(action=f"delete|{tariff_id}").pack()
),
],
[InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack())],
]
)
def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="📝 Название", callback_data=f"edit_field|{tariff_id}|name")],
[InlineKeyboardButton(text="📅 Длительность", callback_data=f"edit_field|{tariff_id}|duration_days")],
[InlineKeyboardButton(text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub")],
[InlineKeyboardButton(text="📦 Трафик (ГБ или 0)", callback_data=f"edit_field|{tariff_id}|traffic_limit")],
[InlineKeyboardButton(text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view|{tariff_id}")],
]
)
+329
View File
@@ -0,0 +1,329 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from database import create_tariff
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminTariffCallback,
build_cancel_kb,
build_edit_tariff_fields_kb,
build_single_tariff_kb,
build_tariff_groups_kb,
build_tariff_list_kb,
build_tariff_menu_kb,
)
router = Router()
class TariffCreateState(StatesGroup):
group = State()
name = State()
duration = State()
price = State()
traffic = State()
confirm_more = State()
class TariffEditState(StatesGroup):
choosing_field = State()
editing_value = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter())
async def handle_tariff_menu(callback_query: CallbackQuery):
text = (
"<b>💸 Управление тарифами</b>\n\n"
"Здесь вы можете:\n"
"• 🆕 Создать новый тариф (длительность, цена, лимит трафика)\n"
"• 📋 Просмотреть и редактировать существующие тарифы"
)
await callback_query.message.edit_text(text=text, reply_markup=build_tariff_menu_kb())
@router.callback_query(AdminTariffCallback.filter(F.action == "create"), IsAdminFilter())
async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffCreateState.group)
await callback.message.edit_text(
"📁 Введите <b>код группы</b>, в которую вы хотите добавить тариф.\n\n"
"Например: <code>basic</code>, <code>vip</code>, <code>business</code>",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.group, IsAdminFilter())
async def process_tariff_group(message: Message, state: FSMContext):
group_code = message.text.strip().lower()
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await message.answer(
"📝 Введите <b>название тарифа</b>\n\n"
"Например: <i>30 дней</i> или <i>1 месяц</i>\n\n"
"<i>Это название будет отображаться пользователю при выборе тарифа</i>",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.name, IsAdminFilter())
async def process_tariff_name(message: Message, state: FSMContext):
await state.update_data(name=message.text.strip())
await state.set_state(TariffCreateState.duration)
await message.answer(
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):", reply_markup=build_cancel_kb()
)
@router.message(TariffCreateState.duration, IsAdminFilter())
async def process_tariff_duration(message: Message, state: FSMContext):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректное количество дней (целое число больше 0):")
return
await state.update_data(duration_days=days)
await state.set_state(TariffCreateState.price)
await message.answer(
"💰 Введите <b>цену тарифа в рублях</b> (например: <i>150</i>)\n\n"
"<i>Будет показано клиенту при выборе тарифа</i>",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.price, IsAdminFilter())
async def process_tariff_price(message: Message, state: FSMContext):
try:
price = int(message.text.strip())
if price < 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректную цену (целое число 0 или больше):")
return
await state.update_data(price_rub=price)
await state.set_state(TariffCreateState.traffic)
await message.answer(
"📦 Введите <b>лимит трафика в ГБ</b> (например: <i>100</i>, 0 — безлимит):", reply_markup=build_cancel_kb()
)
@router.message(TariffCreateState.traffic, IsAdminFilter())
async def process_tariff_traffic(message: Message, state: FSMContext, session):
try:
traffic = int(message.text.strip())
if traffic < 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректный лимит трафика (целое число 0 или больше):")
return
data = await state.get_data()
data["traffic_limit"] = traffic * 1024**3 if traffic > 0 else None
new_tariff = await create_tariff(
session,
{
"name": data["name"],
"group_code": data["group_code"],
"duration_days": data["duration_days"],
"price_rub": data["price_rub"],
"traffic_limit": data["traffic_limit"],
},
)
await state.set_state(TariffCreateState.confirm_more)
await message.answer(
f"✅ Тариф <b>{new_tariff['name']}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
"➕ Хотите добавить ещё один тариф в эту группу?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
InlineKeyboardButton(text="❌ Нет", callback_data="done_tariff_group"),
]
]
),
)
@router.callback_query(F.data == "add_more_tariff", IsAdminFilter())
async def handle_add_more_tariff(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffCreateState.name)
await callback.message.edit_text("📝 Введите <b>название следующего тарифа</b>:", reply_markup=build_cancel_kb())
@router.callback_query(F.data == "done_tariff_group", IsAdminFilter())
async def handle_done_tariff_group(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb())
@router.callback_query(F.data == "cancel_tariff_creation", IsAdminFilter())
async def cancel_tariff_creation(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb())
@router.callback_query(AdminTariffCallback.filter(F.action == "list"), IsAdminFilter())
async def show_tariff_groups(callback: CallbackQuery, session):
rows = await session.fetch(
"SELECT DISTINCT group_code FROM tariffs WHERE group_code IS NOT NULL ORDER BY group_code"
)
groups = [r["group_code"] for r in rows]
if not groups:
await callback.message.edit_text("❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb())
return
await callback.message.edit_text("<b>📋 Выберите тарифную группу:</b>", reply_markup=build_tariff_groups_kb(groups))
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter())
async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTariffCallback, session):
group_code = callback_data.action.split("|", 1)[1]
rows = await session.fetch("SELECT * FROM tariffs WHERE group_code = $1 ORDER BY duration_days", group_code)
tariffs = [dict(r) for r in rows]
if not tariffs:
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
return
await callback.message.edit_text(
f"<b>📦 Тарифы группы: {group_code}</b>", reply_markup=build_tariff_list_kb(tariffs)
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter())
async def view_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, session):
tariff_id = int(callback_data.action.split("|", 1)[1])
tariff = await session.fetchrow("SELECT * FROM tariffs WHERE id = $1", tariff_id)
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
t = dict(tariff)
traffic_text = f"{t['traffic_limit'] // 1024**3} ГБ" if t["traffic_limit"] else "Безлимит"
text = (
f"<b>📄 Тариф: {t['name']}</b>\n\n"
f"📁 Группа: <code>{t['group_code']}</code>\n"
f"📅 Длительность: <b>{t['duration_days']} дней</b>\n"
f"💰 Стоимость: <b>{t['price_rub']}₽</b>\n"
f"📦 Трафик: <b>{traffic_text}</b>\n"
f"{'✅ Активен' if t['is_active'] else '⛔ Отключен'}"
)
await callback.message.edit_text(text, reply_markup=build_single_tariff_kb(tariff_id))
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter())
async def confirm_tariff_deletion(callback: CallbackQuery, callback_data: AdminTariffCallback):
tariff_id = int(callback_data.action.split("|", 1)[1])
await callback.message.edit_text(
"⚠️ Вы уверены, что хотите <b>удалить</b> этот тариф?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view|{tariff_id}"),
]
]
),
)
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
async def delete_tariff(callback: CallbackQuery, session):
tariff_id = int(callback.data.split("|", 1)[1])
await session.execute("DELETE FROM tariffs WHERE id = $1", tariff_id)
await callback.message.edit_text("🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb())
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter())
async def start_edit_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext):
tariff_id = int(callback_data.action.split("|")[1])
await state.update_data(tariff_id=tariff_id)
await state.set_state(TariffEditState.choosing_field)
await callback.message.edit_text(
"<b>✏️ Что вы хотите изменить?</b>", reply_markup=build_edit_tariff_fields_kb(tariff_id)
)
@router.callback_query(F.data.startswith("edit_field|"), IsAdminFilter())
async def ask_new_value(callback: CallbackQuery, state: FSMContext):
_, _tariff_id, field = callback.data.split("|")
await state.update_data(field=field)
await state.set_state(TariffEditState.editing_value)
field_names = {
"name": "название тарифа",
"duration_days": "длительность в днях",
"price_rub": "цену в рублях",
"traffic_limit": "лимит трафика в ГБ (0 — безлимит)",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:", reply_markup=build_cancel_kb()
)
@router.message(TariffEditState.editing_value, IsAdminFilter())
async def apply_edit(message: Message, state: FSMContext, session):
data = await state.get_data()
tariff_id = data["tariff_id"]
field = data["field"]
value = message.text.strip()
if field in ["duration_days", "price_rub", "traffic_limit"]:
try:
num = int(value)
if num < 0:
raise ValueError
if field == "traffic_limit":
value = num * 1024**3 if num > 0 else None
else:
value = num
except ValueError:
await message.answer("❌ Введите корректное число.")
return
await session.execute(f"UPDATE tariffs SET {field} = $1, updated_at = NOW() WHERE id = $2", value, tariff_id)
await state.clear()
await message.answer("✅ Тариф успешно обновлён.")
@router.callback_query(F.data.startswith("toggle_active|"), IsAdminFilter())
async def toggle_tariff_status(callback: CallbackQuery, session):
tariff_id = int(callback.data.split("|")[1])
row = await session.fetchrow("SELECT is_active FROM tariffs WHERE id = $1", tariff_id)
if not row:
await callback.message.edit_text("❌ Тариф не найден.")
return
new_status = not row["is_active"]
await session.execute("UPDATE tariffs SET is_active = $1, updated_at = NOW() WHERE id = $2", new_status, tariff_id)
status_text = "✅ Тариф активирован." if new_status else "⛔ Тариф отключён."
await callback.message.edit_text(status_text)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter())
async def start_tariff_creation_existing_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
):
group_code = callback_data.action.split("|", 1)[1]
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await callback.message.edit_text(
f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:",
reply_markup=build_cancel_kb(),
)
+111 -57
View File
@@ -1,11 +1,13 @@
from datetime import datetime, timezone
import asyncpg
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RENEWAL_PRICES, TOTAL_GB, HWID_RESET_BUTTON
from database import get_clusters
from config import DATABASE_URL, HWID_RESET_BUTTON
from database import get_clusters, get_tariffs, get_tariffs_for_cluster
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_btn
@@ -77,29 +79,50 @@ def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
return builder.as_markup()
def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
async def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month, amount in RENEWAL_PRICES.items():
builder.button(
text=f"+ {amount}Р ({month} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=amount).pack(),
conn = await asyncpg.connect(DATABASE_URL)
try:
tariffs = await get_tariffs(conn)
for tariff in tariffs:
months = tariff["duration_days"] // 30
if months < 1:
continue
price = tariff["price_rub"]
builder.row(
InlineKeyboardButton(
text=f"+ {price}₽ ({months} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=price).pack(),
),
InlineKeyboardButton(
text=f"- {price}₽ ({months} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=-price).pack(),
),
)
finally:
await conn.close()
builder.row(
InlineKeyboardButton(
text="💵 Добавить",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack(),
),
InlineKeyboardButton(
text="💵 Вычесть",
callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
)
builder.button(
text=f"- {amount}Р ({month} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=-amount).pack(),
)
builder.button(
text="💵 Добавить", callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack()
)
builder.button(
text="💵 Вычесть", callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack()
)
builder.button(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
@@ -112,33 +135,69 @@ def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
return builder.as_markup()
def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
async def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month in RENEWAL_PRICES.keys():
month = int(month)
builder.button(
text=f"+ {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=month).pack(),
conn = await asyncpg.connect(DATABASE_URL)
try:
record = await conn.fetchrow("SELECT server_id FROM keys WHERE email = $1", email)
if not record:
builder.row(
InlineKeyboardButton(
text="⚠️ Сервер не найден",
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
)
return builder.as_markup()
server_id = record["server_id"]
tariffs = await get_tariffs_for_cluster(conn, server_id)
for tariff in tariffs:
months = tariff["duration_days"] // 30
if months < 1:
continue
builder.row(
InlineKeyboardButton(
text=f"+ {months} мес.",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email, month=months
).pack(),
),
InlineKeyboardButton(
text=f"- {months} мес.",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email, month=-months
).pack(),
),
)
finally:
await conn.close()
builder.row(
InlineKeyboardButton(
text="⏳ Добавить дни",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack(),
),
InlineKeyboardButton(
text="⏳ Вычесть дни",
callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
)
builder.button(
text=f"- {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=-month).pack(),
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.button(
text="⏳ Добавить дни", callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Вычесть дни", callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
)
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
@@ -182,13 +241,12 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
text="📊 Трафик",
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
)
if TOTAL_GB > 0:
builder.button(
text="♻️ Сбросить трафик",
callback_data=AdminUserEditorCallback(
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="♻️ Сбросить трафик",
callback_data=AdminUserEditorCallback(
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
).pack(),
)
if HWID_RESET_BUTTON:
builder.button(
text="💻 HWID",
@@ -205,15 +263,11 @@ def build_hwid_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="♻️ Сбросить HWID",
callback_data=AdminUserEditorCallback(
action="users_hwid_reset", data=email, tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_hwid_reset", data=email, tg_id=tg_id).pack(),
)
builder.button(
text="🔙 Назад",
callback_data=AdminUserEditorCallback(
action="users_key_edit", data=email, tg_id=tg_id
).pack(),
callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(),
)
builder.adjust(1)
return builder.as_markup()
+67 -22
View File
@@ -5,6 +5,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
import asyncpg
import pytz
from aiogram import F, Router, types
@@ -14,7 +15,7 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RENEWAL_PRICES, TOTAL_GB, USE_COUNTRY_SELECTION, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from config import DATABASE_URL, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, USE_COUNTRY_SELECTION
from database import (
delete_key,
delete_user_data,
@@ -22,6 +23,7 @@ from database import (
get_client_id_by_email,
get_key_details,
get_servers,
get_tariffs_for_cluster,
set_user_balance,
update_balance,
update_key_expiry,
@@ -38,8 +40,8 @@ from handlers.keys.key_utils import (
)
from handlers.utils import generate_random_email, sanitize_key_name
from logger import logger
from utils.csv_export import export_referrals_csv
from panels.remnawave import RemnawaveAPI
from utils.csv_export import export_referrals_csv
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb
from .keyboard import (
@@ -47,6 +49,7 @@ from .keyboard import (
AdminUserKeyEditorCallback,
build_cluster_selection_kb,
build_editor_kb,
build_hwid_menu_kb,
build_key_delete_kb,
build_key_edit_kb,
build_user_delete_kb,
@@ -55,7 +58,6 @@ from .keyboard import (
build_users_balance_kb,
build_users_key_expiry_kb,
build_users_key_show_kb,
build_hwid_menu_kb
)
@@ -156,7 +158,9 @@ async def handle_hwid_reset(callback_query: CallbackQuery, callback_data: AdminU
devices = await api.get_user_hwid_devices(client_id)
if not devices:
await callback_query.message.edit_text("ℹ️ У пользователя нет привязанных устройств.", reply_markup=build_editor_kb(tg_id, True))
await callback_query.message.edit_text(
"ℹ️ У пользователя нет привязанных устройств.", reply_markup=build_editor_kb(tg_id, True)
)
return
deleted = 0
@@ -331,7 +335,7 @@ async def handle_balance_change(callback_query: CallbackQuery, callback_data: Ad
else:
text += "\n <i>🚫 Отсутствуют</i>"
await callback_query.message.edit_text(text=text, reply_markup=build_users_balance_kb(tg_id))
await callback_query.message.edit_text(text=text, reply_markup=await build_users_balance_kb(tg_id))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_add"), IsAdminFilter())
@@ -450,14 +454,10 @@ async def handle_key_edit(
text += f"\n🏷️ Имя ключа: <b>{alias}</b>"
if not update or not callback_data.edit:
await callback_query.message.edit_text(
text=text,
reply_markup=build_key_edit_kb(key_details, email)
)
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_details, email))
else:
await callback_query.message.edit_text(
text=text,
reply_markup=build_users_key_expiry_kb(callback_data.tg_id, email)
text=text, reply_markup=await build_users_key_expiry_kb(callback_data.tg_id, email)
)
@@ -466,7 +466,7 @@ async def handle_change_expiry(callback_query: CallbackQuery, callback_data: Adm
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_reply_markup(reply_markup=build_users_key_expiry_kb(tg_id, email))
await callback_query.message.edit_reply_markup(reply_markup=await build_users_key_expiry_kb(tg_id, email))
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "add"), IsAdminFilter())
@@ -763,14 +763,30 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
if client_id is None:
return ValueError(f"User with email {email} was not found")
server_id = await session.fetchrow("SELECT server_id FROM keys WHERE client_id = $1", client_id)
record = await session.fetchrow("SELECT server_id FROM keys WHERE client_id = $1", client_id)
if not record:
return ValueError(f"Key with client_id {client_id} was not found")
if not server_id:
return ValueError(f"User with client_id {server_id} was not found")
server_id = record["server_id"]
servers = await session.fetch(
"SELECT tariff_group FROM servers WHERE server_name = $1 OR cluster_name = $1 LIMIT 1", server_id
)
if not servers or not servers[0]["tariff_group"]:
return ValueError(f"Tariff group not found for server_id={server_id}")
tariff_group = servers[0]["tariff_group"]
tariffs = await session.fetch(
"SELECT duration_days, traffic_limit FROM tariffs WHERE group_code = $1 AND is_active = TRUE ORDER BY duration_days",
tariff_group,
)
if not tariffs:
return ValueError(f"No tariffs found for group {tariff_group}")
added_days = max((expiry_time - int(time.time() * 1000)) / (1000 * 86400), 1)
closest_tariff = min(tariffs, key=lambda t: abs(t["duration_days"] - added_days))
total_gb = closest_tariff["traffic_limit"] or 0
clusters = await get_servers()
added_days = max((expiry_time - int(time.time() * 1000)) / (1000 * 86400), 1)
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
async def update_key_on_all_servers():
tasks = [
@@ -785,7 +801,6 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
)
for cluster_name in clusters
]
await asyncio.gather(*tasks, return_exceptions=True)
await update_key_on_all_servers()
@@ -953,8 +968,28 @@ async def handle_create_key_country(callback_query: CallbackQuery, state: FSMCon
await state.set_state(UserEditorState.selecting_duration)
builder = InlineKeyboardBuilder()
for months, _ in RENEWAL_PRICES.items():
builder.button(text=f"{months} мес.", callback_data=str(months))
conn = await asyncpg.connect(DATABASE_URL)
try:
row = await conn.fetchrow("SELECT cluster_name FROM servers WHERE server_name = $1", country)
if not row:
await callback_query.message.edit_text("❌ Сервер не найден.")
return
cluster_name = row["cluster_name"]
await state.update_data(cluster_name=cluster_name)
tariffs = await get_tariffs_for_cluster(conn, cluster_name)
for tariff in tariffs:
months = tariff["duration_days"] // 30
if months < 1:
continue
builder.button(text=f"{months} мес.", callback_data=str(months))
finally:
await conn.close()
builder.adjust(1)
builder.row(build_admin_back_btn())
@@ -971,8 +1006,18 @@ async def handle_create_key_cluster(callback_query: CallbackQuery, state: FSMCon
await state.set_state(UserEditorState.selecting_duration)
builder = InlineKeyboardBuilder()
for months, _ in RENEWAL_PRICES.items():
builder.button(text=f"{months} мес.", callback_data=str(months))
conn = await asyncpg.connect(DATABASE_URL)
try:
tariffs = await get_tariffs_for_cluster(conn, cluster_name)
for tariff in tariffs:
months = tariff["duration_days"] // 30
if months < 1:
continue
builder.button(text=f"{months} мес.", callback_data=str(months))
finally:
await conn.close()
builder.adjust(1)
builder.row(build_admin_back_btn())
+18 -4
View File
@@ -6,9 +6,6 @@ from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
TOTAL_GB,
)
from database import (
get_key_details,
)
@@ -83,6 +80,7 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
leftover = 0
new_expiry_time = now_ms + leftover
await session.execute(
"""
UPDATE keys
@@ -95,8 +93,24 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
record["tg_id"],
client_id,
)
tariff = await session.fetchrow(
"""
SELECT t.traffic_limit
FROM tariffs t
JOIN servers s ON s.tariff_group = t.tariff_group
WHERE s.server_name = $1
ORDER BY t.duration_days DESC
LIMIT 1
""",
cluster_id,
)
if not tariff or not tariff["traffic_limit"]:
raise ValueError("Не удалось определить тариф для сервера")
base_bytes = int(tariff["traffic_limit"])
added_days = max(leftover / (1000 * 86400), 0.01)
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
total_gb = int((added_days / 30) * base_bytes)
await renew_key_in_cluster(
cluster_id=cluster_id,
+5 -9
View File
@@ -9,12 +9,7 @@ from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Mess
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
CONNECT_PHONE_BUTTON,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
DEFAULT_HWID_LIMIT
)
from config import CONNECT_PHONE_BUTTON, DEFAULT_HWID_LIMIT, SUPPORT_CHAT_URL
from database import (
get_key_details,
get_trial,
@@ -90,9 +85,10 @@ async def key_cluster_mode(
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
if data.get("tariff_id"):
row = await session.fetchrow("SELECT price_rub FROM tariffs WHERE id = $1", data["tariff_id"])
if row:
await update_balance(tg_id, -row["price_rub"], session)
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
+5 -5
View File
@@ -18,12 +18,11 @@ from config import (
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
DATABASE_URL,
DEFAULT_HWID_LIMIT,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
DEFAULT_HWID_LIMIT
)
from database import (
add_user,
@@ -427,9 +426,10 @@ async def finalize_key_creation(
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
if data.get("tariff_id"):
row = await session.fetchrow("SELECT price_rub FROM tariffs WHERE id = $1", data["tariff_id"])
if row:
await update_balance(tg_id, -row["price_rub"], session)
except Exception as e:
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
+35 -39
View File
@@ -10,19 +10,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
NOTIFY_EXTRA_DAYS,
RENEWAL_PRICES,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_user,
check_user_exists,
create_temporary_data,
get_balance,
get_trial,
)
from database import add_user, check_user_exists, create_temporary_data, get_balance, get_tariffs_for_cluster, get_trial
from handlers.buttons import (
MAIN_MENU,
PAYMENT,
@@ -31,11 +24,10 @@ from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message
from handlers.utils import edit_or_send_message, get_least_loaded_cluster
from logger import logger
from .key_cluster_mode import key_cluster_mode
@@ -81,61 +73,66 @@ async def handle_key_creation(
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
cluster_name = await get_least_loaded_cluster()
tariffs = await get_tariffs_for_cluster(session, cluster_name)
if not tariffs:
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text="❌ Нет доступных тарифов для выбранного кластера.",
reply_markup=None,
)
return
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if DISCOUNTS and 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}% 🔥)"
for t in tariffs:
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
text=f"{t['name']}{t['price_rub']}",
callback_data=f"select_tariff_plan|{t['id']}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
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_"))
@router.callback_query(F.data.startswith("select_tariff_plan|"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
tariff_id = int(callback_query.data.split("|")[1])
row = await session.fetchrow("SELECT * FROM tariffs WHERE id = $1", tariff_id)
if not row:
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
return
duration_days = int(plan_id) * 30
tariff = dict(row)
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
if balance < price_rub:
required_amount = price_rub - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"tariff_id": tariff_id,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
@@ -148,12 +145,11 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
@@ -161,8 +157,8 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
await state.update_data(tariff_id=tariff_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=tariff_id)
async def create_key(
+175 -150
View File
@@ -10,8 +10,6 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
DATABASE_URL,
RENEWAL_PRICES,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
@@ -35,7 +33,6 @@ from handlers.keys.key_utils import (
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
DISCOUNTS,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
@@ -54,173 +51,201 @@ async def process_callback_renew_key(callback_query: CallbackQuery, session: Any
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
for plan_id, price in RENEWAL_PRICES.items():
months = int(plan_id)
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
button_text = f"📅 {format_months(months)} ({price} руб.)"
if discount > 0:
button_text += f" {discount}% скидка"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{months}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
if not record:
await callback_query.message.answer("<b>Ключ не найден.</b>")
return
client_id = record["client_id"]
expiry_time = record["expiry_time"]
server_id = record["server_id"]
logger.info(f"[RENEW] Получение тарифной группы для server_id={server_id}")
row = await session.fetchrow(
"""
SELECT tariff_group FROM servers
WHERE id::text = $1 OR server_name = $1 OR cluster_name = $1
LIMIT 1
""",
server_id,
)
if not row or not row["tariff_group"]:
logger.warning(f"[RENEW] Тарифная группа не найдена для server_id={server_id}")
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
return
tariff_group = row["tariff_group"]
logger.info(f"[RENEW] Найдена тарифная группа '{tariff_group}' для server_id={server_id}")
tariffs = await session.fetch(
"""
SELECT * FROM tariffs
WHERE group_code = $1 AND is_active = TRUE
ORDER BY duration_days
""",
tariff_group,
)
if not tariffs:
logger.warning(f"[RENEW] Нет активных тарифов для группы '{tariff_group}'")
await callback_query.message.answer("❌ Нет доступных тарифов для этой группы.")
return
builder = InlineKeyboardBuilder()
for t in tariffs:
button_text = f"📅 {t['name']}{t['price_rub']}"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{t['id']}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"Ошибка в process_callback_renew_key: {e}")
logger.error(f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
days_to_extend = 30 * int(plan)
total_gb = int((int(plan) or 1) * TOTAL_GB * 1024**3)
tariff_id, client_id = callback_query.data.split("|")[1:]
tariff_id = int(tariff_id)
try:
tariff = await session.fetchrow("SELECT * FROM tariffs WHERE id = $1 AND is_active = TRUE", tariff_id)
if not tariff:
await callback_query.message.answer("❌ Тариф не найден или отключён.")
return
duration_days = tariff["duration_days"]
cost = tariff["price_rub"]
total_gb = tariff["traffic_limit"] or 0
record = await get_key_by_server(tg_id, client_id, session)
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
cost = RENEWAL_PRICES.get(plan)
if cost is None:
await callback_query.message.answer("❌ Неверный тарифный план.")
return
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
)
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"plan": plan,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
await handle_custom_amount_input(callback_query, session)
else:
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
else:
if not record:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
return
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=duration_days).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000)
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(f"[RENEW] Недостаточно средств: {required_amount}")
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"tariff_id": tariff_id,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
)
return
logger.info(f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, tariff_id)
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
logger.info(f"[Info] Продление ключа {client_id} на {plan} мес. (Start)")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
response_message = SUCCESS_RENEWAL_MSG.format(months_formatted=format_months(int(plan)))
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, tariff_id):
conn = await asyncpg.connect(DATABASE_URL)
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
await conn.close()
return
server_id = key_info["server_id"]
try:
logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)")
if USE_COUNTRY_SELECTION:
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
await conn.close()
tariff = await conn.fetchrow("SELECT * FROM tariffs WHERE id = $1", tariff_id)
if not tariff:
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
return
cluster_id = cluster_info["cluster_name"]
else:
cluster_id = server_id
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
await update_key_expiry(client_id, new_expiry_time, conn)
await update_balance(tg_id, -cost, conn)
await conn.close()
months_formatted = format_months(tariff["duration_days"] // 30)
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
response_message = SUCCESS_RENEWAL_MSG.format(months_formatted=months_formatted)
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
return
server_id = key_info["server_id"]
if USE_COUNTRY_SELECTION:
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
return
cluster_id = cluster_info["cluster_name"]
else:
cluster_id = server_id
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
await update_key_expiry(client_id, new_expiry_time, conn)
await update_balance(tg_id, -cost, conn)
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
except Exception as e:
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
finally:
await conn.close()
+55 -13
View File
@@ -1,20 +1,18 @@
import asyncio
from datetime import datetime, timezone
from typing import Any, Optional
from typing import Any
import asyncpg
from bot import bot
from config import (
DATABASE_URL,
DEFAULT_HWID_LIMIT,
LIMIT_IP,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
SUPERNODE,
TOTAL_GB,
DEFAULT_HWID_LIMIT
)
from database import delete_notification, get_servers, store_key
from handlers.utils import check_server_key_limit, get_least_loaded_cluster
@@ -40,7 +38,7 @@ async def create_key_on_cluster(
plan: int = None,
session=None,
remnawave_link: str = None,
hwid_limit: Optional[int] = DEFAULT_HWID_LIMIT,
hwid_limit: int | None = DEFAULT_HWID_LIMIT,
):
try:
servers = await get_servers(include_enabled=True)
@@ -66,12 +64,21 @@ async def create_key_on_cluster(
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
traffic_limit_bytes = None
if plan is not None:
tariff = await conn.fetchrow("SELECT traffic_limit FROM tariffs WHERE id = $1", plan)
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
traffic_limit_bytes = int(tariff["traffic_limit"])
remnawave_servers = [
s for s in enabled_servers
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, conn)
]
xui_servers = [
s for s in enabled_servers
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, conn)
]
@@ -96,7 +103,6 @@ async def create_key_on_cluster(
if not inbound_ids:
logger.warning("Нет inbound_id у серверов Remnawave")
else:
traffic_limit_bytes = int((plan or 1) * TOTAL_GB * 1024**3)
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
@@ -104,12 +110,14 @@ async def create_key_on_cluster(
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"trafficLimitBytes": traffic_limit_bytes,
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if short_uuid:
user_data["shortUuid"] = short_uuid
if hwid_limit is not None:
@@ -171,7 +179,7 @@ async def create_client_on_server(
plan: int = None,
):
"""
Создает клиента на указанном сервере.
Создает клиента на указанном 3x-ui сервере с лимитом по тарифу.
"""
async with semaphore:
xui = await get_xui_instance(server_info["api_url"])
@@ -190,7 +198,14 @@ async def create_client_on_server(
unique_email = email
sub_id = unique_email
total_gb_value = int((plan or 1) * TOTAL_GB * 1024**3)
total_gb_value = 0
if plan is not None:
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
tariff = await conn.fetchrow("SELECT traffic_limit FROM tariffs WHERE id = $1", plan)
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
total_gb_value = int(tariff["traffic_limit"])
await add_client(
xui,
@@ -212,7 +227,9 @@ async def create_client_on_server(
await asyncio.sleep(0.7)
async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb, hwid_device_limit=DEFAULT_HWID_LIMIT):
async def renew_key_in_cluster(
cluster_id, email, client_id, new_expiry_time, total_gb, hwid_device_limit=DEFAULT_HWID_LIMIT
):
try:
servers = await get_servers()
cluster = servers.get(cluster_id)
@@ -434,6 +451,18 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
"activeUserInbounds": inbound_ids,
}
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
group_id = remnawave_servers[0].get("tariff_group")
if group_id is None:
raise ValueError("У Remnawave-сервера отсутствует tariff_group")
tariff = await conn.fetchrow(
"SELECT traffic_limit FROM tariffs WHERE tariff_group = $1 ORDER BY traffic_limit_gb DESC LIMIT 1",
group_id,
)
if tariff:
user_data["trafficLimitBytes"] = int(tariff["traffic_limit"] * 1024**3)
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
@@ -464,12 +493,25 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
sub_id = email
unique_email = email
total_gb_bytes = 0
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
group_id = server_info.get("tariff_group")
if group_id is None:
raise ValueError(f"У сервера {server_name} отсутствует tariff_group")
tariff = await conn.fetchrow(
"SELECT traffic_limit FROM tariffs WHERE tariff_group = $1 ORDER BY traffic_limit_gb DESC LIMIT 1",
group_id,
)
if tariff:
total_gb_bytes = int(tariff["traffic_limit_gb"] * 1024**3)
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
total_gb=total_gb_bytes,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
+5 -11
View File
@@ -17,16 +17,12 @@ from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
HWID_RESET_BUTTON,
QRCODE,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
HWID_RESET_BUTTON
)
from database import (
get_key_details,
get_keys,
get_servers
)
from database import get_key_details, get_keys, get_servers
from handlers.buttons import (
ADD_SUB,
ALIAS,
@@ -36,6 +32,7 @@ from handlers.buttons import (
CONNECT_PHONE,
DELETE,
FREEZE,
HWID_BUTTON,
MAIN_MENU,
PC_BUTTON,
QR,
@@ -43,7 +40,6 @@ from handlers.buttons import (
RENEW_FULL,
TV_BUTTON,
UNFREEZE,
HWID_BUTTON
)
from handlers.texts import (
FROZEN_SUBSCRIPTION_MSG,
@@ -196,7 +192,7 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
return
is_frozen = record["is_frozen"]
email = record["email"]
record["email"]
client_id = record.get("client_id")
remnawave_link = record.get("remnawave_link")
key = record.get("key")
@@ -326,9 +322,7 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
return
servers = await get_servers()
remna_server = next(
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None
)
remna_server = next((srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None)
if not remna_server:
await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True)
return
+66 -89
View File
@@ -16,8 +16,6 @@ from config import (
NOTIFY_MAXPRICE,
NOTIFY_RENEW,
NOTIFY_RENEW_EXPIRED,
RENEWAL_PRICES,
TOTAL_GB,
TRIAL_TIME_DISABLE,
)
from database import (
@@ -29,6 +27,7 @@ from database import (
get_all_keys,
get_balance,
get_last_notification_time,
get_tariffs_for_cluster,
update_balance,
update_key_expiry,
)
@@ -297,25 +296,22 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
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]
expired_keys = [key for key in keys if key.get("expiry_time") and key["expiry_time"] < current_time]
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
tg_ids = [key["tg_id"] for key in expired_keys]
emails = [key.get("email", "") for key in expired_keys]
users = await check_notifications_bulk("key_expired", 0, conn, tg_ids=tg_ids, emails=emails)
messages = []
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")
email = key["email"]
client_id = key["client_id"]
server_id = key["server_id"]
notification_id = f"{email}_key_expired"
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
@@ -323,20 +319,17 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
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)]
tariffs = await get_tariffs_for_cluster(conn, server_id)
tariff = tariffs[0] if tariffs else None
if balance >= renewal_cost:
try:
if tariff and balance >= tariff["price_rub"]:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
)
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
continue
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
continue
if NOTIFY_DELETE_KEY:
@@ -375,16 +368,12 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
if NOTIFY_DELETE_DELAY > 0:
hours = NOTIFY_DELETE_DELAY // 60
minutes = NOTIFY_DELETE_DELAY % 60
if hours > 0:
if minutes > 0:
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
)
else:
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
email=email, hours_formatted=format_hours(hours)
)
if hours > 0 and minutes > 0:
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
)
elif hours > 0:
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(email=email, hours_formatted=format_hours(hours))
else:
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
email=email, minutes_formatted=format_minutes(minutes)
@@ -405,16 +394,15 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
results = await send_messages_with_limit(bot, messages, conn=conn)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
email = msg["email"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
await add_notification(msg["tg_id"], msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекшем ключе для подписки {email} пользователю {tg_id}.")
logger.info(f"📢 Уведомление об истекшем ключе {msg['email']} отправлено пользователю {msg['tg_id']}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекшем ключе для подписки {email} пользователю {tg_id}."
f"📢 Не удалось отправить уведомление об истекшем ключе {msg['email']} пользователю {msg['tg_id']}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истекших ключах.")
logger.info("Обработка истекших ключей завершена.")
@@ -424,10 +412,6 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
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"
@@ -441,65 +425,58 @@ async def process_auto_renew_or_notify(
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")
total_gb = int(renewal_period_months * TOTAL_GB * 1024**3)
tariffs = await get_tariffs_for_cluster(conn, server_id)
if not tariffs:
logger.warning(f"⛔ Нет доступных тарифов для продления подписки {email} (сервер: {server_id})")
return
if NOTIFY_MAXPRICE:
suitable_tariffs = [t for t in tariffs if balance >= t["price_rub"]]
selected_tariff = suitable_tariffs[-1] if suitable_tariffs else None
else:
selected_tariff = tariffs[0] if balance >= tariffs[0]["price_rub"] else None
if not selected_tariff:
keyboard = build_notification_kb(email)
await add_notification(tg_id, notification_id, session=conn)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
return
client_id = key.get("client_id")
current_expiry = key.get("expiry_time")
duration_days = selected_tariff["duration_days"]
renewal_cost = selected_tariff["price_rub"]
traffic_limit = selected_tariff["traffic_limit"]
total_gb = traffic_limit if traffic_limit else 0
new_expiry_time = (
current_expiry
if current_expiry > datetime.utcnow().timestamp() * 1000
else datetime.utcnow().timestamp() * 1000
) + duration_days * 24 * 60 * 60 * 1000
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=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 renew_key_in_cluster(server_id, email, client_id, int(new_expiry_time), total_gb)
await update_balance(tg_id, -renewal_cost, session=conn)
await update_key_expiry(client_id, int(new_expiry_time), conn)
await add_notification(tg_id, renew_notification_id, session=conn)
await delete_notification(tg_id, notification_id, session=conn)
await add_notification(tg_id, renew_notification_id, session=conn)
await delete_notification(tg_id, notification_id, session=conn)
renewed_message = KEY_RENEWED.format(email=email, months=duration_days // 30, expiry_date=formatted_expiry_date)
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()
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
if result:
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
)
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 add_notification(tg_id, notification_id, session=conn)
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
keyboard = build_notification_expired_kb()
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
if result:
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
else:
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
except Exception as e:
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {e}")
+4 -25
View File
@@ -105,7 +105,7 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
session=session,
admin=admin,
text_to_process=original_text,
user_data=user_data
user_data=user_data,
)
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
except Exception as e:
@@ -113,23 +113,6 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
await callback_query.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
async def process_start_logic(
message: Message,
state: FSMContext,
session: Any,
admin: bool,
text_to_process: str = None,
user_data: dict | None = None,
):
text = text_to_process or message.text or message.caption
user_data = user_data or {
"tg_id": (message.from_user or message.chat).id,
"username": getattr(message.from_user, "username", None),
"first_name": getattr(message.from_user, "first_name", None),
"last_name": getattr(message.from_user, "last_name", None),
"language_code": getattr(message.from_user, "language_code", None),
"is_bot": getattr(message.from_user, "is_bot", False),
}
async def process_start_logic(
message: Message,
@@ -139,10 +122,6 @@ async def process_start_logic(
text_to_process: str = None,
user_data: dict | None = None,
):
from config import CHANNEL_EXISTS, CHANNEL_REQUIRED, CHANNEL_ID, CHANNEL_URL
from handlers.texts import SUBSCRIPTION_REQUIRED_MSG
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardButton
user_data = user_data or {
"tg_id": (message.from_user or message.chat).id,
@@ -229,7 +208,7 @@ async def process_start_logic(
logger.info(f"[UTM] Обнаружена ссылка на UTM: {utm_code}")
await handle_utm_link(utm_code, message, state, session, user_data=user_data)
continue
await state.clear()
if gift_detected:
return
@@ -247,7 +226,7 @@ async def process_start_logic(
await show_start_menu(message, admin, session)
else:
await show_start_menu(message, admin, session)
await state.clear()
except Exception as e:
@@ -342,4 +321,4 @@ async def handle_about_vpn(callback_query: CallbackQuery, session: Any):
reply_markup=builder.as_markup(),
media_path=image_path,
force_text=False,
)
)
+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+3 -1
View File
@@ -1,9 +1,11 @@
from aiohttp.web_urldispatcher import UrlDispatcher
import bot
from config import TBLOCKER_WEBHOOK_PATH
from .tblocker import tblocker_webhook
async def register_web_routes(router: UrlDispatcher) -> None:
dp = bot.dp
router.add_post(TBLOCKER_WEBHOOK_PATH, tblocker_webhook)
+33 -52
View File
@@ -1,34 +1,30 @@
import asyncio
import datetime
from datetime import datetime
import json
import os
import re
import aiohttp
from aiohttp import web
from datetime import datetime
import asyncpg
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiohttp import web
from bot import bot
from config import BLOCK_DURATION, DATABASE_URL, SERVER_COUNTRIES, TIMESTAMP_TTL
from database import get_key_details
from logger import logger
from handlers.buttons import MAIN_MENU
from logger import logger
last_unblock_data = {}
def get_country_from_server(server: str) -> str:
"""
Определяет страну сервера по его имени или домену.
Ищет совпадение части домена в полных доменах.
"""
server_part = server.split('.')[0]
server_part = server.split(".")[0]
for full_domain, country in SERVER_COUNTRIES.items():
if server_part in full_domain:
@@ -52,17 +48,18 @@ def handle_telegram_errors(func):
tg_id = kwargs.get("tg_id") or args[1]
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
return False
return wrapper
@handle_telegram_errors
async def send_notification(tg_id: int, username: str, ip: str, server: str, action: str, timestamp: str):
country = get_country_from_server(server)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if action == 'block':
if action == "block":
message = (
f"⚠️ <b>Замечено использование торрентов</b> ⚠️\n\n"
f"<b>Уважаемый пользователь, мы обнаружили использование торрент-трафика в вашей подписке.</b>\n\n"
@@ -92,13 +89,8 @@ async def send_notification(tg_id: int, username: str, ip: str, server: str, act
f"• Пожалуйста, воздержитесь от использования торрентов\n"
f"• Убедитесь, что торрент-клиент полностью выключен"
)
await bot.send_message(
chat_id=tg_id,
text=message,
parse_mode='HTML',
reply_markup=builder.as_markup()
)
await bot.send_message(chat_id=tg_id, text=message, parse_mode="HTML", reply_markup=builder.as_markup())
logger.info(f"Отправлено уведомление пользователю {tg_id} о {action} для подписки {username}")
return True
@@ -107,13 +99,13 @@ async def tblocker_webhook(request):
try:
data = await request.json()
logger.info(f"Получен запрос от tblocker: {data}")
username = data.get('username')
ip = data.get('ip')
server = data.get('server')
action = data.get('action')
timestamp = data.get('timestamp')
username = data.get("username")
ip = data.get("ip")
server = data.get("server")
action = data.get("action")
timestamp = data.get("timestamp")
if not all([username, ip, server, action, timestamp]):
logger.error("Неполные данные в вебхуке")
return web.json_response({"error": "Missing required fields"}, status=400)
@@ -122,45 +114,34 @@ async def tblocker_webhook(request):
current_time = datetime.now().timestamp()
last_unblock_data = {
k: v for k, v in last_unblock_data.items()
if current_time - v['received_at'] <= TIMESTAMP_TTL
k: v for k, v in last_unblock_data.items() if current_time - v["received_at"] <= TIMESTAMP_TTL
}
cache_key = f"{username}:{server}"
if action == 'unblock' and cache_key in last_unblock_data:
if timestamp == last_unblock_data[cache_key]['timestamp']:
if action == "unblock" and cache_key in last_unblock_data:
if timestamp == last_unblock_data[cache_key]["timestamp"]:
return web.json_response({"status": "ok", "message": "duplicate unblock skipped"})
if action == 'unblock':
last_unblock_data[cache_key] = {
'timestamp': timestamp,
'received_at': current_time
}
if action == "unblock":
last_unblock_data[cache_key] = {"timestamp": timestamp, "received_at": current_time}
conn = await asyncpg.connect(DATABASE_URL)
try:
key_info = await get_key_details(username, conn)
if not key_info:
logger.error(f"Ключ не найден для email {username}")
return web.json_response({"error": "Key not found"}, status=404)
success = await send_notification(
key_info['tg_id'],
username,
ip,
server,
action,
timestamp
)
success = await send_notification(key_info["tg_id"], username, ip, server, action, timestamp)
if not success:
logger.warning(f"Не удалось отправить уведомление пользователю {key_info['tg_id']}")
return web.json_response({"status": "ok"})
finally:
await conn.close()
except Exception as e:
logger.error(f"Ошибка при обработке вебхука: {str(e)}")
return web.json_response({"error": str(e)}, status=500)
return web.json_response({"error": str(e)}, status=500)