silent mode/ wata flow/ api tracking/ refactoring

This commit is contained in:
Vladless
2025-08-02 21:13:50 +03:00
parent f02a6358e9
commit f87da6db7a
51 changed files with 1548 additions and 1529 deletions
-2
View File
@@ -1,5 +1,3 @@
from typing import Any, Type, Union
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+1 -1
View File
@@ -8,7 +8,7 @@ from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.key_utils import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
+29 -1
View File
@@ -13,6 +13,7 @@ from api.schemas import (
TemporaryDataResponse,
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.models import (
Admin,
BlockedUser,
@@ -133,9 +134,36 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
enabled_methods=["get_all", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_admin_token)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_admin_token)]
)
async def get_tracking_source_with_stats(
code: str,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Tracking source not found")
stats = await get_tracking_source_stats(session, code)
return TrackingSourceResponse(
id=source.id,
name=source.name,
code=source.code,
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=stats.get("registrations", 0),
trials=stats.get("trials", 0),
payments=stats.get("payments", 0),
)
+1 -1
View File
@@ -9,7 +9,7 @@ from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.key_utils import delete_key_from_cluster
from handlers.keys.operations import delete_key_from_cluster
from logger import logger
-1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, model_validator
-1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
-2
View File
@@ -1,5 +1,3 @@
from typing import Optional
from pydantic import BaseModel, Field
+4
View File
@@ -102,5 +102,9 @@ class TrackingSourceResponse(BaseModel):
created_by: int
created_at: datetime
registrations: int = 0
trials: int = 0
payments: int = 0
class Config:
from_attributes = True
-2
View File
@@ -1,5 +1,3 @@
from typing import Optional
from pydantic import BaseModel
-1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
-1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
+11 -11
View File
@@ -44,7 +44,7 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
):
logger.warning("Отправляем стартовое меню.")
try:
from handlers.start import handle_start_callback_query, start_command
from handlers.start import start_entry
if event.update.message:
fsm_context = dp.fsm.get_context(
@@ -52,8 +52,8 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message,
await start_entry(
event=event.update.message,
state=fsm_context,
session=None,
admin=False,
@@ -65,15 +65,15 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query,
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
return True
@@ -93,7 +93,7 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
from handlers.start import handle_start_callback_query, start_command
from handlers.start import start_entry
if event.update.message:
fsm_context = dp.fsm.get_context(
@@ -101,8 +101,8 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message,
await start_entry(
event=event.update.message,
state=fsm_context,
session=None,
admin=False,
@@ -114,8 +114,8 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query,
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
+1 -1
View File
@@ -151,7 +151,7 @@ async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext
await callback.message.edit_text(
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
"Пример:\n<code>123456789\n987654321</code>",
reply_markup=build_admin_back_kb("bans")
reply_markup=build_admin_back_kb("bans"),
)
+1 -2
View File
@@ -1,7 +1,6 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback
def build_bans_kb():
+5 -3
View File
@@ -21,7 +21,7 @@ from config import (
from database import check_unique_server_name, get_servers, update_key_expiry
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
from handlers.keys.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
@@ -745,13 +745,15 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
device_limit = 0
if key.tariff_id:
result = await session.execute(
select(Tariff.traffic_limit, Tariff.device_limit).where(Tariff.id == key.tariff_id, Tariff.is_active.is_(True))
select(Tariff.traffic_limit, Tariff.device_limit).where(
Tariff.id == key.tariff_id, Tariff.is_active.is_(True)
)
)
tariff = result.first()
if tariff:
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
device_limit = int(tariff[1]) if tariff[1] is not None else 0
await renew_key_in_cluster(
cluster_name,
email=key.email,
-2
View File
@@ -1,5 +1,3 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
+1 -1
View File
@@ -3,7 +3,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from database.models import Gift, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days, format_months, get_plural_form
from handlers.utils import format_days, format_months
from ..panel.keyboard import AdminPanelCallback
@@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database.models import Admin, Key, Server, User
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import update_subscription
from handlers.keys.operations import update_subscription
from logger import logger
from middlewares import maintenance
from panels.remnawave import RemnawaveAPI
+9 -44
View File
@@ -5,8 +5,8 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK, MAIN_MENU
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
@@ -21,6 +21,7 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="👤 Поиск пользователя",
callback_data=AdminPanelCallback(action="search_user").pack(),
@@ -29,15 +30,18 @@ async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
text="🔑 Поиск по подписке",
callback_data=AdminPanelCallback(action="search_key").pack(),
)
if admin_role == "superadmin":
builder.button(
text="🖥️ Управление серверами",
callback_data=AdminPanelCallback(action="clusters").pack(),
)
builder.row(
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
if admin_role == "superadmin":
builder.row(
InlineKeyboardButton(text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()),
@@ -48,14 +52,8 @@ async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
callback_data=AdminPanelCallback(action="management").pack(),
)
builder.row(
InlineKeyboardButton(
text="📊 Статистика",
callback_data=AdminPanelCallback(action="stats").pack(),
),
InlineKeyboardButton(
text="📈 Аналитика",
callback_data=AdminPanelCallback(action="ads").pack(),
),
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
)
else:
builder.button(
@@ -64,40 +62,7 @@ async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
)
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
for module_btn in module_buttons:
if isinstance(module_btn, dict) and "after" in module_btn:
after_callback = module_btn["after"]
insert_pos = -1
current_markup = builder.as_markup()
for i, row in enumerate(current_markup.inline_keyboard):
for btn in row:
if btn.callback_data == after_callback:
insert_pos = i + 1
break
if insert_pos > 0:
break
if insert_pos > 0:
new_buttons = []
for i, row in enumerate(current_markup.inline_keyboard):
if i == insert_pos:
new_buttons.append([module_btn["button"]])
new_buttons.append(row)
if insert_pos >= len(current_markup.inline_keyboard):
new_buttons.append([module_btn["button"]])
builder = InlineKeyboardBuilder.from_markup(InlineKeyboardMarkup(inline_keyboard=new_buttons))
else:
builder.row(module_btn["button"])
else:
if isinstance(module_btn, dict):
builder.row(module_btn["button"])
else:
builder.row(module_btn)
builder = insert_hook_buttons(builder, module_buttons)
builder.button(
text=MAIN_MENU,
@@ -108,7 +73,7 @@ async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
builder.adjust(1, 1, 1, 1, 2, 2, 1, 2, 1)
else:
builder.adjust(1, 1, 1, 2, 1, 1)
return builder.as_markup()
+1 -1
View File
@@ -10,7 +10,7 @@ from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
+1 -1
View File
@@ -33,7 +33,7 @@ from database import (
)
from database.models import Key, ManualBan, Payment, Referral, Server, Tariff, User
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
from handlers.keys.operations import (
create_key_on_cluster,
delete_key_from_cluster,
get_user_traffic,
+1 -1
View File
@@ -28,7 +28,7 @@ from database import (
update_key_expiry,
)
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.keys.operations import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPONS_DAYS_MESSAGE,
+1 -2
View File
@@ -5,7 +5,6 @@ from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import text
from database import (
get_key_details,
@@ -15,7 +14,7 @@ from database import (
mark_key_as_unfrozen,
)
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
from handlers.keys.operations import renew_key_in_cluster, toggle_client_on_cluster
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
+1 -3
View File
@@ -32,12 +32,10 @@ from handlers.buttons import (
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_key_on_cluster
from handlers.keys.operations import create_key_on_cluster
from handlers.texts import key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
+1 -4
View File
@@ -30,19 +30,16 @@ from database import (
check_server_name_by_cluster,
check_user_exists,
get_key_details,
get_tariff_by_id,
get_trial,
update_balance,
update_trial,
)
from database.models import Key, Server, Tariff
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, MY_SUB, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_client_on_server
from handlers.keys.operations import create_client_on_server
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
+5 -1
View File
@@ -10,7 +10,6 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
NOTIFY_EXTRA_DAYS,
@@ -33,6 +32,7 @@ from handlers.admin.panel.keyboard import AdminPanelCallback
from handlers.buttons import MAIN_MENU, PAYMENT
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.wata import handle_custom_amount_input as handle_custom_amount_input_wata
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
@@ -321,6 +321,10 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "WATA":
await state.update_data(wata_cassa="sbp", required_amount=required_amount)
await handle_custom_amount_input_wata(callback_query, state)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
+5 -1
View File
@@ -28,9 +28,10 @@ from database import (
from database.models import Key, Server
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.keys.operations import renew_key_in_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.wata import handle_custom_amount_input as handle_custom_amount_input_wata
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
@@ -292,6 +293,9 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "WATA":
await state.update_data(wata_cassa="sbp", required_amount=required_amount)
await handle_custom_amount_input_wata(callback_query, state)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
-987
View File
@@ -1,987 +0,0 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import delete_notification, get_servers, get_tariff_by_id, store_key
from database.models import Key, Server, Tariff
from handlers.utils import check_server_key_limit, get_least_loaded_cluster
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import (
ClientConfig,
add_client,
delete_client,
extend_client_key,
get_client_traffic,
get_xui_instance,
toggle_client,
)
async def create_key_on_cluster(
cluster_id: str,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
plan: int = None,
session: AsyncSession = None,
remnawave_link: str = None,
hwid_limit: int = None,
traffic_limit_bytes: int = None,
is_trial: bool = False,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
server_id_to_store = cluster_id
if not cluster:
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
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
enabled_servers = [s for s in cluster if s.get("enabled", True)]
if not enabled_servers:
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
return
if plan is not None and traffic_limit_bytes is None:
tariff = await get_tariff_by_id(session, plan)
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
traffic_limit_bytes = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None
if hwid_limit is None and tariff.get("device_limit") is not None:
hwid_limit = int(tariff["device_limit"])
remnawave_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, session)
]
xui_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, session)
]
if not remnawave_servers and not xui_servers:
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
return
semaphore = asyncio.Semaphore(2)
remnawave_created = False
remnawave_key = None
remnawave_client_id = None
if remnawave_servers:
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
else:
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
inbound_ids = [s.get("inbound_id") for s in remnawave_servers if s.get("inbound_id")]
if not inbound_ids:
logger.warning("Нет inbound_id у серверов Remnawave")
else:
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeInternalSquads": inbound_ids,
}
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes * 1024 * 1024 * 1024
if short_uuid:
user_data["shortUuid"] = short_uuid
if hwid_limit is not None:
user_data["hwidDeviceLimit"] = hwid_limit
logger.info(f"[Key Creation] Данные для создания клиента в Remnawave: {user_data}")
result = await remna.create_user(user_data)
if not result:
logger.error("Ошибка при создании пользователя в Remnawave")
else:
remnawave_created = True
remnawave_key = result.get("subscriptionUrl")
remnawave_client_id = result.get("uuid")
logger.info(f"[Key Creation] Пользователь создан в Remnawave: {result}")
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" if xui_servers else None
final_client_id = remnawave_client_id or client_id
logger.info(f"[Debug] 3x-ui servers для кластера {cluster_id}: {[s['server_name'] for s in xui_servers]}")
if xui_servers:
if SUPERNODE:
for server_info in xui_servers:
await create_client_on_server(
server_info,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
session=session,
is_trial=is_trial,
)
else:
await asyncio.gather(
*[
create_client_on_server(
server,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
session=session,
is_trial=is_trial,
)
for server in xui_servers
],
return_exceptions=True,
)
if (remnawave_created and remnawave_client_id) or xui_servers:
await store_key(
session=session,
tg_id=tg_id,
client_id=final_client_id,
email=email,
expiry_time=expiry_timestamp,
key=public_link,
server_id=server_id_to_store,
remnawave_link=remnawave_key,
tariff_id=plan,
)
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
raise e
async def create_client_on_server(
server_info: dict,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
semaphore: asyncio.Semaphore,
plan: int = None,
session=None,
is_trial: bool = False,
):
"""
Создает клиента на указанном 3x-ui сервере с лимитом по тарифу или триалу.
"""
logger.info(
f"[Client] Вход в create_client_on_server: сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}"
)
async with semaphore:
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"[Client] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
return
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
total_gb_value = 0
device_limit_value = 0
if plan is not None:
tariff = await get_tariff_by_id(session, plan)
logger.info(f"[Tariff Debug] Получен тариф: {tariff}")
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
try:
logger.info(
f"[Client] Вызов add_client: email={email}, client_id={client_id}, GB={total_gb_value}, Devices={device_limit_value}"
)
traffic_limit_bytes = total_gb_value * 1024 * 1024 * 1024
await add_client(
xui,
ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=traffic_limit_bytes,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
),
)
logger.info(f"[Client] Клиент успешно добавлен на сервер {server_name}")
except Exception as e:
logger.error(f"[Client Error] Не удалось создать клиента на {server_name}: {e}")
if SUPERNODE:
await asyncio.sleep(0.7)
async def renew_key_in_cluster(
cluster_id: str,
email: str,
client_id: str,
new_expiry_time: int,
total_gb: int,
session: AsyncSession,
hwid_device_limit: int = 0,
reset_traffic: bool = True,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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} не найден.")
result = await session.execute(select(Key.tg_id, Key.server_id).where(Key.client_id == client_id).limit(1))
row = result.first()
if not row:
logger.error(f"Не найден пользователь с client_id={client_id} в таблице keys.")
return False
tg_id, server_id = row
result = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
tariff_group_row = result.scalar_one_or_none()
if tariff_group_row:
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == tariff_group_row, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
tariff = result.scalar_one_or_none()
if tariff and tariff.device_limit is not None:
hwid_device_limit = int(tariff.device_limit)
remnawave_inbound_ids = []
tasks = []
for server_info in cluster:
if server_info.get("panel_type", "3x-ui").lower() == "remnawave":
inbound_id = server_info.get("inbound_id")
if inbound_id:
remnawave_inbound_ids.append(inbound_id)
if remnawave_inbound_ids:
remnawave_server = next(
(
s
for s in cluster
if s.get("panel_type", "").lower() == "remnawave" and s.get("inbound_id") in remnawave_inbound_ids
),
None,
)
if remnawave_server:
remna = RemnawaveAPI(remnawave_server["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
traffic_limit_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
updated = await remna.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=remnawave_inbound_ids,
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_device_limit,
)
if updated:
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
if reset_traffic:
await remna.reset_user_traffic(client_id)
else:
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}, пробуем создать")
result = await session.execute(
select(Key.remnawave_link, Key.key).where(Key.client_id == client_id)
)
row = result.one_or_none()
remnawave_link = row[0] if row else None
row[1] if row else None
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeInternalSquads": remnawave_inbound_ids,
}
if remnawave_link and "/" in remnawave_link:
user_data["shortUuid"] = remnawave_link.rstrip("/").split("/")[-1]
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if hwid_device_limit is not None:
user_data["hwidDeviceLimit"] = hwid_device_limit
result = await remna.create_user(user_data)
if result:
new_client_id = result.get("uuid")
new_remnawave_link = result.get("subscriptionUrl")
logger.info(f"Пользователь Remnawave {client_id} успешно создан")
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(client_id=new_client_id, remnawave_link=new_remnawave_link)
)
await session.commit()
else:
logger.error(f"Не удалось создать пользователя Remnawave {client_id}")
else:
logger.error("Не удалось войти в Remnawave API")
for server_info in cluster:
if server_info.get("panel_type", "3x-ui").lower() != "3x-ui":
continue
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
traffic_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
async def update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name):
updated = await extend_client_key(
xui=xui,
inbound_id=int(inbound_id),
email=unique_email,
new_expiry_time=new_expiry_time,
client_id=client_id,
total_gb=traffic_bytes,
sub_id=sub_id,
tg_id=tg_id,
limit_ip=hwid_device_limit,
)
if not updated:
logger.warning(f"Не удалось обновить клиента {unique_email}, пробуем создать")
config = ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=hwid_device_limit if hwid_device_limit is not None else 0,
total_gb=traffic_bytes,
expiry_time=new_expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
await add_client(xui, config)
tasks.append(update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name))
await asyncio.gather(*tasks, return_exceptions=True)
notification_prefixes = ["key_24h", "key_10h", "key_expired", "renew"]
for notif in notification_prefixes:
notification_id = f"{email}_{notif}"
await delete_notification(session, tg_id, notification_id)
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
except Exception as e:
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise
async def delete_key_from_cluster(cluster_id: str, email: str, client_id: str, session: AsyncSession):
"""Удаление ключа с серверов в кластере или с конкретного сервера"""
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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} не найден.")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"[Remnawave] Не удалось войти на сервер {server_name}")
continue
success = await remna.delete_user(client_id)
if success:
logger.info(f"[Remnawave] Клиент {client_id} успешно удалён с {server_name}")
else:
logger.warning(f"[Remnawave] Не удалось удалить клиента {client_id} с {server_name}")
elif panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[3x-ui] INBOUND_ID отсутствует на сервере {server_name}. Пропуск.")
continue
await delete_client(
xui,
inbound_id=int(inbound_id),
email=email,
client_id=client_id,
)
logger.info(f"[3x-ui] Клиент {client_id} удалён с сервера {server_name}")
else:
logger.warning(f"[Unknown] Неизвестный тип панели '{panel_type}' для сервера {server_name}")
except Exception as e:
logger.error(f"❌ Ошибка при удалении ключа {client_id} из кластера/сервера {cluster_id}: {e}")
raise
async def update_key_on_cluster(
tg_id: int,
client_id: str,
email: str,
expiry_time: int,
cluster_id: str,
session: AsyncSession,
traffic_limit: int = None,
device_limit: int = None,
remnawave_link: str = None,
):
"""
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
Работает с панелями 3x-ui и Remnawave. Возвращает кортеж: (новый client_id, remnawave ссылка или None).
"""
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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} не найден.")
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_client_id = None
remnawave_key = None
if remnawave_servers:
inbound_ids = [s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")]
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
group_code = remnawave_servers[0].get("tariff_group")
if not group_code:
raise ValueError("У Remnawave-сервера отсутствует tariff_group")
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
result.scalar_one_or_none()
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
logger.info(f"[Update] Извлечен short_uuid из ссылки: {short_uuid}")
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeInternalSquads": inbound_ids,
}
if traffic_limit is not None:
user_data["trafficLimitBytes"] = traffic_limit * 1024**3
if device_limit is not None:
user_data["hwidDeviceLimit"] = device_limit
if short_uuid:
user_data["shortUuid"] = short_uuid
logger.info(f"[Update] Добавлен short_uuid в user_data: {short_uuid}")
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
else:
logger.error("[Update] Ошибка создания Remnawave клиента")
else:
logger.error("[Update] Не удалось авторизоваться в Remnawave")
if not remnawave_client_id:
logger.warning(f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}")
remnawave_client_id = client_id
tasks = []
for server_info in xui_servers:
server_name = server_info.get("server_name", "unknown")
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(server_info["api_url"])
sub_id = email
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
group_code = server_info.get("tariff_group")
if not group_code:
raise ValueError(f"У сервера {server_name} отсутствует tariff_group")
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
result.scalar_one_or_none()
total_gb_bytes = int(traffic_limit * 1024**3) if traffic_limit is not None else 0
device_limit_value = device_limit if device_limit is not None else 0
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=total_gb_bytes,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
tasks.append(add_client(xui, config))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}")
return remnawave_client_id, remnawave_key
except Exception as e:
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise
async def update_subscription(
tg_id: int,
email: str,
session: AsyncSession,
cluster_override: str = None,
country_override: str = None,
remnawave_link: str = None,
) -> None:
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email))
record = result.scalar_one_or_none()
if not record:
raise ValueError(f"The key {email} does not exist in database")
expiry_time = record.expiry_time
client_id = record.client_id
old_cluster_id = record.server_id
tariff_id = record.tariff_id
alias = record.alias
remnawave_link = remnawave_link or record.remnawave_link
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
traffic_limit = None
device_limit = None
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None
device_limit = int(tariff.device_limit) if tariff.device_limit is not None else 0
else:
logger.warning(f"[LOG] update_subscription: тариф с id={tariff_id} не найден!")
else:
logger.warning("[LOG] update_subscription: tariff_id отсутствует!")
await delete_key_from_cluster(old_cluster_id, email, client_id, session=session)
await session.execute(delete(Key).where(Key.tg_id == tg_id, Key.email == email))
await session.commit()
if country_override or cluster_override:
new_cluster_id = country_override or cluster_override
else:
try:
new_cluster_id = await get_least_loaded_cluster(session)
except ValueError:
logger.warning("[Update] Нет доступных кластеров, оставляем на старом")
new_cluster_id = old_cluster_id
new_client_id, remnawave_key = await update_key_on_cluster(
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_time=expiry_time,
cluster_id=new_cluster_id,
session=session,
traffic_limit=traffic_limit,
device_limit=device_limit,
remnawave_link=remnawave_link,
)
servers = await get_servers(session)
cluster_servers = servers.get(new_cluster_id)
if cluster_servers is None:
for server_list in servers.values():
for server_info in server_list:
if server_info.get("server_name", "").lower() == new_cluster_id.lower():
cluster_servers = [server_info]
break
if cluster_servers:
break
else:
cluster_servers = []
has_xui = any(s.get("panel_type", "").lower() == "3x-ui" for s in cluster_servers)
final_key_link = public_link if has_xui else None
await store_key(
session=session,
tg_id=tg_id,
client_id=new_client_id,
email=email,
expiry_time=expiry_time,
key=final_key_link,
remnawave_link=remnawave_key,
server_id=new_cluster_id,
tariff_id=tariff_id,
alias=alias,
)
async def get_user_traffic(session: AsyncSession, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
"""
result = await session.execute(select(Key.client_id, Key.server_id).where(Key.tg_id == tg_id, Key.email == email))
rows = result.all()
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
server_ids = {row.server_id for row in rows}
server_id = list(server_ids)[0]
result = await session.execute(
select(Server)
.where(Server.enabled.is_(True))
.where(Server.server_name.in_(server_ids) | Server.cluster_name.in_(server_ids))
)
server_rows = result.scalars().all()
if not server_rows:
logger.error(f"Не найдено серверов для: {server_ids}")
return {
"status": "error",
"message": f"Серверы не найдены: {', '.join(server_ids)}",
}
servers_map = {
s.server_name: {
"server_name": s.server_name,
"cluster_name": s.cluster_name,
"api_url": s.api_url,
"panel_type": s.panel_type,
}
for s in server_rows
}
user_traffic_data = {}
tasks = []
remnawave_client_id = None
remnawave_checked = False
remnawave_api_url = None
async def fetch_traffic(server_info: dict, client_id: str) -> tuple[str, Any]:
server_name = server_info["server_name"]
api_url = server_info["api_url"]
panel_type = server_info.get("panel_type", "3x-ui").lower()
try:
if panel_type == "3x-ui":
xui = await get_xui_instance(api_url)
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
return server_name, round(used_gb, 2)
else:
return server_name, "Ошибка получения трафика"
else:
return server_name, f"Неизвестная панель: {panel_type}"
except Exception as e:
return server_name, f"Ошибка: {e}"
for row in rows:
client_id = row.client_id
server_id = row.server_id
matched_servers = [
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
]
for server_info in matched_servers:
panel_type = server_info.get("panel_type", "3x-ui").lower()
if panel_type == "remnawave" and not remnawave_checked:
remnawave_client_id = client_id
remnawave_api_url = server_info["api_url"]
remnawave_checked = True
elif panel_type == "3x-ui":
tasks.append(fetch_traffic(server_info, client_id))
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in results:
user_traffic_data[server] = result
if remnawave_client_id and remnawave_api_url:
try:
remna = RemnawaveAPI(remnawave_api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
user_traffic_data["Remnawave (общий)"] = "Не удалось авторизоваться"
else:
user_data = await remna.get_user_by_uuid(remnawave_client_id)
if not user_data:
user_traffic_data["Remnawave (общий)"] = "Клиент не найден"
else:
used_bytes = user_data.get("usedTrafficBytes", 0)
used_gb = round(used_bytes / 1073741824, 2)
user_traffic_data["Remnawave (общий)"] = used_gb
except Exception as e:
user_traffic_data["Remnawave (общий)"] = f"Ошибка: {e}"
return {"status": "success", "traffic": user_traffic_data}
async def toggle_client_on_cluster(
cluster_id: str,
email: str,
client_id: str,
enable: bool = True,
session: AsyncSession = None,
) -> dict[str, Any]:
try:
if session is None:
raise ValueError("[Cluster Toggle] Не передан объект сессии для toggle_client_on_cluster")
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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}' не найден.")
results = {}
tasks = []
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if panel_type == "3x-ui":
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[3x-ui] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
results[server_name] = False
continue
xui = await get_xui_instance(server_info["api_url"])
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(toggle_client(xui, int(inbound_id), unique_email, client_id, enable))
elif panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"[Remnawave] Авторизация не удалась на сервере {server_name}")
results[server_name] = False
continue
func = remna.enable_user if enable else remna.disable_user
tasks.append(func(client_id))
else:
logger.warning(
f"[Cluster Toggle] Неизвестный тип панели '{panel_type}' на сервере {server_name}. Пропуск."
)
results[server_name] = False
task_results = await asyncio.gather(*tasks, return_exceptions=True)
for server_info, result in zip(cluster, task_results, strict=False):
server_name = server_info.get("server_name", "unknown")
if isinstance(result, Exception):
logger.error(f"[Cluster Toggle] Ошибка на сервере {server_name}: {result}")
results[server_name] = False
else:
results[server_name] = result
status = "включен" if enable else "отключен"
logger.info(f"[Cluster Toggle] Клиент {email} {status} на серверах кластера {cluster_id}")
logger.info(f"[Cluster Toggle DEBUG] Результаты: {results}")
return {
"status": "success" if any(results.values()) else "error",
"results": results,
}
except Exception as e:
logger.error(f"[Cluster Toggle] Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
return {"status": "error", "error": str(e)}
async def reset_traffic_in_cluster(cluster_id: str, email: str, session: AsyncSession) -> None:
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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 = []
remnawave_done = False
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
api_url = server_info.get("api_url")
inbound_id = server_info.get("inbound_id")
if panel_type == "remnawave" and not remnawave_done:
result = await session.execute(
select(Key.client_id).where(Key.email == email, Key.server_id == cluster_id).limit(1)
)
row = result.first()
if not row:
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row[0]
remna = RemnawaveAPI(api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
remnawave_done = True
continue
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(api_url)
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
else:
logger.warning(f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+1 -3
View File
@@ -1,5 +1,3 @@
import asyncio
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
@@ -7,8 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_key, get_key_details
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import delete_key_from_cluster, update_subscription
from handlers.keys.key_view import process_callback_view_key
from handlers.keys.operations import delete_key_from_cluster, update_subscription
from handlers.texts import DELETE_KEY_CONFIRM_MSG, KEY_DELETED_MSG_SIMPLE
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
+21
View File
@@ -0,0 +1,21 @@
# handlers/keys/operations/__init__.py
from .creation import create_client_on_server, create_key_on_cluster
from .deletion import delete_key_from_cluster
from .renewal import renew_key_in_cluster
from .toggles import toggle_client_on_cluster
from .traffic import get_user_traffic, reset_traffic_in_cluster
from .update import update_key_on_cluster, update_subscription
__all__ = [
"create_key_on_cluster",
"create_client_on_server",
"renew_key_in_cluster",
"update_key_on_cluster",
"update_subscription",
"delete_key_from_cluster",
"get_user_traffic",
"reset_traffic_in_cluster",
"toggle_client_on_cluster",
]
+255
View File
@@ -0,0 +1,255 @@
import asyncio
from datetime import datetime
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import get_servers, get_tariff_by_id, store_key
from database.models import User
from handlers.utils import check_server_key_limit
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import (
ClientConfig,
add_client,
get_xui_instance,
)
async def create_key_on_cluster(
cluster_id: str,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
plan: int = None,
session: AsyncSession = None,
remnawave_link: str = None,
hwid_limit: int = None,
traffic_limit_bytes: int = None,
is_trial: bool = False,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
server_id_to_store = cluster_id
if not cluster:
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
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
enabled_servers = [s for s in cluster if s.get("enabled", True)]
if not enabled_servers:
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
return
if plan is not None and traffic_limit_bytes is None:
tariff = await get_tariff_by_id(session, plan)
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
traffic_limit_bytes = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None
if hwid_limit is None and tariff.get("device_limit") is not None:
hwid_limit = int(tariff["device_limit"])
remnawave_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, session)
]
xui_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, session)
]
if not remnawave_servers and not xui_servers:
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
return
semaphore = asyncio.Semaphore(2)
remnawave_created = False
remnawave_key = None
remnawave_client_id = None
if remnawave_servers:
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
else:
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
inbound_ids = [s.get("inbound_id") for s in remnawave_servers if s.get("inbound_id")]
if not inbound_ids:
logger.warning("Нет inbound_id у серверов Remnawave")
else:
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeInternalSquads": inbound_ids,
}
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes * 1024 * 1024 * 1024
if short_uuid:
user_data["shortUuid"] = short_uuid
if hwid_limit is not None:
user_data["hwidDeviceLimit"] = hwid_limit
logger.info(f"[Key Creation] Данные для создания клиента в Remnawave: {user_data}")
result = await remna.create_user(user_data)
if not result:
logger.error("Ошибка при создании пользователя в Remnawave")
else:
remnawave_created = True
remnawave_key = result.get("subscriptionUrl")
remnawave_client_id = result.get("uuid")
logger.info(f"[Key Creation] Пользователь создан в Remnawave: {result}")
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" if xui_servers else None
final_client_id = remnawave_client_id or client_id
logger.info(f"[Debug] 3x-ui servers для кластера {cluster_id}: {[s['server_name'] for s in xui_servers]}")
if xui_servers:
if SUPERNODE:
for server_info in xui_servers:
await create_client_on_server(
server_info,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
session=session,
is_trial=is_trial,
)
else:
await asyncio.gather(
*[
create_client_on_server(
server,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
session=session,
is_trial=is_trial,
)
for server in xui_servers
],
return_exceptions=True,
)
if (remnawave_created and remnawave_client_id) or xui_servers:
await store_key(
session=session,
tg_id=tg_id,
client_id=final_client_id,
email=email,
expiry_time=expiry_timestamp,
key=public_link,
server_id=server_id_to_store,
remnawave_link=remnawave_key,
tariff_id=plan,
)
await session.execute(update(User).where(User.tg_id == tg_id, User.trial.in_([0, -1])).values(trial=1))
await session.commit()
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
raise e
async def create_client_on_server(
server_info: dict,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
semaphore: asyncio.Semaphore,
plan: int = None,
session=None,
is_trial: bool = False,
):
"""
Создает клиента на указанном 3x-ui сервере с лимитом по тарифу или триалу.
"""
logger.info(
f"[Client] Вход в create_client_on_server: сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}"
)
async with semaphore:
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"[Client] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
return
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
total_gb_value = 0
device_limit_value = 0
if plan is not None:
tariff = await get_tariff_by_id(session, plan)
logger.info(f"[Tariff Debug] Получен тариф: {tariff}")
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
try:
logger.info(
f"[Client] Вызов add_client: email={email}, client_id={client_id}, GB={total_gb_value}, Devices={device_limit_value}"
)
traffic_limit_bytes = total_gb_value * 1024 * 1024 * 1024
await add_client(
xui,
ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=traffic_limit_bytes,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
),
)
logger.info(f"[Client] Клиент успешно добавлен на сервер {server_name}")
except Exception as e:
logger.error(f"[Client Error] Не удалось создать клиента на {server_name}: {e}")
if SUPERNODE:
await asyncio.sleep(0.7)
+65
View File
@@ -0,0 +1,65 @@
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database import get_servers
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance
async def delete_key_from_cluster(cluster_id: str, email: str, client_id: str, session: AsyncSession):
"""Удаление ключа с серверов в кластере или с конкретного сервера"""
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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} не найден.")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"[Remnawave] Не удалось войти на сервер {server_name}")
continue
success = await remna.delete_user(client_id)
if success:
logger.info(f"[Remnawave] Клиент {client_id} успешно удалён с {server_name}")
else:
logger.warning(f"[Remnawave] Не удалось удалить клиента {client_id} с {server_name}")
elif panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[3x-ui] INBOUND_ID отсутствует на сервере {server_name}. Пропуск.")
continue
await delete_client(
xui,
inbound_id=int(inbound_id),
email=email,
client_id=client_id,
)
logger.info(f"[3x-ui] Клиент {client_id} удалён с сервера {server_name}")
else:
logger.warning(f"[Unknown] Неизвестный тип панели '{panel_type}' для сервера {server_name}")
except Exception as e:
logger.error(f"❌ Ошибка при удалении ключа {client_id} из кластера/сервера {cluster_id}: {e}")
raise
+198
View File
@@ -0,0 +1,198 @@
import asyncio
from datetime import datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import delete_notification, get_servers
from database.models import Key, Server, Tariff
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import ClientConfig, add_client, extend_client_key, get_xui_instance
async def renew_key_in_cluster(
cluster_id: str,
email: str,
client_id: str,
new_expiry_time: int,
total_gb: int,
session: AsyncSession,
hwid_device_limit: int = 0,
reset_traffic: bool = True,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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} не найден.")
result = await session.execute(select(Key.tg_id, Key.server_id).where(Key.client_id == client_id).limit(1))
row = result.first()
if not row:
logger.error(f"Не найден пользователь с client_id={client_id} в таблице keys.")
return False
tg_id, server_id = row
result = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
tariff_group_row = result.scalar_one_or_none()
if tariff_group_row:
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == tariff_group_row, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
tariff = result.scalar_one_or_none()
if tariff and tariff.device_limit is not None:
hwid_device_limit = int(tariff.device_limit)
remnawave_inbound_ids = []
tasks = []
for server_info in cluster:
if server_info.get("panel_type", "3x-ui").lower() == "remnawave":
inbound_id = server_info.get("inbound_id")
if inbound_id:
remnawave_inbound_ids.append(inbound_id)
if remnawave_inbound_ids:
remnawave_server = next(
(
s
for s in cluster
if s.get("panel_type", "").lower() == "remnawave" and s.get("inbound_id") in remnawave_inbound_ids
),
None,
)
if remnawave_server:
remna = RemnawaveAPI(remnawave_server["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
traffic_limit_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
updated = await remna.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=remnawave_inbound_ids,
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_device_limit,
)
if updated:
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
if reset_traffic:
await remna.reset_user_traffic(client_id)
else:
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}, пробуем создать")
result = await session.execute(
select(Key.remnawave_link, Key.key).where(Key.client_id == client_id)
)
row = result.one_or_none()
remnawave_link = row[0] if row else None
row[1] if row else None
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeInternalSquads": remnawave_inbound_ids,
}
if remnawave_link and "/" in remnawave_link:
user_data["shortUuid"] = remnawave_link.rstrip("/").split("/")[-1]
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if hwid_device_limit is not None:
user_data["hwidDeviceLimit"] = hwid_device_limit
result = await remna.create_user(user_data)
if result:
new_client_id = result.get("uuid")
new_remnawave_link = result.get("subscriptionUrl")
logger.info(f"Пользователь Remnawave {client_id} успешно создан")
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(client_id=new_client_id, remnawave_link=new_remnawave_link)
)
await session.commit()
else:
logger.error(f"Не удалось создать пользователя Remnawave {client_id}")
else:
logger.error("Не удалось войти в Remnawave API")
for server_info in cluster:
if server_info.get("panel_type", "3x-ui").lower() != "3x-ui":
continue
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
traffic_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
async def update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name):
updated = await extend_client_key(
xui=xui,
inbound_id=int(inbound_id),
email=unique_email,
new_expiry_time=new_expiry_time,
client_id=client_id,
total_gb=traffic_bytes,
sub_id=sub_id,
tg_id=tg_id,
limit_ip=hwid_device_limit,
)
if not updated:
logger.warning(f"Не удалось обновить клиента {unique_email}, пробуем создать")
config = ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=hwid_device_limit if hwid_device_limit is not None else 0,
total_gb=traffic_bytes,
expiry_time=new_expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
await add_client(xui, config)
tasks.append(update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name))
await asyncio.gather(*tasks, return_exceptions=True)
notification_prefixes = ["key_24h", "key_10h", "key_expired", "renew"]
for notif in notification_prefixes:
notification_id = f"{email}_{notif}"
await delete_notification(session, tg_id, notification_id)
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
except Exception as e:
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise
+94
View File
@@ -0,0 +1,94 @@
import asyncio
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import get_servers
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import get_xui_instance, toggle_client
async def toggle_client_on_cluster(
cluster_id: str,
email: str,
client_id: str,
enable: bool = True,
session: AsyncSession = None,
) -> dict[str, Any]:
try:
if session is None:
raise ValueError("[Cluster Toggle] Не передан объект сессии для toggle_client_on_cluster")
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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}' не найден.")
results = {}
tasks = []
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if panel_type == "3x-ui":
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[3x-ui] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
results[server_name] = False
continue
xui = await get_xui_instance(server_info["api_url"])
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(toggle_client(xui, int(inbound_id), unique_email, client_id, enable))
elif panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"[Remnawave] Авторизация не удалась на сервере {server_name}")
results[server_name] = False
continue
func = remna.enable_user if enable else remna.disable_user
tasks.append(func(client_id))
else:
logger.warning(
f"[Cluster Toggle] Неизвестный тип панели '{panel_type}' на сервере {server_name}. Пропуск."
)
results[server_name] = False
task_results = await asyncio.gather(*tasks, return_exceptions=True)
for server_info, result in zip(cluster, task_results, strict=False):
server_name = server_info.get("server_name", "unknown")
if isinstance(result, Exception):
logger.error(f"[Cluster Toggle] Ошибка на сервере {server_name}: {result}")
results[server_name] = False
else:
results[server_name] = result
status = "включен" if enable else "отключен"
logger.info(f"[Cluster Toggle] Клиент {email} {status} на серверах кластера {cluster_id}")
logger.info(f"[Cluster Toggle DEBUG] Результаты: {results}")
return {
"status": "success" if any(results.values()) else "error",
"results": results,
}
except Exception as e:
logger.error(f"[Cluster Toggle] Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
return {"status": "error", "error": str(e)}
+181
View File
@@ -0,0 +1,181 @@
import asyncio
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import get_servers
from database.models import Key, Server
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import get_client_traffic, get_xui_instance
async def get_user_traffic(session: AsyncSession, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
"""
result = await session.execute(select(Key.client_id, Key.server_id).where(Key.tg_id == tg_id, Key.email == email))
rows = result.all()
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
server_ids = {row.server_id for row in rows}
server_id = list(server_ids)[0]
result = await session.execute(
select(Server)
.where(Server.enabled.is_(True))
.where(Server.server_name.in_(server_ids) | Server.cluster_name.in_(server_ids))
)
server_rows = result.scalars().all()
if not server_rows:
logger.error(f"Не найдено серверов для: {server_ids}")
return {
"status": "error",
"message": f"Серверы не найдены: {', '.join(server_ids)}",
}
servers_map = {
s.server_name: {
"server_name": s.server_name,
"cluster_name": s.cluster_name,
"api_url": s.api_url,
"panel_type": s.panel_type,
}
for s in server_rows
}
user_traffic_data = {}
tasks = []
remnawave_client_id = None
remnawave_checked = False
remnawave_api_url = None
async def fetch_traffic(server_info: dict, client_id: str) -> tuple[str, Any]:
server_name = server_info["server_name"]
api_url = server_info["api_url"]
panel_type = server_info.get("panel_type", "3x-ui").lower()
try:
if panel_type == "3x-ui":
xui = await get_xui_instance(api_url)
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
return server_name, round(used_gb, 2)
else:
return server_name, "Ошибка получения трафика"
else:
return server_name, f"Неизвестная панель: {panel_type}"
except Exception as e:
return server_name, f"Ошибка: {e}"
for row in rows:
client_id = row.client_id
server_id = row.server_id
matched_servers = [
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
]
for server_info in matched_servers:
panel_type = server_info.get("panel_type", "3x-ui").lower()
if panel_type == "remnawave" and not remnawave_checked:
remnawave_client_id = client_id
remnawave_api_url = server_info["api_url"]
remnawave_checked = True
elif panel_type == "3x-ui":
tasks.append(fetch_traffic(server_info, client_id))
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in results:
user_traffic_data[server] = result
if remnawave_client_id and remnawave_api_url:
try:
remna = RemnawaveAPI(remnawave_api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
user_traffic_data["Remnawave (общий)"] = "Не удалось авторизоваться"
else:
user_data = await remna.get_user_by_uuid(remnawave_client_id)
if not user_data:
user_traffic_data["Remnawave (общий)"] = "Клиент не найден"
else:
used_bytes = user_data.get("usedTrafficBytes", 0)
used_gb = round(used_bytes / 1073741824, 2)
user_traffic_data["Remnawave (общий)"] = used_gb
except Exception as e:
user_traffic_data["Remnawave (общий)"] = f"Ошибка: {e}"
return {"status": "success", "traffic": user_traffic_data}
async def reset_traffic_in_cluster(cluster_id: str, email: str, session: AsyncSession) -> None:
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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 = []
remnawave_done = False
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
api_url = server_info.get("api_url")
inbound_id = server_info.get("inbound_id")
if panel_type == "remnawave" and not remnawave_done:
result = await session.execute(
select(Key.client_id).where(Key.email == email, Key.server_id == cluster_id).limit(1)
)
row = result.first()
if not row:
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row[0]
remna = RemnawaveAPI(api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
remnawave_done = True
continue
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(api_url)
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
else:
logger.warning(f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+252
View File
@@ -0,0 +1,252 @@
import asyncio
from datetime import datetime, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import get_servers, store_key
from database.models import Key, Tariff
from handlers.utils import get_least_loaded_cluster
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import ClientConfig, add_client, get_xui_instance
from .deletion import delete_key_from_cluster
async def update_key_on_cluster(
tg_id: int,
client_id: str,
email: str,
expiry_time: int,
cluster_id: str,
session: AsyncSession,
traffic_limit: int = None,
device_limit: int = None,
remnawave_link: str = None,
):
"""
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
Работает с панелями 3x-ui и Remnawave. Возвращает кортеж: (новый client_id, remnawave ссылка или None).
"""
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
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} не найден.")
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_client_id = None
remnawave_key = None
if remnawave_servers:
inbound_ids = [s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")]
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
group_code = remnawave_servers[0].get("tariff_group")
if not group_code:
raise ValueError("У Remnawave-сервера отсутствует tariff_group")
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
result.scalar_one_or_none()
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
logger.info(f"[Update] Извлечен short_uuid из ссылки: {short_uuid}")
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeInternalSquads": inbound_ids,
}
if traffic_limit is not None:
user_data["trafficLimitBytes"] = traffic_limit * 1024**3
if device_limit is not None:
user_data["hwidDeviceLimit"] = device_limit
if short_uuid:
user_data["shortUuid"] = short_uuid
logger.info(f"[Update] Добавлен short_uuid в user_data: {short_uuid}")
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
else:
logger.error("[Update] Ошибка создания Remnawave клиента")
else:
logger.error("[Update] Не удалось авторизоваться в Remnawave")
if not remnawave_client_id:
logger.warning(f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}")
remnawave_client_id = client_id
tasks = []
for server_info in xui_servers:
server_name = server_info.get("server_name", "unknown")
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(server_info["api_url"])
sub_id = email
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
group_code = server_info.get("tariff_group")
if not group_code:
raise ValueError(f"У сервера {server_name} отсутствует tariff_group")
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
result.scalar_one_or_none()
total_gb_bytes = int(traffic_limit * 1024**3) if traffic_limit is not None else 0
device_limit_value = device_limit if device_limit is not None else 0
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=total_gb_bytes,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
tasks.append(add_client(xui, config))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}")
return remnawave_client_id, remnawave_key
except Exception as e:
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise
async def update_subscription(
tg_id: int,
email: str,
session: AsyncSession,
cluster_override: str = None,
country_override: str = None,
remnawave_link: str = None,
) -> None:
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email))
record = result.scalar_one_or_none()
if not record:
raise ValueError(f"The key {email} does not exist in database")
expiry_time = record.expiry_time
client_id = record.client_id
old_cluster_id = record.server_id
tariff_id = record.tariff_id
alias = record.alias
remnawave_link = remnawave_link or record.remnawave_link
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
traffic_limit = None
device_limit = None
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None
device_limit = int(tariff.device_limit) if tariff.device_limit is not None else 0
else:
logger.warning(f"[LOG] update_subscription: тариф с id={tariff_id} не найден!")
else:
logger.warning("[LOG] update_subscription: tariff_id отсутствует!")
await delete_key_from_cluster(old_cluster_id, email, client_id, session=session)
await session.execute(delete(Key).where(Key.tg_id == tg_id, Key.email == email))
await session.commit()
if country_override or cluster_override:
new_cluster_id = country_override or cluster_override
else:
try:
new_cluster_id = await get_least_loaded_cluster(session)
except ValueError:
logger.warning("[Update] Нет доступных кластеров, оставляем на старом")
new_cluster_id = old_cluster_id
new_client_id, remnawave_key = await update_key_on_cluster(
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_time=expiry_time,
cluster_id=new_cluster_id,
session=session,
traffic_limit=traffic_limit,
device_limit=device_limit,
remnawave_link=remnawave_link,
)
servers = await get_servers(session)
cluster_servers = servers.get(new_cluster_id)
if cluster_servers is None:
for server_list in servers.values():
for server_info in server_list:
if server_info.get("server_name", "").lower() == new_cluster_id.lower():
cluster_servers = [server_info]
break
if cluster_servers:
break
else:
cluster_servers = []
has_xui = any(s.get("panel_type", "").lower() == "3x-ui" for s in cluster_servers)
final_key_link = public_link if has_xui else None
await store_key(
session=session,
tg_id=tg_id,
client_id=new_client_id,
email=email,
expiry_time=expiry_time,
key=final_key_link,
remnawave_link=remnawave_key,
server_id=new_cluster_id,
tariff_id=tariff_id,
alias=alias,
)
@@ -33,7 +33,7 @@ from database import (
update_key_expiry,
update_key_tariff,
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.keys.operations import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import (
build_notification_expired_kb,
build_notification_kb,
@@ -22,7 +22,7 @@ from database import (
)
from database.tariffs import get_tariffs
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, TV_BUTTON
from handlers.keys.key_utils import get_user_traffic
from handlers.keys.operations import get_user_traffic
from handlers.notifications.notify_utils import send_messages_with_limit
from handlers.texts import (
TRIAL_INACTIVE_BONUS_MSG,
-1
View File
@@ -2,7 +2,6 @@ import aiohttp
import hashlib
import hmac
import time
import json
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
+49 -42
View File
@@ -156,54 +156,61 @@ async def process_custom_amount_button(callback_query: types.CallbackQuery, stat
await state.set_state(ReplenishBalanceWataState.entering_custom_amount)
@router.message(ReplenishBalanceWataState.entering_custom_amount)
async def handle_custom_amount_input(message: types.Message, state: FSMContext):
data = await state.get_data()
cassa_name = data.get("wata_cassa")
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name), None)
if not cassa or not cassa["enable"]:
await edit_or_send_message(
target_message=message,
text="Ошибка: выбранная касса недоступна.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
force_text=True,
)
return
async def handle_custom_amount_input(
callback_query: types.CallbackQuery,
state: FSMContext,
):
tg_id = callback_query.from_user.id
target_message = callback_query.message
try:
amount = int(message.text.strip())
if amount <= 0:
raise ValueError
if cassa_name == "sbp" and amount < 50:
data = await state.get_data()
required_amount = data.get("required_amount", 0)
cassa_name = data.get("wata_cassa", "sbp")
if not required_amount or required_amount <= 0:
await edit_or_send_message(
target_message=message,
text="Минимальная сумма для оплаты через СБП — 50 рублей.",
target_message=target_message,
text="❌ Недостаточная сумма для оплаты.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
force_text=True,
)
return
except Exception:
await edit_or_send_message(
target_message=message,
text="Некорректная сумма. Введите целое число больше 0.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
force_text=True,
cassa = next((c for c in WATA_CASSA_CONFIG if c["name"] == cassa_name and c["enable"]), None)
if not cassa:
await edit_or_send_message(
target_message=target_message,
text="❌ Выбранная касса WATA недоступна.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
payment_url = await generate_wata_payment_link(required_amount, tg_id, cassa)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="balance")],
]
)
message_text = f"💰 Вы выбрали пополнение на <b>{required_amount}₽</b>. Перейдите по ссылке для оплаты:"
await edit_or_send_message(
target_message=target_message,
text=message_text,
reply_markup=keyboard,
disable_web_page_preview=True,
)
await state.clear()
except Exception as e:
logger.error(f"[WATA] Ошибка при создании ссылки для оплаты: {e}", exc_info=True)
await edit_or_send_message(
target_message=target_message,
text="⚠️ Произошла ошибка при создании ссылки на оплату. Попробуйте позже.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
await state.update_data(amount=amount)
payment_url = await generate_wata_payment_link(amount, message.chat.id, cassa)
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="balance")],
]
)
await edit_or_send_message(
target_message=message,
text=WATA_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
force_text=True,
)
await state.set_state(ReplenishBalanceWataState.waiting_for_payment_confirmation)
@router.callback_query(F.data.startswith("wata_amount|"))
+22 -70
View File
@@ -1,9 +1,8 @@
import html
import os
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
@@ -29,12 +28,12 @@ from handlers.buttons import (
TRIAL_SUB,
)
from handlers.texts import ADD_SUBSCRIPTION_HINT
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .texts import profile_message_send
from .utils import edit_or_send_message
from .utils import edit_or_send_message, get_username
router = Router()
@@ -50,39 +49,25 @@ async def process_callback_view_profile(
):
if isinstance(callback_query_or_message, CallbackQuery):
chat = callback_query_or_message.message.chat
from_user = callback_query_or_message.from_user
chat_id = chat.id
target_message = callback_query_or_message.message
user = callback_query_or_message.from_user
message = callback_query_or_message.message
else:
chat = callback_query_or_message.chat
from_user = callback_query_or_message.from_user
chat_id = chat.id
target_message = callback_query_or_message
user = callback_query_or_message.from_user
message = callback_query_or_message
user = chat if chat.type == "private" else from_user
if getattr(user, "full_name", None):
username = html.escape(user.full_name)
elif getattr(user, "first_name", None):
username = html.escape(user.first_name)
elif getattr(user, "username", None):
username = "@" + html.escape(user.username)
else:
username = "Пользователь"
image_path = os.path.join("img", "profile.jpg")
chat_id = chat.id
username = get_username(user or chat)
key_count = await get_key_count(session, chat_id)
balance = await get_balance(session, chat_id) or 0
trial_status = await get_trial(session, chat_id)
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
if key_count == 0:
profile_message += ADD_SUBSCRIPTION_HINT
else:
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
profile_message += ADD_SUBSCRIPTION_HINT if key_count == 0 else f"\n<blockquote><i>{NEWS_MESSAGE}</i></blockquote>"
builder = InlineKeyboardBuilder()
if key_count > 0:
builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data="renew_menu"))
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
@@ -94,68 +79,35 @@ async def process_callback_view_profile(
if BALANCE_BUTTON:
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
row_buttons = []
extra_buttons = []
if REFERRAL_BUTTON:
row_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
extra_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
if GIFT_BUTTON:
row_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
if row_buttons:
builder.row(*row_buttons)
extra_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
if extra_buttons:
builder.row(*extra_buttons)
module_buttons = await run_hooks("profile_menu", chat_id=chat_id, admin=admin, session=session)
for module_btn in module_buttons:
if isinstance(module_btn, dict) and "after" in module_btn:
after_callback = module_btn["after"]
insert_pos = -1
current_markup = builder.as_markup()
for i, row in enumerate(current_markup.inline_keyboard):
for btn in row:
if btn.callback_data == after_callback:
insert_pos = i + 1
break
if insert_pos > 0:
break
if insert_pos > 0:
new_buttons = []
for i, row in enumerate(current_markup.inline_keyboard):
if i == insert_pos:
new_buttons.append([module_btn["button"]])
new_buttons.append(row)
if insert_pos >= len(current_markup.inline_keyboard):
new_buttons.append([module_btn["button"]])
builder = InlineKeyboardBuilder.from_markup(InlineKeyboardMarkup(inline_keyboard=new_buttons))
else:
builder.row(module_btn["button"])
else:
if isinstance(module_btn, dict):
builder.row(module_btn["button"])
else:
builder.row(module_btn)
builder = insert_hook_buttons(builder, module_buttons)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
if admin:
builder.row(
InlineKeyboardButton(
text="📊 Администратор",
callback_data=AdminPanelCallback(action="admin").pack(),
)
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
if SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
else:
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
await edit_or_send_message(
target_message=target_message,
target_message=message,
text=profile_message,
reply_markup=builder.as_markup(),
media_path=image_path,
media_path=os.path.join("img", "profile.jpg"),
disable_web_page_preview=False,
force_text=True,
)
+128 -237
View File
@@ -60,94 +60,41 @@ from .utils import edit_or_send_message
router = Router()
processing_gifts = set()
@router.callback_query(F.data == "start")
async def handle_start_callback_query(
callback_query: CallbackQuery,
state: FSMContext,
session: Any,
admin: bool,
captcha: bool = False,
):
await start_command(callback_query.message, state, session, admin, captcha)
@router.message(Command("start"))
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True):
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
@router.callback_query(F.data == "start")
async def start_entry(
event: Message | CallbackQuery, state: FSMContext, session: Any, admin: bool, captcha: bool = True
):
message = event.message if isinstance(event, CallbackQuery) else event
if CAPTCHA_ENABLE and captcha:
user_exists = await check_user_exists(session, message.chat.id)
if not user_exists:
if not await check_user_exists(session, message.chat.id):
captcha_data = await generate_captcha(message, state)
await edit_or_send_message(
target_message=message,
text=captcha_data["text"],
reply_markup=captcha_data["markup"],
)
await edit_or_send_message(message, captcha_data["text"], reply_markup=captcha_data["markup"])
return
state_data = await state.get_data()
text_to_process = state_data.get("original_text", message.text)
await process_start_logic(message, state, session, admin, text_to_process)
text = getattr(event, "data", None) or message.text
await process_start_logic(message, state, session, admin, text)
@router.callback_query(F.data == "check_subscription")
async def check_subscription_callback(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool):
user_id = callback_query.from_user.id
logger.info(f"[CALLBACK] Получен callback 'check_subscription' от пользователя {user_id}")
async def check_subscription_callback(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
user_id = callback.from_user.id
try:
member = await bot.get_chat_member(CHANNEL_ID, user_id)
logger.info(f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}")
if member.status not in ["member", "administrator", "creator"]:
await callback_query.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
builder.row(InlineKeyboardButton(text=SUB_CHANELL_DONE, callback_data="check_subscription"))
await callback_query.message.edit_text(
SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
await prompt_subscription(callback)
return
await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG)
await callback.answer(SUBSCRIPTION_CONFIRMED_MSG)
data = await state.get_data()
original_text = data.get("original_text") or callback_query.message.text
user_data = data.get("user_data")
if not user_data:
user = callback_query.from_user
user_data = {
"tg_id": user.id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"language_code": user.language_code,
"is_bot": user.is_bot,
}
await state.update_data(user_data=user_data)
if user_data.get("is_bot"):
logger.warning(f"[CALLBACK] Попытка регистрации бота: {user_data}")
return
await process_start_logic(
message=callback_query.message,
state=state,
session=session,
admin=admin,
text_to_process=original_text,
user_data=user_data,
)
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
original_text = data.get("original_text") or callback.message.text
user_data = data.get("user_data") or extract_user_data(callback.from_user)
await state.update_data(user_data=user_data)
await process_start_logic(callback.message, state, session, admin, original_text, user_data)
except Exception as e:
logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True)
await callback_query.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
logger.error(f"[CALLBACK] Ошибка подписки: {e}", exc_info=True)
await callback.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
async def process_start_logic(
@@ -158,222 +105,166 @@ async def process_start_logic(
text_to_process: str = None,
user_data: dict | None = None,
):
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),
}
user_data = user_data or extract_user_data(message.from_user or message.chat)
text = text_to_process or message.text or message.caption
if not text:
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}")
await show_start_menu(message, admin, session)
return
if text.startswith("/start "):
parts = text.split(maxsplit=1)
if len(parts) > 1:
text = parts[1]
text = text.split(maxsplit=1)[1]
try:
gift_detected = False
text_parts = text.split("-")
gift_detected = False
for part in text.split("-"):
await run_hooks("start_link", message=message, state=state, session=session, user_data=user_data, part=part)
for part in text_parts:
await run_hooks("start_link", message=message, state=state, session=session, user_data=user_data, part=part)
if "coupons" in part:
await handle_coupon_link(part, message, state, session, admin, user_data)
continue
if "gift" in part:
gift_detected = await handle_gift(part, message, state, session, user_data)
break
if "referral" in part:
await handle_referral_link_safe(part, message, state, session, user_data)
continue
if "utm" in part:
await handle_utm_link(part, message, state, session, user_data)
for part in text_parts:
if "coupons" in part:
logger.info(f"Обнаружена ссылка на купон: {part}")
coupon_code = part.split("coupons")[1].strip("_")
coupon = await get_coupon_by_code(session, coupon_code)
if not coupon:
continue
await state.clear()
if gift_detected:
return
await activate_coupon(
message,
state,
session,
coupon_code,
admin=admin,
user_data=user_data,
)
if not await check_user_exists(session, user_data["tg_id"]):
await add_user(session=session, **user_data)
if coupon.days:
return
continue
trial_status = await get_trial(session, user_data["tg_id"])
key_count = await get_key_count(session, user_data["tg_id"])
if "gift" in part:
gift_raw = part.split("gift")[1].strip("_")
if not gift_raw:
await message.answer("❌ Неверный формат ссылки на подарок.")
return await process_callback_view_profile(message, state, admin, session)
gift_id = gift_raw
if gift_id in processing_gifts:
await message.answer("⏳ Подарок уже обрабатывается, подождите...")
return await process_callback_view_profile(message, state, admin, session)
processing_gifts.add(gift_id)
try:
logger.info(f"[GIFT] Обнаружен подарок {gift_id}")
await handle_gift_link(gift_id, message, state, session, user_data=user_data)
gift_detected = True
finally:
processing_gifts.discard(gift_id)
break
if "referral" in part:
referrer_tg_id = part.split("referral")[1].strip("_")
try:
referrer_tg_id = int(referrer_tg_id)
await handle_referral_link(referrer_tg_id, message, state, session, user_data=user_data)
except (ValueError, IndexError):
pass
continue
if "utm" in part:
utm_code = part
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
user_exists = await check_user_exists(session, user_data["tg_id"])
if not user_exists:
await add_user(session=session, **user_data)
trial_status = await get_trial(session, user_data["tg_id"])
key_count = await get_key_count(session, user_data["tg_id"])
if SHOW_START_MENU_ONCE:
if key_count > 0:
await process_callback_view_profile(message, state, admin, session)
elif trial_status == 0:
await show_start_menu(message, admin, session)
else:
await process_callback_view_profile(message, state, admin, session)
if SHOW_START_MENU_ONCE:
if key_count > 0 or trial_status != 0:
await process_callback_view_profile(message, state, admin, session)
else:
await show_start_menu(message, admin, session)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при обработке текста {text}{e}", exc_info=True)
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
else:
await show_start_menu(message, admin, session)
async def handle_utm_link(
utm_code: str,
message: Message,
state: FSMContext,
session: AsyncSession,
user_data: dict,
):
async def handle_coupon_link(part, message, state, session, admin, user_data):
code = part.split("coupons")[1].strip("_")
coupon = await get_coupon_by_code(session, code)
if coupon:
await activate_coupon(message, state, session, code, admin=admin, user_data=user_data)
if coupon.days:
return
async def handle_gift(part, message, state, session, user_data):
gift_id = part.split("gift")[1].strip("_")
if not gift_id:
await message.answer("❌ Неверный формат ссылки на подарок.")
await process_callback_view_profile(message, state, False, session)
return False
if gift_id in processing_gifts:
await message.answer("⏳ Подарок уже обрабатывается, подождите...")
await process_callback_view_profile(message, state, False, session)
return False
processing_gifts.add(gift_id)
try:
await handle_gift_link(gift_id, message, state, session, user_data=user_data)
return True
finally:
processing_gifts.discard(gift_id)
async def handle_referral_link_safe(part, message, state, session, user_data):
try:
referrer_id = int(part.split("referral")[1].strip("_"))
await handle_referral_link(referrer_id, message, state, session, user_data)
except Exception:
pass
async def prompt_subscription(callback: CallbackQuery):
await callback.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
kb.row(InlineKeyboardButton(text=SUB_CHANELL_DONE, callback_data="check_subscription"))
await callback.message.edit_text(SUBSCRIPTION_REQUIRED_MSG, reply_markup=kb.as_markup())
def extract_user_data(user) -> dict:
return {
"tg_id": user.id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"language_code": user.language_code,
"is_bot": user.is_bot,
}
async def handle_utm_link(utm_code: str, message: Message, state: FSMContext, session: AsyncSession, user_data: dict):
user_id = user_data["tg_id"]
result = await session.execute(select(TrackingSource).where(TrackingSource.code == utm_code))
utm_exists = result.scalar_one_or_none()
if not utm_exists:
if not result.scalar_one_or_none():
await message.answer("❌ UTM ссылка не найдена.")
return
result = await session.execute(select(User).where(User.tg_id == user_id))
user = result.scalar_one_or_none()
if user and user.source_code is None:
user = (await session.execute(select(User).where(User.tg_id == user_id))).scalar_one_or_none()
if user and not user.source_code:
user.source_code = utm_code
await session.commit()
logger.info(f"[UTM] Привязана {utm_code} к пользователю {user_id}")
elif not user:
await add_user(session=session, source_code=utm_code, **user_data)
logger.info(f"[UTM] Зарегистрирован и привязан {utm_code} к пользователю {user_id}")
async def show_start_menu(message: Message, admin: bool, session: AsyncSession):
"""Функция для отображения стандартного меню через редактирование сообщения."""
image_path = os.path.join("img", "pic.jpg")
builder = InlineKeyboardBuilder()
kb = InlineKeyboardBuilder()
trial_status = None
if session is not None:
trial_status = await get_trial(session, message.chat.id)
else:
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
trial_status = await get_trial(session, message.chat.id) if session else None
show_trial = trial_status == 0 and not TRIAL_TIME_DISABLE
show_profile = not SHOW_START_MENU_ONCE or trial_status != 0 or TRIAL_TIME_DISABLE
show_trial_button = trial_status == 0 and not TRIAL_TIME_DISABLE
show_profile_button = not SHOW_START_MENU_ONCE or trial_status != 0 or TRIAL_TIME_DISABLE
if show_trial_button:
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
if show_profile_button:
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if show_trial:
kb.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
if show_profile:
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if CHANNEL_EXISTS:
builder.row(
kb.row(
InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL),
InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL),
)
else:
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
kb.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if admin:
builder.row(
InlineKeyboardButton(
text="📊 Администратор",
callback_data=AdminPanelCallback(action="admin").pack(),
)
)
kb.row(InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack()))
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
kb.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
await edit_or_send_message(
target_message=message,
text=WELCOME_TEXT,
reply_markup=builder.as_markup(),
media_path=image_path,
)
await edit_or_send_message(message, WELCOME_TEXT, reply_markup=kb.as_markup(), media_path=image_path)
@router.callback_query(F.data == "about_vpn")
async def handle_about_vpn(callback_query: CallbackQuery, session: AsyncSession):
user_id = callback_query.from_user.id
async def handle_about_vpn(callback: CallbackQuery, session: AsyncSession):
user_id = callback.from_user.id
trial = await get_trial(session, user_id)
back_target = "profile" if SHOW_START_MENU_ONCE and trial > 0 else "start"
builder = InlineKeyboardBuilder()
kb = InlineKeyboardBuilder()
if DONATIONS_ENABLE:
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
kb.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
support_btn = InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)
kb.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CHANNEL_EXISTS:
channel_btn = InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL)
builder.row(support_btn, channel_btn)
else:
builder.row(support_btn)
builder.row(InlineKeyboardButton(text=BACK, callback_data=back_target))
kb.row(InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL))
kb.row(InlineKeyboardButton(text=BACK, callback_data=back_target))
text = get_about_vpn("3.2.3-minor")
image_path = os.path.join("img", "pic.jpg")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=image_path,
force_text=False,
callback.message, text, reply_markup=kb.as_markup(), media_path=os.path.join("img", "pic.jpg"), force_text=False
)
+11
View File
@@ -1,3 +1,4 @@
import html
import os
import re
import secrets
@@ -292,3 +293,13 @@ def get_russian_month(date: datetime) -> str:
"""
english_month = date.strftime("%B")
return RUSSIAN_MONTHS.get(english_month, english_month)
def get_username(user) -> str:
if getattr(user, "full_name", None):
return html.escape(user.full_name)
if getattr(user, "first_name", None):
return html.escape(user.first_name)
if getattr(user, "username", None):
return "@" + html.escape(user.username)
return "Пользователь"
+31
View File
@@ -0,0 +1,31 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
def insert_hook_buttons(builder: InlineKeyboardBuilder, buttons: list) -> InlineKeyboardBuilder:
"""
Вставляет кнопки из хуков в существующий builder (вставка после указанной кнопки через `after`)
"""
markup = builder.as_markup()
new_rows = markup.inline_keyboard.copy()
for module in buttons:
if isinstance(module, dict) and "after" in module and "button" in module:
after = module["after"]
button = module["button"]
insert_pos = -1
for i, row in enumerate(new_rows):
if any(btn.callback_data == after for btn in row):
insert_pos = i + 1
break
if 0 <= insert_pos <= len(new_rows):
new_rows.insert(insert_pos, [button])
else:
new_rows.append([button])
else:
button = module.get("button") if isinstance(module, dict) else module
new_rows.append([button])
return InlineKeyboardBuilder.from_markup(InlineKeyboardMarkup(inline_keyboard=new_rows))
+6 -7
View File
@@ -24,9 +24,12 @@ def register_middleware(
sessionmaker=None,
) -> None:
"""Регистрирует middleware в диспетчере."""
direct_start_blocker = DirectStartBlockerMiddleware()
dispatcher.message.outer_middleware(direct_start_blocker)
dispatcher.callback_query.outer_middleware(direct_start_blocker)
dispatcher.update.outer_middleware(DirectStartBlockerMiddleware())
if sessionmaker:
dispatcher.update.outer_middleware(SubscriptionMiddleware())
dispatcher.update.outer_middleware(BanCheckerMiddleware(sessionmaker))
if middlewares is None:
available_middlewares = {
@@ -41,10 +44,6 @@ def register_middleware(
exclude_set = set(exclude or [])
middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set]
if sessionmaker:
dispatcher.update.outer_middleware(SubscriptionMiddleware())
dispatcher.update.outer_middleware(BanCheckerMiddleware(sessionmaker))
handlers = [
dispatcher.message,
dispatcher.callback_query,
+57 -19
View File
@@ -3,9 +3,11 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import Message, Update
from sqlalchemy import select
from config import DISABLE_DIRECT_START
from database import async_session_maker, check_user_exists
from database.models import Coupon, Gift, TrackingSource, User
from logger import logger
@@ -19,14 +21,13 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
event: Update,
data: dict[str, Any],
) -> Any:
logger.debug("[DirectStartBlocker] Middleware triggered")
if not DISABLE_DIRECT_START:
return await handler(event, data)
if not isinstance(event, Message):
return await handler(event, data)
message: Message = event
if not message.text:
message: Message | None = getattr(event, "message", None)
if not message or not message.text:
return await handler(event, data)
tg_id = message.from_user.id
@@ -35,23 +36,60 @@ class DirectStartBlockerMiddleware(BaseMiddleware):
async with async_session_maker() as session:
user_exists = await check_user_exists(session, tg_id)
if user_exists:
logger.debug(f"[DirectStartBlocker] Пользователь {tg_id} уже есть в базе — пропущен")
return await handler(event, data)
if user_exists:
logger.debug(f"[DirectStartBlocker] Пользователь {tg_id} уже есть в базе — пропущен")
return await handler(event, data)
parts = text.split(maxsplit=1)
parts = text.split(maxsplit=1)
if parts[0] == "/start":
if len(parts) == 1:
logger.info(f"[DirectStartBlocker] Прямой старт запрещён для нового пользователя {tg_id}")
return
if parts[0] != "/start":
return await handler(event, data)
start_param = parts[1].strip()
if not start_param or not start_param.startswith(self.allowed_prefixes):
logger.info(f"[DirectStartBlocker] Отклонена неизвестная ссылка от {tg_id}: {start_param!r}")
return
if len(parts) == 1:
logger.info(f"[DirectStartBlocker] Прямой старт запрещён для нового пользователя {tg_id}")
if start_param.startswith("coupons_"):
code = start_param.removeprefix("coupons_")
result = await session.execute(select(Coupon).where(Coupon.code == code))
if not result.scalar_one_or_none():
logger.info(f"[DirectStartBlocker] Купон не найден: {code!r}")
return
elif start_param.startswith("gift_"):
gift_id = start_param.removeprefix("gift_")
result = await session.execute(select(Gift).where(Gift.id == gift_id))
if not result.scalar_one_or_none():
logger.info(f"[DirectStartBlocker] Подарок не найден: {gift_id!r}")
return
elif start_param.startswith("referral_"):
try:
ref_id = int(start_param.removeprefix("referral_"))
result = await session.execute(select(User).where(User.tg_id == ref_id))
if not result.scalar_one_or_none():
logger.info(f"[DirectStartBlocker] Реферал не найден: {ref_id!r}")
return
except ValueError:
logger.info(f"[DirectStartBlocker] Неверный формат referral-ссылки: {start_param!r}")
return
elif start_param.startswith("utm"):
utm_code = start_param
result = await session.execute(select(TrackingSource).where(TrackingSource.code == utm_code))
if not result.scalar_one_or_none():
logger.info(f"[DirectStartBlocker] UTM не найден: {utm_code!r}")
return
logger.info(f"[DirectStartBlocker] Разрешённая и валидная ссылка от {tg_id}: {start_param!r}")
return await handler(event, data)
if text.startswith("/"):
logger.info(
f"[DirectStartBlocker] Команда '{text}' отклонена для незарегистрированного пользователя {tg_id}"
)
return
start_param = parts[1].strip()
if not start_param or not start_param.startswith(self.allowed_prefixes):
logger.info(f"[DirectStartBlocker] Отклонена неизвестная ссылка от {tg_id}: {start_param!r}")
return
logger.debug(f"[DirectStartBlocker] Разрешённая ссылка от {tg_id}: {start_param!r}")
return await handler(event, data)
+36 -12
View File
@@ -1,23 +1,47 @@
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message
from aiogram.types import CallbackQuery, Message, Update
from config import ADMIN_ID
from database import async_session_maker
from database.models import Admin
maintenance_mode = False
class MaintenanceModeMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
if maintenance_mode:
user_id = None
if isinstance(event, Message):
user_id = event.from_user.id
elif isinstance(event, CallbackQuery):
user_id = event.from_user.id
async def __call__(
self,
handler: Callable[[Update, dict[str, Any]], Awaitable[Any]],
event: Update,
data: dict[str, Any],
) -> Any:
if not maintenance_mode:
return await handler(event, data)
if user_id and user_id not in ADMIN_ID:
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
return
user_id = None
if isinstance(event, Message):
user_id = event.from_user.id
elif isinstance(event, CallbackQuery):
user_id = event.from_user.id
return await handler(event, data)
if not user_id:
return
if user_id in ADMIN_ID:
return await handler(event, data)
async with async_session_maker() as session:
db_admin = await session.get(Admin, user_id)
if db_admin:
return await handler(event, data)
if isinstance(event, CallbackQuery):
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.", show_alert=True)
elif isinstance(event, Message):
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
return
+32 -36
View File
@@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, Message, Update
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -15,9 +16,6 @@ from logger import logger
class SubscriptionMiddleware(BaseMiddleware):
def __init__(self) -> None:
pass
async def __call__(
self,
handler: Callable[[Update, dict[str, Any]], Awaitable[Any]],
@@ -29,13 +27,16 @@ class SubscriptionMiddleware(BaseMiddleware):
tg_id = None
message = None
from_user = None
if event.message:
tg_id = event.message.from_user.id
message = event.message
from_user = event.message.from_user
elif event.callback_query:
tg_id = event.callback_query.from_user.id
message = event.callback_query.message
from_user = event.callback_query.from_user
else:
return await handler(event, data)
@@ -43,44 +44,39 @@ class SubscriptionMiddleware(BaseMiddleware):
member = await bot.get_chat_member(CHANNEL_ID, tg_id)
if member.status not in ("member", "administrator", "creator"):
logger.info(f"[SubMiddleware] Пользователь {tg_id} не подписан")
state: FSMContext = data.get("state")
if state:
original_text = message.text or message.caption
if event.message:
from_user = event.message.from_user
elif event.callback_query:
from_user = event.callback_query.from_user
else:
logger.warning(f"[SubMiddleware] Не удалось определить пользователя из события: {event}")
return await handler(event, data)
if from_user.is_bot:
logger.warning(f"[SubMiddleware] Пропуск сохранения is_bot=True для {from_user.id}")
else:
state_data = await state.get_data()
if "original_text" not in state_data or "user_data" not in state_data:
user_data = {
"tg_id": from_user.id,
"username": from_user.username,
"first_name": from_user.first_name,
"last_name": from_user.last_name,
"language_code": from_user.language_code,
"is_bot": from_user.is_bot,
}
await state.update_data(
original_text=original_text,
user_data=user_data,
)
await self._store_user_state(data, message, from_user)
return await self._ask_to_subscribe(message)
except Exception as e:
logger.warning(f"[SubMiddleware] Ошибка при проверке подписки для {tg_id}: {e}")
except (TelegramBadRequest, TelegramForbiddenError) as e:
logger.warning(f"[SubMiddleware] Ошибка при проверке подписки {tg_id}: {e}")
await self._store_user_state(data, message, from_user)
return await self._ask_to_subscribe(message)
return await handler(event, data)
async def _store_user_state(self, data: dict, message: Message, from_user):
state: FSMContext = data.get("state")
if not state or not from_user or from_user.is_bot:
return
state_data = await state.get_data()
if "original_text" in state_data and "user_data" in state_data:
return
original_text = message.text or message.caption
user_data = {
"tg_id": from_user.id,
"username": from_user.username,
"first_name": from_user.first_name,
"last_name": from_user.last_name,
"language_code": from_user.language_code,
"is_bot": from_user.is_bot,
}
await state.update_data(
original_text=original_text,
user_data=user_data,
)
async def _ask_to_subscribe(self, message: Message):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
+27 -18
View File
@@ -4,17 +4,23 @@ aiogram==3.13.1
aiohappyeyeballs==2.4.3
aiohttp==3.10.10
aiosignal==1.3.1
alembic==1.16.4
annotated-types==0.7.0
anyio==4.8.0
APScheduler==3.11.0
async-timeout==4.0.3
asyncpg==0.30.0
attrs==24.2.0
babel==2.17.0
cachetools==5.5.1
certifi==2023.11.17
cffi==1.17.1
charset-normalizer==3.4.0
deprecated==1.2.14
click==8.2.2
cryptography==45.0.5
Deprecated==1.2.14
distro==1.9.0
fastapi==0.116.1
frozenlist==1.4.1
greenlet==3.1.1
h11==0.14.0
@@ -23,35 +29,38 @@ httpx==0.27.2
idna==3.10
loguru==0.7.3
magic-filter==1.0.12
Mako==1.3.10
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
multidict==6.1.0
netaddr==1.3.0
pillow==11.3.0
ping3==4.0.8
propcache==0.2.0
psutil==7.0.0
psycopg2-binary==2.9.10
py3xui==0.3.4
pycparser==2.22
pydantic==2.9.2
pydantic-core==2.23.4
pydantic_core==2.23.4
Pygments==2.19.2
python-dateutil==2.9.0.post0
pytz==2025.1
qrcode==8.2
requests==2.32.3
rich==14.1.0
robokassa==0.3.2
ruff==0.9.5
six==1.17.0
sniffio==1.3.1
sqlalchemy==2.0.38
strenum==0.4.15
typing-extensions==4.12.2
SQLAlchemy==2.0.38
starlette==0.47.2
StrEnum==0.4.15
typing_extensions==4.12.2
tzlocal==5.3.1
urllib3==2.2.3
uvicorn==0.35.0
wrapt==1.16.0
yarl==1.15.5
yookassa==3.3.0
httpx
qrcode
pillow
rich
apscheduler
alembic
psycopg2-binary
psutil
uvicorn
fastapi
python-dateutil
aiohttp>=3.8.0
cryptography>=41.0.0
-2
View File
@@ -1,7 +1,5 @@
from aiohttp.web_urldispatcher import UrlDispatcher
import bot
from config import TBLOCKER_WEBHOOK_PATH
from .heleket_payment import heleket_payment_webhook
-1
View File
@@ -1,5 +1,4 @@
import hashlib
import json
from aiohttp import web