Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26d334653 | |||
| ef1cf1fe26 | |||
| 1356170fb0 | |||
| 998cf9a6e1 | |||
| 85a6be831e | |||
| 9df5d8469d | |||
| 3133cc7799 | |||
| 7b894300b7 | |||
| 2621d29ef0 | |||
| 76ad4e5821 | |||
| 0056454375 | |||
| 3c15589b03 | |||
| 4849c899c2 | |||
| 44b9986a45 | |||
| a9359e9dc0 | |||
| 76e730475d | |||
| 18d3b4246a | |||
| 22121b9171 | |||
| ff498feb7e | |||
| 15fcc0ac70 | |||
| b1c73dc1a6 | |||
| 888812babd | |||
| 023cd1728d | |||
| abbd3474b4 | |||
| fa17faa424 | |||
| 799c657605 | |||
| fa875fb3bc | |||
| cc628a08fa | |||
| fa85fb70f1 | |||
| 1d5980d725 | |||
| 9474ccfb06 | |||
| c6e91c0543 | |||
| 593206f6a0 | |||
| ea6f5297c1 | |||
| 471714b80d | |||
| a92a19b84d | |||
| 289e0ae5b5 | |||
| d08b410888 | |||
| 455455d696 | |||
| 57ad26711d | |||
| 24428a030e | |||
| c42fee4298 | |||
| aba9620c0f | |||
| dd8c87f887 | |||
| eebc2ee1d5 | |||
| 7bd5ecc55f | |||
| 5d25e211df | |||
| 0887e28b2b | |||
| e04be209cb | |||
| d19eee8897 | |||
| 3efdeed00c | |||
| 9e89a2db10 | |||
| 59a23ea9bd | |||
| 216c8f8d81 | |||
| d0f6511c5c | |||
| e1ecb6a1dc | |||
| 99c02874ec | |||
| 0cd896d35e | |||
| ab35ce9178 | |||
| f71e6eb226 | |||
| cd3b55271a | |||
| e71de5f3cf | |||
| 0fc2c535de | |||
| d9533e673e |
@@ -1,6 +1,6 @@
|
||||
# 🚀 SoloBot
|
||||
|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||
**SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
|
||||
|
||||
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
@@ -7,15 +8,28 @@ CREATE TABLE IF NOT EXISTS users
|
||||
language_code TEXT,
|
||||
is_bot BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
balance REAL NOT NULL DEFAULT 0.0,
|
||||
trial INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
balance REAL NOT NULL DEFAULT 0.0,
|
||||
trial INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'balance'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN balance REAL NOT NULL DEFAULT 0.0;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'trial'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN trial INTEGER NOT NULL DEFAULT 0;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments
|
||||
(
|
||||
@@ -44,6 +58,21 @@ CREATE TABLE IF NOT EXISTS keys
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'key' AND is_nullable = 'NO'
|
||||
) THEN
|
||||
ALTER TABLE keys ALTER COLUMN key DROP NOT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'remnawave_link'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN remnawave_link TEXT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'is_frozen'
|
||||
@@ -59,7 +88,6 @@ BEGIN
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals
|
||||
(
|
||||
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
@@ -110,34 +138,76 @@ CREATE TABLE IF NOT EXISTS servers
|
||||
cluster_name TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
subscription_url TEXT NOT NULL,
|
||||
subscription_url TEXT,
|
||||
inbound_id TEXT NOT NULL,
|
||||
panel_type TEXT NOT NULL DEFAULT '3x-ui',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
max_keys INTEGER,
|
||||
UNIQUE (cluster_name, server_name)
|
||||
);
|
||||
|
||||
ALTER TABLE servers ADD COLUMN IF NOT EXISTS panel_type TEXT NOT NULL DEFAULT '3x-ui';
|
||||
ALTER TABLE servers
|
||||
ALTER COLUMN subscription_url DROP NOT NULL;
|
||||
ALTER TABLE servers
|
||||
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE servers ADD COLUMN IF NOT EXISTS max_keys INTEGER;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gifts
|
||||
(
|
||||
gift_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_tg_id BIGINT NOT NULL,
|
||||
gift_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_tg_id BIGINT NOT NULL,
|
||||
selected_months INTEGER NOT NULL,
|
||||
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
gift_link TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
gift_link TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recipient_tg_id BIGINT,
|
||||
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
|
||||
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temporary_data (
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_users (
|
||||
tg_id BIGINT PRIMARY KEY,
|
||||
blocked_at TIMESTAMP DEFAULT NOW()
|
||||
tg_id BIGINT PRIMARY KEY,
|
||||
blocked_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracking_sources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by BIGINT,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS source_code TEXT REFERENCES tracking_sources (code);
|
||||
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'connections'
|
||||
) THEN
|
||||
EXECUTE $upd$
|
||||
UPDATE users
|
||||
SET balance = c.balance,
|
||||
trial = c.trial
|
||||
FROM connections c
|
||||
WHERE users.tg_id = c.tg_id;
|
||||
$upd$;
|
||||
|
||||
EXECUTE 'DROP TABLE connections';
|
||||
END IF;
|
||||
END$$;
|
||||
@@ -19,7 +19,7 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
version = "4.1-Release"
|
||||
version = "4.2"
|
||||
|
||||
register_middleware(dp)
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
import re
|
||||
|
||||
from config import BOT_SERVICE
|
||||
|
||||
|
||||
try:
|
||||
sys.stdin.reconfigure(encoding='utf-8')
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stdin.reconfigure(encoding="utf-8")
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -20,24 +23,41 @@ if not os.environ.get("LC_ALL", "").endswith("UTF-8"):
|
||||
os.environ["LC_ALL"] = "en_US.UTF-8"
|
||||
os.environ["LANG"] = "en_US.UTF-8"
|
||||
|
||||
console = Console()
|
||||
|
||||
BACK_DIR = os.path.expanduser("~/.solobot_backup")
|
||||
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
|
||||
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
IS_ROOT_DIR = PROJECT_DIR == '/root'
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
|
||||
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
|
||||
console.print("[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]")
|
||||
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
|
||||
sys.exit(1)
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
SERVICE_NAME = BOT_SERVICE
|
||||
|
||||
console = Console()
|
||||
|
||||
def is_service_exists(service_name):
|
||||
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
|
||||
return service_name in result.stdout
|
||||
|
||||
|
||||
def print_logo():
|
||||
logo = Text("""
|
||||
logo = Text(
|
||||
"""
|
||||
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
|
||||
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
|
||||
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
|
||||
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
|
||||
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
|
||||
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
|
||||
""", style="bold cyan")
|
||||
""",
|
||||
style="bold cyan",
|
||||
)
|
||||
console.print(logo)
|
||||
|
||||
|
||||
@@ -49,12 +69,61 @@ def backup_project():
|
||||
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
|
||||
|
||||
|
||||
def fix_permissions():
|
||||
"""Устанавливает корректные права на файлы проекта"""
|
||||
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
|
||||
try:
|
||||
user = os.getenv('SUDO_USER') or os.getenv('USER')
|
||||
if user:
|
||||
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
|
||||
|
||||
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
|
||||
|
||||
console.print("[green]✅ Права успешно установлены[/green]")
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
|
||||
|
||||
|
||||
def install_rsync_if_needed():
|
||||
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
|
||||
console.print("[blue]📦 Установка rsync...[/blue]")
|
||||
os.system("sudo apt update && sudo apt install -y rsync")
|
||||
|
||||
|
||||
def clean_project_dir_safe(update_buttons=False):
|
||||
console.print("[yellow]🧹 Очистка проекта перед обновлением...[/yellow]")
|
||||
preserved_paths = {
|
||||
os.path.join(PROJECT_DIR, "config.py"),
|
||||
os.path.join(PROJECT_DIR, "handlers", "buttons.py"),
|
||||
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
|
||||
os.path.join(PROJECT_DIR, "img"),
|
||||
}
|
||||
|
||||
if not update_buttons:
|
||||
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
|
||||
|
||||
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
|
||||
for file in files:
|
||||
path = os.path.join(root, file)
|
||||
if path in preserved_paths:
|
||||
continue
|
||||
try:
|
||||
os.remove(path)
|
||||
except PermissionError:
|
||||
subprocess.run(["sudo", "rm", "-f", path])
|
||||
except Exception as e:
|
||||
console.print(f"[red]Не удалось удалить файл: {path}: {e}[/red]")
|
||||
|
||||
for dir in dirs:
|
||||
dir_path = os.path.join(root, dir)
|
||||
if os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "handlers"):
|
||||
continue
|
||||
try:
|
||||
os.rmdir(dir_path)
|
||||
except Exception:
|
||||
subprocess.run(["sudo", "rm", "-rf", dir_path])
|
||||
|
||||
|
||||
def install_git_if_needed():
|
||||
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
|
||||
console.print("[blue]Установка Git...[/blue]")
|
||||
@@ -65,22 +134,31 @@ def install_dependencies():
|
||||
console.print("[blue]🔧 Установка зависимостей...[/blue]")
|
||||
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
|
||||
try:
|
||||
subprocess.run("bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True)
|
||||
if not os.path.exists("venv"):
|
||||
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
|
||||
subprocess.run("python3 -m venv venv", shell=True, check=True)
|
||||
|
||||
subprocess.run(
|
||||
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
|
||||
|
||||
|
||||
def restart_service():
|
||||
console.print("[blue]🚀 Перезапуск службы...[/blue]")
|
||||
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
|
||||
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
console.print("[blue]🚀 Перезапуск службы...[/blue]")
|
||||
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
|
||||
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
|
||||
|
||||
def get_local_version():
|
||||
path = os.path.join(PROJECT_DIR, "bot.py")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
|
||||
if match:
|
||||
@@ -103,6 +181,7 @@ def get_remote_version(branch="main"):
|
||||
|
||||
|
||||
def update_from_beta():
|
||||
update_cli_launcher()
|
||||
local_version = get_local_version()
|
||||
remote_version = get_remote_version(branch="dev")
|
||||
|
||||
@@ -116,39 +195,58 @@ def update_from_beta():
|
||||
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
return
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
install_rsync_if_needed()
|
||||
|
||||
os.chdir(PROJECT_DIR)
|
||||
|
||||
console.print("[cyan]📥 Клонируем репозиторий dev во временную папку...[/cyan]")
|
||||
console.print("[cyan]📅 Клонируем временный репозиторий...[/cyan]")
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
|
||||
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
|
||||
return
|
||||
|
||||
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
|
||||
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||
clean_project_dir_safe(update_buttons=update_buttons)
|
||||
|
||||
exclude_options = "--exclude=img"
|
||||
if not update_buttons:
|
||||
exclude_options += " --exclude=handlers/buttons.py"
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
fix_permissions()
|
||||
install_dependencies()
|
||||
restart_service()
|
||||
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
|
||||
|
||||
|
||||
def update_from_release():
|
||||
update_cli_launcher()
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
|
||||
return
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
|
||||
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
|
||||
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
install_rsync_if_needed()
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10
|
||||
)
|
||||
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
|
||||
releases = response.json()[:3]
|
||||
tag_choices = [r["tag_name"] for r in releases]
|
||||
|
||||
@@ -160,18 +258,30 @@ def update_from_release():
|
||||
console.print(f"[cyan]{idx}.[/cyan] {tag}")
|
||||
|
||||
selected = Prompt.ask(
|
||||
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
|
||||
)
|
||||
tag_name = tag_choices[int(selected) - 1]
|
||||
|
||||
if not Confirm.ask(f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"):
|
||||
return
|
||||
|
||||
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
subprocess.run(f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", shell=True, check=True)
|
||||
|
||||
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
|
||||
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
|
||||
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||
clean_project_dir_safe(update_buttons=update_buttons)
|
||||
|
||||
exclude_options = "--exclude=img"
|
||||
if not update_buttons:
|
||||
exclude_options += " --exclude=handlers/buttons.py"
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
fix_permissions()
|
||||
install_dependencies()
|
||||
restart_service()
|
||||
console.print(f"[green]✅ Обновление до релиза {tag_name} завершено.[/green]")
|
||||
@@ -181,6 +291,12 @@ def update_from_release():
|
||||
|
||||
|
||||
def show_update_menu():
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
|
||||
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Выберите способ обновления", title_style="bold green")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Источник", style="white")
|
||||
@@ -198,11 +314,9 @@ def show_update_menu():
|
||||
|
||||
|
||||
def show_menu():
|
||||
table = Table(title="Solobot CLI", title_style="bold magenta", header_style="bold blue")
|
||||
|
||||
table = Table(title="Solobot CLI v0.1.7", title_style="bold magenta", header_style="bold blue")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Операция", style="white")
|
||||
|
||||
table.add_row("1", "Запустить бота (systemd)")
|
||||
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
|
||||
table.add_row("3", "Перезапустить бота (systemd)")
|
||||
@@ -210,45 +324,78 @@ def show_menu():
|
||||
table.add_row("5", "Показать логи (80 строк)")
|
||||
table.add_row("6", "Показать статус")
|
||||
table.add_row("7", "Обновить Solobot")
|
||||
table.add_row("8", "Выход")
|
||||
|
||||
table.add_row("8", "Обновить CLI лаунчер")
|
||||
table.add_row("9", "Выход")
|
||||
console.print(table)
|
||||
|
||||
|
||||
def update_cli_launcher():
|
||||
"""Обновляет CLI лаунчер с dev ветки"""
|
||||
console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]")
|
||||
try:
|
||||
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), 'w', encoding='utf-8') as f:
|
||||
f.write(response.text)
|
||||
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
|
||||
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
|
||||
else:
|
||||
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
console.print("[bold red]⛔ Требуется запуск от имени root или через sudo.[/bold red]")
|
||||
sys.exit(1)
|
||||
|
||||
os.chdir(PROJECT_DIR)
|
||||
|
||||
os.chdir(PROJECT_DIR)
|
||||
print_logo()
|
||||
|
||||
while True:
|
||||
show_menu()
|
||||
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 9)])
|
||||
|
||||
if choice == "1":
|
||||
os.system(f"sudo systemctl start {SERVICE_NAME}")
|
||||
elif choice == "2":
|
||||
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
|
||||
os.system("sudo venv/bin/python main.py")
|
||||
elif choice == "3":
|
||||
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
|
||||
os.system(f"sudo systemctl restart {SERVICE_NAME}")
|
||||
elif choice == "4":
|
||||
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
|
||||
os.system(f"sudo systemctl stop {SERVICE_NAME}")
|
||||
elif choice == "5":
|
||||
os.system(f"sudo journalctl -u {SERVICE_NAME} -n 80 --no-pager")
|
||||
elif choice == "6":
|
||||
os.system(f"sudo systemctl status {SERVICE_NAME}")
|
||||
elif choice == "7":
|
||||
show_update_menu()
|
||||
elif choice == "8":
|
||||
console.print("[bold cyan] Выход из CLI. Удачного дня![/bold cyan]")
|
||||
break
|
||||
|
||||
try:
|
||||
while True:
|
||||
show_menu()
|
||||
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 10)])
|
||||
if choice == "1":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "2":
|
||||
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
|
||||
subprocess.run(["venv/bin/python", "main.py"])
|
||||
elif choice == "3":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
|
||||
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "4":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
|
||||
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "5":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "journalctl", "-u", SERVICE_NAME, "-n", "80", "--no-pager"])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "6":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "systemctl", "status", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "7":
|
||||
show_update_menu()
|
||||
elif choice == "8":
|
||||
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
|
||||
update_cli_launcher()
|
||||
elif choice == "9":
|
||||
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
|
||||
update_cli_launcher()
|
||||
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,6 +12,7 @@ from .notifications import router as notifications_router
|
||||
from .pay import router as pay_router
|
||||
from .payments import router as payments_router
|
||||
from .profile import router as profile_router
|
||||
from .refferal import router as refferal_router
|
||||
from .start import router as start_router
|
||||
|
||||
|
||||
@@ -29,4 +30,5 @@ router.include_routers(
|
||||
keys_router,
|
||||
instructions_router,
|
||||
admin_router,
|
||||
refferal_router,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ __all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .ads import router as ads_router
|
||||
from .backups import router as backups_router
|
||||
from .bans import router as bans_router
|
||||
from .clusters import router as clusters_router
|
||||
@@ -29,4 +30,5 @@ router.include_routers(
|
||||
coupons_router,
|
||||
restart_router,
|
||||
bans_router,
|
||||
ads_router,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .ads_handler import router
|
||||
@@ -0,0 +1,155 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from config import USERNAME_BOT
|
||||
from database import (
|
||||
create_tracking_source,
|
||||
get_all_tracking_sources,
|
||||
get_tracking_source_stats,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import (
|
||||
AdminAdsCallback,
|
||||
build_ads_delete_confirm_kb,
|
||||
build_ads_kb,
|
||||
build_ads_list_kb,
|
||||
build_ads_stats_kb,
|
||||
build_cancel_input_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminAdsState(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
waiting_for_new_code = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
|
||||
async def handle_ads_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
|
||||
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminAdsState.waiting_for_new_name)
|
||||
await callback_query.message.edit_text(
|
||||
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
|
||||
async def handle_ads_name_input(message: Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
await state.update_data(name=name)
|
||||
await state.set_state(AdminAdsState.waiting_for_new_code)
|
||||
await message.answer(
|
||||
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.", reply_markup=build_cancel_input_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
|
||||
async def handle_ads_code_input(message: Message, state: FSMContext, session):
|
||||
code = message.text.strip()
|
||||
data = await state.get_data()
|
||||
name = data["name"]
|
||||
code_with_prefix = f"utm_{code}"
|
||||
|
||||
try:
|
||||
await create_tracking_source(
|
||||
name=name, code=code_with_prefix, type_="utm", created_by=message.from_user.id, session=session
|
||||
)
|
||||
stats = await get_tracking_source_stats(code_with_prefix, session)
|
||||
if not stats:
|
||||
await message.answer("❌ Источник не найден или не содержит данных.")
|
||||
return
|
||||
msg = format_ads_stats(stats, USERNAME_BOT)
|
||||
await message.answer(
|
||||
text=msg,
|
||||
reply_markup=build_ads_stats_kb(code_with_prefix),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ссылки: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании ссылки.")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
|
||||
async def handle_ads_list(callback_query: CallbackQuery, session):
|
||||
try:
|
||||
ads = await get_all_tracking_sources(session)
|
||||
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
|
||||
await callback_query.message.edit_text(
|
||||
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка UTM: {e}")
|
||||
await callback_query.message.edit_text("Произошла ошибка при получении списка.")
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
|
||||
async def handle_ads_view(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
|
||||
code = callback_data.code
|
||||
try:
|
||||
stats = await get_tracking_source_stats(code, session)
|
||||
if not stats:
|
||||
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
|
||||
return
|
||||
msg = format_ads_stats(stats, USERNAME_BOT)
|
||||
await callback_query.message.edit_text(
|
||||
text=msg, reply_markup=build_ads_stats_kb(code), parse_mode=ParseMode.HTML
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при просмотре статистики: {e}")
|
||||
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
|
||||
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
|
||||
code = callback_data.code
|
||||
await callback_query.message.edit_text(
|
||||
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
|
||||
reply_markup=build_ads_delete_confirm_kb(code),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
|
||||
async def handle_ads_delete(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
|
||||
code = callback_data.code
|
||||
try:
|
||||
await session.execute("UPDATE users SET source_code = NULL WHERE source_code = $1", code)
|
||||
await session.execute("DELETE FROM tracking_sources WHERE code = $1", code)
|
||||
await callback_query.message.edit_text(f"🗑️ Ссылка <code>{code}</code> удалена.", reply_markup=build_ads_kb())
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
|
||||
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
|
||||
|
||||
|
||||
def format_ads_stats(stats: dict, username_bot: str) -> str:
|
||||
return (
|
||||
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
|
||||
f"📌 <b>Название:</b> {stats['name']}\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
|
||||
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💡 <b>Активность:</b>\n"
|
||||
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
|
||||
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n"
|
||||
f"\n💰 <b>Финансовая информация:</b>\n"
|
||||
f"└ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n\n"
|
||||
f"<i>Просмотр статистики и управление рекламными ссылками</i>."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
|
||||
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
|
||||
@@ -0,0 +1,77 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
|
||||
|
||||
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
|
||||
action: str
|
||||
code: str | None = None
|
||||
|
||||
|
||||
def build_ads_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="➕ Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
|
||||
builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for ad in ads:
|
||||
builder.button(
|
||||
text=f"📎 {ad['name']}",
|
||||
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
|
||||
)
|
||||
|
||||
pagination_buttons = []
|
||||
if current_page > 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
|
||||
)
|
||||
)
|
||||
if current_page < total_pages:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
builder.row(build_admin_back_btn("ads"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🗑️ Удалить", callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack())
|
||||
builder.row(build_admin_back_btn("ads"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=AdminAdsCallback(
|
||||
action="delete",
|
||||
code=code,
|
||||
).pack(),
|
||||
)
|
||||
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="view", code=code).pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_cancel_input_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="cancel_input", code="none").pack())
|
||||
return builder.as_markup()
|
||||
@@ -12,11 +12,25 @@ from aiogram.types import CallbackQuery, Message
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from backup import create_backup_and_send_to_admins
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, TOTAL_GB, USE_COUNTRY_SELECTION
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
DATABASE_URL,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
TOTAL_GB,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import check_unique_server_name, get_servers, update_key_expiry
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster, renew_key_in_cluster
|
||||
from handlers.keys.key_utils import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import (
|
||||
@@ -25,6 +39,7 @@ from .keyboard import (
|
||||
build_cluster_management_kb,
|
||||
build_clusters_editor_kb,
|
||||
build_manage_cluster_kb,
|
||||
build_panel_type_kb,
|
||||
build_sync_cluster_kb,
|
||||
)
|
||||
|
||||
@@ -54,9 +69,10 @@ async def handle_servers(callback_query: CallbackQuery):
|
||||
|
||||
text = (
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
|
||||
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
|
||||
"<blockquote>"
|
||||
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
|
||||
"</blockquote>\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
|
||||
)
|
||||
|
||||
@@ -143,9 +159,11 @@ async def handle_server_name_input(message: Message, state: FSMContext, session:
|
||||
|
||||
text = (
|
||||
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Ссылку можно найти в поисковой строке браузера, при входе в 3X-UI.\n\n"
|
||||
"ℹ️ Формат API URL:\n"
|
||||
"<code>https://your_domain:port/panel_path/</code>"
|
||||
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
|
||||
"ℹ️ <b>Формат для 3X-UI:</b>\n"
|
||||
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
|
||||
"ℹ️ <b>Формат для Remnawave:</b>\n"
|
||||
"<code>https://your-domain.com/api</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
@@ -158,58 +176,38 @@ async def handle_server_name_input(message: Message, state: FSMContext, session:
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
|
||||
async def handle_api_url_input(message: Message, state: FSMContext):
|
||||
if not message.text or not message.text.strip().startswith("https://"):
|
||||
await message.answer(
|
||||
text="❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
api_url = message.text.strip().rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
|
||||
await state.update_data(api_url=api_url)
|
||||
|
||||
text = (
|
||||
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Ссылку можно найти в панели 3X-UI, в информации о клиенте.\n\n"
|
||||
"ℹ️ Формат Subscription URL:\n"
|
||||
"<code>https://your_domain:port_sub/sub_path/</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
"Если вы используете Remnawave — введите <code>0</code>\n\n"
|
||||
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
|
||||
async def handle_subscription_url_input(message: Message, state: FSMContext):
|
||||
if not message.text or not message.text.strip().startswith("https://"):
|
||||
await message.answer(
|
||||
text="❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
subscription_url = message.text.strip().rstrip("/")
|
||||
raw = message.text.strip()
|
||||
subscription_url = None if raw == "0" else raw.rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
|
||||
await state.update_data(subscription_url=subscription_url)
|
||||
|
||||
text = (
|
||||
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
text=f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
f"Для Remnawave это UUID Инбаунда, для 3x-ui — просто ID (например, <code>1</code>).",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
|
||||
@@ -218,39 +216,50 @@ async def handle_subscription_url_input(message: Message, state: FSMContext):
|
||||
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
|
||||
async def handle_inbound_id_input(message: Message, state: FSMContext):
|
||||
inbound_id = message.text.strip()
|
||||
await state.update_data(inbound_id=inbound_id)
|
||||
|
||||
if not inbound_id.isdigit():
|
||||
await message.answer(
|
||||
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
await message.answer(
|
||||
text=(
|
||||
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
|
||||
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
|
||||
"Поддержка режима выбора стран — <b>ограничена</b>."
|
||||
),
|
||||
reply_markup=build_panel_type_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])), IsAdminFilter())
|
||||
async def handle_panel_type_selection(
|
||||
callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext
|
||||
):
|
||||
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
api_url = user_data.get("api_url")
|
||||
subscription_url = user_data.get("subscription_url")
|
||||
inbound_id = user_data.get("inbound_id")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id, panel_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
cluster_name,
|
||||
server_name,
|
||||
api_url,
|
||||
subscription_url,
|
||||
inbound_id,
|
||||
panel_type,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@@ -260,11 +269,20 @@ async def handle_clusters_manage(
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔧 Управление кластером {cluster_name}</b>",
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
|
||||
async def handle_cluster_servers(callback: CallbackQuery):
|
||||
cluster_name = callback.data.split("|", 1)[1]
|
||||
servers = await get_servers()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=f"<b>📡 Серверы в кластере {cluster_name}</b>",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
@@ -281,30 +299,67 @@ async def handle_cluster_availability(
|
||||
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
|
||||
return
|
||||
|
||||
text = (
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
|
||||
"Это может занять до 1 минуты, пожалуйста, подождите..."
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
|
||||
"Это может занять до 1 минуты, пожалуйста, подождите..."
|
||||
)
|
||||
)
|
||||
await callback_query.message.edit_text(text=text)
|
||||
|
||||
total_online_users = 0
|
||||
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
|
||||
server_name = server["server_name"]
|
||||
panel_type = server.get("panel_type", "3x-ui").lower()
|
||||
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
|
||||
|
||||
try:
|
||||
await xui.login()
|
||||
online_clients = await xui.client.online()
|
||||
inbound_id = int(server["inbound_id"])
|
||||
online_inbound_users = 0
|
||||
for client_email in online_clients:
|
||||
client = await xui.client.get_by_email(client_email)
|
||||
if client and client.inbound_id == inbound_id:
|
||||
online_inbound_users += 1
|
||||
total_online_users += online_inbound_users
|
||||
result_text += f"🌍 <b>{server['server_name']}</b> - {online_inbound_users} онлайн\n"
|
||||
if panel_type == "3x-ui":
|
||||
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
|
||||
await xui.login()
|
||||
inbound_id = int(server["inbound_id"])
|
||||
online_clients = await xui.client.online()
|
||||
online_inbound_users = 0
|
||||
|
||||
for client_email in online_clients:
|
||||
client = await xui.client.get_by_email(client_email)
|
||||
if client and client.inbound_id == inbound_id:
|
||||
online_inbound_users += 1
|
||||
|
||||
total_online_users += online_inbound_users
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
|
||||
|
||||
elif panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise Exception("Не удалось авторизоваться")
|
||||
|
||||
server_inbound_id = server.get("inbound_id")
|
||||
if not server_inbound_id:
|
||||
raise Exception("Не указан inbound_id сервера")
|
||||
|
||||
all_nodes = await remna.get_all_nodes()
|
||||
if not all_nodes:
|
||||
raise Exception("Не удалось получить список нод")
|
||||
|
||||
matching_node = None
|
||||
for node in all_nodes:
|
||||
excluded_inbounds = node.get("excludedInbounds", [])
|
||||
if server_inbound_id not in excluded_inbounds:
|
||||
matching_node = node
|
||||
break
|
||||
|
||||
if not matching_node:
|
||||
raise Exception("Нода, обслуживающая этот inbound_id, не найдена")
|
||||
|
||||
online_remna_users = matching_node.get("usersOnline", 0)
|
||||
total_online_users += online_remna_users
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
|
||||
|
||||
except Exception as e:
|
||||
result_text += f"❌ <b>{server['server_name']}</b> - ошибка: {str(e) if str(e).strip() else 'Сервер недоступен'}\n"
|
||||
error_text = str(e) or "Сервер недоступен"
|
||||
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
|
||||
|
||||
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
|
||||
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
|
||||
@@ -414,10 +469,10 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
|
||||
|
||||
try:
|
||||
query_keys = """
|
||||
SELECT tg_id, client_id, email, expiry_time
|
||||
FROM keys
|
||||
WHERE server_id = $1
|
||||
"""
|
||||
SELECT tg_id, client_id, email, expiry_time, remnawave_link
|
||||
FROM keys
|
||||
WHERE server_id = $1
|
||||
"""
|
||||
keys_to_sync = await session.fetch(query_keys, cluster_name)
|
||||
|
||||
if not keys_to_sync:
|
||||
@@ -427,33 +482,42 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
|
||||
)
|
||||
return
|
||||
|
||||
text = f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"])
|
||||
|
||||
await session.execute(
|
||||
"DELETE FROM keys WHERE tg_id = $1 AND client_id = $2", key["tg_id"], key["client_id"]
|
||||
)
|
||||
|
||||
await create_key_on_cluster(
|
||||
cluster_name,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
session=session,
|
||||
remnawave_link=key.get("remnawave_link"),
|
||||
)
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}")
|
||||
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в {cluster_name}: {e}")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters")
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@@ -477,16 +541,6 @@ async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminS
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "manage_cluster"), IsAdminFilter())
|
||||
async def handle_manage_cluster_menu(callback_query: CallbackQuery, callback_data: AdminClusterCallback):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🛠 Управление кластером {cluster_name}</b>\nВыберите действие:",
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
|
||||
async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
|
||||
cluster_name = callback_data.data
|
||||
@@ -511,6 +565,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
|
||||
|
||||
now = int(time.time() * 1000)
|
||||
add_ms = days * 86400 * 1000
|
||||
total_gb = int((days / 30) * TOTAL_GB * 1024**3)
|
||||
|
||||
keys = await session.fetch(
|
||||
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
|
||||
@@ -529,7 +584,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=TOTAL_GB,
|
||||
total_gb=total_gb,
|
||||
)
|
||||
await update_key_expiry(key["client_id"], new_expiry, session)
|
||||
|
||||
@@ -588,8 +643,7 @@ async def handle_new_cluster_name_input(message: Message, state: FSMContext, ses
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
existing_cluster = await conn.fetchval(
|
||||
"SELECT cluster_name FROM servers WHERE cluster_name = $1 LIMIT 1",
|
||||
new_cluster_name
|
||||
"SELECT cluster_name FROM servers WHERE cluster_name = $1 LIMIT 1", new_cluster_name
|
||||
)
|
||||
if existing_cluster:
|
||||
await message.answer(
|
||||
@@ -598,23 +652,16 @@ async def handle_new_cluster_name_input(message: Message, state: FSMContext, ses
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
|
||||
old_cluster_name
|
||||
)
|
||||
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_cluster_name)
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2",
|
||||
new_cluster_name,
|
||||
old_cluster_name
|
||||
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2", new_cluster_name, old_cluster_name
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
|
||||
new_cluster_name,
|
||||
old_cluster_name
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
@@ -695,7 +742,7 @@ async def handle_new_server_name_input(message: Message, state: FSMContext, sess
|
||||
existing_server = await conn.fetchval(
|
||||
"SELECT server_name FROM servers WHERE cluster_name = $1 AND server_name = $2 LIMIT 1",
|
||||
cluster_name,
|
||||
new_server_name
|
||||
new_server_name,
|
||||
)
|
||||
if existing_server:
|
||||
await message.answer(
|
||||
@@ -704,24 +751,19 @@ async def handle_new_server_name_input(message: Message, state: FSMContext, sess
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
|
||||
old_server_name
|
||||
)
|
||||
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_server_name)
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"UPDATE servers SET server_name = $1 WHERE cluster_name = $2 AND server_name = $3",
|
||||
new_server_name,
|
||||
cluster_name,
|
||||
old_server_name
|
||||
old_server_name,
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
|
||||
new_server_name,
|
||||
old_server_name
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name
|
||||
)
|
||||
|
||||
final_text = f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!"
|
||||
@@ -753,20 +795,14 @@ async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContex
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
|
||||
new_server_name,
|
||||
old_server_name
|
||||
)
|
||||
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name)
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
|
||||
cluster_name,
|
||||
old_server_name
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
|
||||
)
|
||||
|
||||
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
|
||||
sync_reminder = "\n\n⚠️ Не забудьте сделать \"Синхронизацию\"."
|
||||
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
|
||||
final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
@@ -797,21 +833,13 @@ async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMConte
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_server_name)
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
|
||||
new_cluster_name,
|
||||
old_server_name
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
|
||||
new_cluster_name,
|
||||
old_cluster_name
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
|
||||
cluster_name,
|
||||
old_server_name
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
from ..servers.keyboard import AdminServerCallback
|
||||
|
||||
|
||||
@@ -55,13 +53,6 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🛠 Управление",
|
||||
callback_data=AdminClusterCallback(action="manage_cluster", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -69,6 +60,12 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📡 Серверы",
|
||||
callback_data=f"cluster_servers|{cluster_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность",
|
||||
@@ -99,12 +96,7 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -130,3 +122,11 @@ def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKey
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_panel_type_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
|
||||
builder.button(text="🌀 Remnawave", callback_data=AdminClusterCallback(action="panel_remnawave").pack())
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from datetime import datetime
|
||||
import html
|
||||
import pytz
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -18,19 +20,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import INLINE_MODE, USERNAME_BOT
|
||||
from database import (
|
||||
add_connection,
|
||||
check_connection_exists,
|
||||
create_coupon,
|
||||
create_coupon_usage,
|
||||
delete_coupon,
|
||||
get_all_coupons,
|
||||
get_keys,
|
||||
update_key_expiry,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
from handlers.keys.key_utils import renew_key_in_cluster
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.utils import format_days
|
||||
from logger import logger
|
||||
|
||||
@@ -45,7 +40,6 @@ class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_type = State()
|
||||
waiting_for_balance_data = State()
|
||||
waiting_for_days_data = State()
|
||||
waiting_for_key_selection = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -225,18 +219,15 @@ async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: Adm
|
||||
coupon_code = callback_data.coupon_code
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
|
||||
text="✅ Да, удалить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
|
||||
)
|
||||
kb.button(
|
||||
text="❌ Нет, отменить",
|
||||
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
|
||||
text="❌ Нет, отменить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
|
||||
)
|
||||
kb.adjust(1)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?",
|
||||
reply_markup=kb.as_markup()
|
||||
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?", reply_markup=kb.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@@ -250,15 +241,13 @@ async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: Ad
|
||||
result = await delete_coupon(coupon_code, session)
|
||||
if not result:
|
||||
await callback_query.message.edit_text(
|
||||
f"❌ Купон с кодом {coupon_code} не найден.",
|
||||
reply_markup=build_admin_back_kb("coupons")
|
||||
f"❌ Купон с кодом {coupon_code} не найден.", reply_markup=build_admin_back_kb("coupons")
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"Произошла ошибка при удалении купона.",
|
||||
reply_markup=build_admin_back_kb("coupons")
|
||||
"Произошла ошибка при удалении купона.", reply_markup=build_admin_back_kb("coupons")
|
||||
)
|
||||
return
|
||||
|
||||
@@ -303,7 +292,11 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
return
|
||||
|
||||
title = f"Купон {coupon['code']}"
|
||||
description = f"Получи {coupon['amount']} рублей!" if coupon["amount"] > 0 else f"Продли подписку на {format_days(coupon['days'])}!"
|
||||
description = (
|
||||
f"Получи {coupon['amount']} рублей!"
|
||||
if coupon["amount"] > 0
|
||||
else f"Продли подписку на {format_days(coupon['days'])}!"
|
||||
)
|
||||
message_text = (
|
||||
f"🎫 <b>Купон:</b> {coupon['code']}\n"
|
||||
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
|
||||
@@ -317,173 +310,8 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
id=coupon_code,
|
||||
title=title,
|
||||
description=description,
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text,
|
||||
parse_mode=ParseMode.HTML
|
||||
),
|
||||
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await inline_query.answer(
|
||||
results=[result],
|
||||
cache_time=86400,
|
||||
is_personal=True
|
||||
)
|
||||
|
||||
@router.message(F.text.regexp(r"^/start coupons_(.+)$"))
|
||||
async def handle_coupon_activation(
|
||||
message: Message, state: FSMContext, session: Any, admin: bool = False, text: str = None, user_id: int = None
|
||||
):
|
||||
coupon_text = text if text is not None else message.text
|
||||
logger.info(f"Текст купона в handle_coupon_activation: {coupon_text}")
|
||||
coupon_code = coupon_text.split("coupons_")[1]
|
||||
|
||||
coupons = await get_all_coupons(session, page=1, per_page=10)
|
||||
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
|
||||
|
||||
if not coupon:
|
||||
await message.answer("❌ Купон не найден.")
|
||||
return
|
||||
|
||||
if coupon["usage_count"] >= coupon["usage_limit"] or coupon["is_used"]:
|
||||
await message.answer("❌ Лимит активаций купона исчерпан.")
|
||||
return
|
||||
|
||||
effective_user_id = user_id if user_id is not None else message.from_user.id
|
||||
|
||||
usage = await session.fetchrow(
|
||||
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
|
||||
coupon["id"],
|
||||
effective_user_id
|
||||
)
|
||||
if usage:
|
||||
await message.answer("❌ Вы уже активировали этот купон.")
|
||||
return
|
||||
|
||||
connection_exists = await check_connection_exists(effective_user_id)
|
||||
if not connection_exists:
|
||||
await add_connection(tg_id=effective_user_id, session=session)
|
||||
|
||||
if coupon["amount"] > 0:
|
||||
await session.execute(
|
||||
"UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
|
||||
coupon["amount"],
|
||||
effective_user_id
|
||||
)
|
||||
await session.execute(
|
||||
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
|
||||
coupon["usage_count"] + 1 >= coupon["usage_limit"],
|
||||
coupon["id"]
|
||||
)
|
||||
await create_coupon_usage(coupon["id"], effective_user_id, session)
|
||||
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon['amount']} рублей.")
|
||||
await process_callback_view_profile(message, state, admin)
|
||||
return
|
||||
|
||||
if coupon["days"] is not None and coupon["days"] > 0:
|
||||
keys = await get_keys(effective_user_id, session)
|
||||
active_keys = [k for k in keys if not k["is_frozen"]]
|
||||
|
||||
if not active_keys:
|
||||
await message.answer("❌ У вас нет активных подписок для продления.")
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
|
||||
|
||||
for key in active_keys:
|
||||
alias = key.get("alias")
|
||||
email = key["email"]
|
||||
client_id = key["client_id"]
|
||||
expiry_time = key.get("expiry_time")
|
||||
|
||||
key_display = html.escape(alias.strip() if alias else email)
|
||||
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
|
||||
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
|
||||
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon['id']}")
|
||||
|
||||
response_message += "</blockquote>"
|
||||
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
|
||||
builder.adjust(1)
|
||||
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_key_selection)
|
||||
await state.update_data(coupon_id=coupon["id"], user_id=effective_user_id)
|
||||
return
|
||||
|
||||
await message.answer("❌ Купон недействителен (нет суммы или дней).")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("extend_key|"))
|
||||
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
|
||||
parts = callback_query.data.split("|")
|
||||
client_id = parts[1]
|
||||
coupon_id = int(parts[2])
|
||||
|
||||
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
|
||||
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
|
||||
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
usage = await session.fetchrow(
|
||||
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
|
||||
coupon_id,
|
||||
callback_query.from_user.id
|
||||
)
|
||||
if usage:
|
||||
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
key = await session.fetchrow(
|
||||
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2",
|
||||
callback_query.from_user.id,
|
||||
client_id
|
||||
)
|
||||
if not key or key["is_frozen"]:
|
||||
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
now_ms = int(datetime.now().timestamp() * 1000)
|
||||
current_expiry = key["expiry_time"]
|
||||
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
|
||||
|
||||
try:
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=key["server_id"],
|
||||
email=key["email"],
|
||||
client_id=client_id,
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=0
|
||||
)
|
||||
await update_key_expiry(client_id, new_expiry, session)
|
||||
|
||||
await session.execute(
|
||||
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
|
||||
coupon["usage_count"] + 1 >= coupon["usage_limit"],
|
||||
coupon["id"]
|
||||
)
|
||||
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
|
||||
|
||||
alias = key.get("alias") or key["email"]
|
||||
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M")
|
||||
text = f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
|
||||
|
||||
await callback_query.message.answer(text)
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при продлении ключа: {e}")
|
||||
await callback_query.message.edit_text("❌ Произошла ошибка при продлении подписки.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_coupon_activation")
|
||||
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
|
||||
await callback_query.message.answer("⚠️ Активация купона отменена.")
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await state.clear()
|
||||
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
|
||||
|
||||
@@ -12,7 +12,7 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
|
||||
coupon_code: str
|
||||
confirm: Optional[bool] = None
|
||||
confirm: bool | None = None
|
||||
|
||||
|
||||
def build_coupons_kb() -> InlineKeyboardMarkup:
|
||||
@@ -59,7 +59,11 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
|
||||
def format_coupons_list(coupons: list, username_bot: str) -> str:
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
for coupon in coupons:
|
||||
value_text = f"💰 <b>Сумма:</b> {coupon['amount']} рублей" if coupon["amount"] > 0 else f"⏳ <b>{format_days(coupon['days'])}</b>"
|
||||
value_text = (
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
|
||||
if coupon["amount"] > 0
|
||||
else f"⏳ <b>{format_days(coupon['days'])}</b>"
|
||||
)
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"{value_text}\n"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from middlewares import maintenance
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
@@ -11,6 +13,9 @@ def build_management_kb() -> InlineKeyboardMarkup:
|
||||
builder.button(text="🔄 Перезагрузить бота", callback_data=AdminPanelCallback(action="restart").pack())
|
||||
builder.button(text="🌐 Сменить домен", callback_data=AdminPanelCallback(action="change_domain").pack())
|
||||
builder.button(text="🔑 Восстановить пробники", callback_data=AdminPanelCallback(action="restore_trials").pack())
|
||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
|
||||
builder.button(text=maintenance_text, callback_data=AdminPanelCallback(action="toggle_maintenance").pack())
|
||||
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -9,6 +9,7 @@ from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import AdminPanelCallback, build_management_kb
|
||||
from middlewares import maintenance
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -73,3 +74,14 @@ async def process_new_domain(message: Message, state: FSMContext, session: Conne
|
||||
|
||||
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
|
||||
async def toggle_maintenance_mode(callback: CallbackQuery):
|
||||
|
||||
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
||||
|
||||
new_status = "включён" if maintenance.maintenance_mode else "выключен"
|
||||
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=build_management_kb())
|
||||
@@ -23,15 +23,18 @@ def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
builder.button(text="🔑 Поиск по названию ключа", callback_data=AdminPanelCallback(action="search_key").pack())
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()),
|
||||
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
|
||||
)
|
||||
builder.button(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack())
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
|
||||
InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
|
||||
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
|
||||
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
|
||||
)
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1, 1, 2, 1, 2, 1)
|
||||
builder.adjust(1, 1, 2, 2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot import version
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from .keyboard import AdminPanelCallback, build_panel_kb
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ def build_sender_kb() -> InlineKeyboardMarkup:
|
||||
text="📍 Не использовавшие триал", callback_data=AdminSenderCallback(type="untrial").pack()
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔥 Горячие лиды", callback_data=AdminSenderCallback(type="hotleads").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Кластер", callback_data=AdminSenderCallback(type="cluster-select").pack())
|
||||
)
|
||||
|
||||
@@ -77,43 +77,56 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
|
||||
state_data = await state.get_data()
|
||||
send_to = state_data.get("type", "all")
|
||||
|
||||
now = int(datetime.utcnow().timestamp() * 1000)
|
||||
|
||||
if send_to == "subscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT c.tg_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN keys k ON u.tg_id = k.tg_id
|
||||
WHERE k.expiry_time > $1
|
||||
""",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
now,
|
||||
)
|
||||
elif send_to == "unsubscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT c.tg_id
|
||||
FROM connections c
|
||||
LEFT JOIN keys k ON c.tg_id = k.tg_id
|
||||
GROUP BY c.tg_id
|
||||
SELECT u.tg_id
|
||||
FROM users u
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
GROUP BY u.tg_id
|
||||
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
|
||||
""",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
now,
|
||||
)
|
||||
elif send_to == "untrial":
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections WHERE trial = 0")
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users WHERE tg_id NOT IN (SELECT tg_id FROM keys)")
|
||||
elif send_to == "cluster":
|
||||
cluster_name = state_data.get("cluster_name")
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT c.tg_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN keys k ON u.tg_id = k.tg_id
|
||||
JOIN servers s ON k.server_id = s.cluster_name
|
||||
WHERE s.cluster_name = $1
|
||||
""",
|
||||
cluster_name,
|
||||
)
|
||||
elif send_to == "hotleads":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN payments p ON u.tg_id = p.tg_id
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
WHERE p.status = 'success'
|
||||
AND k.tg_id IS NULL
|
||||
"""
|
||||
)
|
||||
else:
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users")
|
||||
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
|
||||
@@ -10,12 +10,27 @@ class AdminServerCallback(CallbackData, prefix="admin_server"):
|
||||
data: str
|
||||
|
||||
|
||||
def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
|
||||
from ..clusters.keyboard import AdminClusterCallback
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
|
||||
toggle_action = "disable" if enabled else "enable"
|
||||
|
||||
builder.button(text=toggle_text, callback_data=AdminServerCallback(action=toggle_action, data=server_name).pack())
|
||||
|
||||
builder.button(
|
||||
text="📈 Задать лимит", callback_data=AdminServerCallback(action="set_limit", data=server_name).pack()
|
||||
)
|
||||
|
||||
builder.button(text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack())
|
||||
builder.button(text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack())
|
||||
|
||||
builder.button(
|
||||
text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack()
|
||||
)
|
||||
|
||||
builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -2,12 +2,13 @@ from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from database import delete_server, get_servers
|
||||
from database import get_servers
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
@@ -19,10 +20,14 @@ from .keyboard import (
|
||||
router = Router()
|
||||
|
||||
|
||||
class ServerLimitState(StatesGroup):
|
||||
waiting_for_limit = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_server_manage(callback_query: CallbackQuery, callback_data: AdminServerCallback):
|
||||
server_name = callback_data.data
|
||||
servers = await get_servers()
|
||||
servers = await get_servers(include_enabled=True)
|
||||
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
@@ -32,17 +37,20 @@ async def handle_server_manage(callback_query: CallbackQuery, callback_data: Adm
|
||||
api_url = server["api_url"]
|
||||
subscription_url = server["subscription_url"]
|
||||
inbound_id = server["inbound_id"]
|
||||
max_keys = server.get("max_keys")
|
||||
limit_display = f"{max_keys}" if max_keys else "не задан"
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {api_url}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {inbound_id}"
|
||||
f"<b>🔑 Inbound ID:</b> {inbound_id}\n"
|
||||
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name),
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(text="❌ Сервер не найден.")
|
||||
@@ -73,10 +81,7 @@ async def process_callback_delete_server(
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
|
||||
server_name
|
||||
)
|
||||
keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", server_name)
|
||||
|
||||
if keys_count > 0:
|
||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||
@@ -87,7 +92,7 @@ async def process_callback_delete_server(
|
||||
FROM servers
|
||||
WHERE server_name != $1
|
||||
""",
|
||||
server_name
|
||||
server_name,
|
||||
)
|
||||
|
||||
if all_servers:
|
||||
@@ -96,13 +101,12 @@ async def process_callback_delete_server(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{server['server_name']} ({server['key_count']})",
|
||||
callback_data=f"transfer_to_server|{server['server_name']}|{server_name}"
|
||||
callback_data=f"transfer_to_server|{server['server_name']}|{server_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -114,22 +118,16 @@ async def process_callback_delete_server(
|
||||
return
|
||||
|
||||
remaining_servers = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM servers WHERE cluster_name = $1 AND server_name != $2",
|
||||
cluster_name,
|
||||
server_name
|
||||
"SELECT COUNT(*) FROM servers WHERE cluster_name = $1 AND server_name != $2", cluster_name, server_name
|
||||
)
|
||||
|
||||
if remaining_servers == 0:
|
||||
other_clusters = await session.fetch(
|
||||
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1",
|
||||
cluster_name
|
||||
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1", cluster_name
|
||||
)
|
||||
|
||||
if other_clusters:
|
||||
cluster_keys_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
|
||||
cluster_name
|
||||
)
|
||||
cluster_keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", cluster_name)
|
||||
|
||||
if cluster_keys_count > 0:
|
||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||
@@ -141,7 +139,7 @@ async def process_callback_delete_server(
|
||||
WHERE cluster_name != $1
|
||||
GROUP BY cluster_name
|
||||
""",
|
||||
cluster_name
|
||||
cluster_name,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -149,13 +147,12 @@ async def process_callback_delete_server(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{cluster['cluster_name']} ({cluster['key_count']})",
|
||||
callback_data=f"transfer_to_cluster|{cluster['cluster_name']}|{cluster_name}|{server_name}"
|
||||
callback_data=f"transfer_to_cluster|{cluster['cluster_name']}|{cluster_name}|{server_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -167,9 +164,7 @@ async def process_callback_delete_server(
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
|
||||
cluster_name,
|
||||
server_name
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
|
||||
@@ -177,11 +172,98 @@ async def process_callback_delete_server(
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
|
||||
cluster_name,
|
||||
server_name
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер '{server_name}' удален.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter())
|
||||
async def toggle_server_enabled(callback_query: CallbackQuery, callback_data: AdminServerCallback, session: Any):
|
||||
server_name = callback_data.data
|
||||
action = callback_data.action
|
||||
|
||||
new_status = action == "enable"
|
||||
|
||||
await session.execute("UPDATE servers SET enabled = $1 WHERE server_name = $2", new_status, server_name)
|
||||
|
||||
servers = await get_servers(include_enabled=True)
|
||||
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
)
|
||||
|
||||
if not server:
|
||||
await callback_query.message.edit_text("❌ Сервер не найден.")
|
||||
return
|
||||
|
||||
max_keys = server.get("max_keys")
|
||||
limit_display = f"{max_keys}" if max_keys else "не задан"
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {server['api_url']}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
|
||||
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=new_status),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter())
|
||||
async def ask_server_limit(callback: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
server_name = callback_data.data
|
||||
await state.set_state(ServerLimitState.waiting_for_limit)
|
||||
await state.update_data(server_name=server_name)
|
||||
await callback.message.edit_text(
|
||||
f"Введите лимит ключей для сервера <b>{server_name}</b> (целое число, 0 — без лимита):",
|
||||
)
|
||||
|
||||
|
||||
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
|
||||
async def save_server_limit(message: types.Message, state: FSMContext, session: Any):
|
||||
try:
|
||||
limit = int(message.text.strip())
|
||||
if limit < 0:
|
||||
raise ValueError
|
||||
|
||||
data = await state.get_data()
|
||||
server_name = data["server_name"]
|
||||
|
||||
new_value = limit if limit > 0 else None
|
||||
await session.execute("UPDATE servers SET max_keys = $1 WHERE server_name = $2", new_value, server_name)
|
||||
|
||||
servers = await get_servers(include_enabled=True)
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
)
|
||||
|
||||
if not server:
|
||||
await message.answer("❌ Сервер не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
max_keys = server.get("max_keys")
|
||||
limit_display = f"{max_keys}" if max_keys is not None else "не задан"
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {server['api_url']}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
|
||||
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text, reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True))
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -13,14 +14,15 @@ from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import PUBLIC_LINK, RENEWAL_PRICES, TOTAL_GB
|
||||
from config import RENEWAL_PRICES, TOTAL_GB, USE_COUNTRY_SELECTION
|
||||
from database import (
|
||||
delete_key,
|
||||
delete_user_data,
|
||||
get_balance,
|
||||
get_client_id_by_email,
|
||||
get_key_details,
|
||||
get_servers,
|
||||
store_key,
|
||||
set_user_balance,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
update_trial,
|
||||
@@ -48,7 +50,6 @@ from .keyboard import (
|
||||
build_key_edit_kb,
|
||||
build_user_delete_kb,
|
||||
build_user_edit_kb,
|
||||
build_user_key_kb,
|
||||
build_users_balance_change_kb,
|
||||
build_users_balance_kb,
|
||||
build_users_key_expiry_kb,
|
||||
@@ -62,15 +63,14 @@ router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
# search
|
||||
waiting_for_user_data = State()
|
||||
waiting_for_key_name = State()
|
||||
# updating data
|
||||
waiting_for_balance = State()
|
||||
waiting_for_expiry_time = State()
|
||||
waiting_for_message_text = State()
|
||||
selecting_cluster = State()
|
||||
selecting_duration = State()
|
||||
selecting_country = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -99,41 +99,6 @@ async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text(text="🔑 Введите имя ключа для поиска:", reply_markup=build_admin_back_kb())
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
|
||||
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
|
||||
kb = build_admin_back_kb()
|
||||
|
||||
if message.forward_from:
|
||||
tg_id = message.forward_from.id
|
||||
await process_user_search(message, state, session, tg_id)
|
||||
return
|
||||
|
||||
if not message.text:
|
||||
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||
return
|
||||
|
||||
if message.text.isdigit():
|
||||
tg_id = int(message.text)
|
||||
else:
|
||||
# Удаление '@' символа в начале сообщения
|
||||
username = message.text.strip().lstrip("@")
|
||||
# Удаление начала ссылки на профиль
|
||||
username = username.replace("https://t.me/", "")
|
||||
|
||||
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
|
||||
|
||||
if not user:
|
||||
await message.answer(
|
||||
text="🚫 Пользователь с указанным Username не найден!",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
tg_id = user["tg_id"]
|
||||
|
||||
await process_user_search(message, state, session, tg_id)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
|
||||
async def handle_key_name_input(message: Message, state: FSMContext, session: Any):
|
||||
kb = build_admin_back_kb()
|
||||
@@ -152,6 +117,39 @@ async def handle_key_name_input(message: Message, state: FSMContext, session: An
|
||||
await process_user_search(message, state, session, key_details["tg_id"])
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
|
||||
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
|
||||
kb = build_admin_back_kb()
|
||||
|
||||
if message.forward_from:
|
||||
tg_id = message.forward_from.id
|
||||
await process_user_search(message, state, session, tg_id)
|
||||
return
|
||||
|
||||
if not message.text:
|
||||
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||
return
|
||||
|
||||
if message.text.isdigit():
|
||||
tg_id = int(message.text)
|
||||
else:
|
||||
username = message.text.strip().lstrip("@")
|
||||
username = username.replace("https://t.me/", "")
|
||||
|
||||
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
|
||||
|
||||
if not user:
|
||||
await message.answer(
|
||||
text="🚫 Пользователь с указанным Username не найден!",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
tg_id = user["tg_id"]
|
||||
|
||||
await process_user_search(message, state, session, tg_id)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminUserEditorCallback.filter(F.action == "users_send_message"),
|
||||
IsAdminFilter(),
|
||||
@@ -246,8 +244,15 @@ async def handle_balance_add(
|
||||
tg_id = callback_data.tg_id
|
||||
amount = callback_data.data
|
||||
|
||||
if amount:
|
||||
await update_balance(tg_id, int(amount), session, is_admin=True)
|
||||
if amount is not None:
|
||||
amount = int(amount)
|
||||
if amount >= 0:
|
||||
await update_balance(tg_id, amount, session, is_admin=True)
|
||||
else:
|
||||
current_balance = await get_balance(tg_id)
|
||||
new_balance = max(0, current_balance + amount)
|
||||
await set_user_balance(tg_id, new_balance, session)
|
||||
|
||||
await handle_balance_change(callback_query, callback_data, session)
|
||||
return
|
||||
|
||||
@@ -304,8 +309,11 @@ async def handle_balance_input(message: Message, state: FSMContext, session: Any
|
||||
text = f"✅ К балансу пользователя добавлено <b>{amount}Р</b>"
|
||||
await update_balance(tg_id, amount, session)
|
||||
elif op_type == "take":
|
||||
text = f"✅ Из баланса пользователя было вычтено <b>{amount}Р</b>"
|
||||
await update_balance(tg_id, -amount, session)
|
||||
current_balance = await get_balance(tg_id)
|
||||
new_balance = max(0, current_balance - amount)
|
||||
deducted = current_balance if amount > current_balance else amount
|
||||
text = f"✅ Из баланса пользователя было вычтено <b>{deducted}Р</b>"
|
||||
await set_user_balance(tg_id, new_balance, session)
|
||||
else:
|
||||
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
|
||||
await set_user_balance(tg_id, amount, session)
|
||||
@@ -330,9 +338,11 @@ async def handle_key_edit(
|
||||
)
|
||||
return
|
||||
|
||||
key_value = key_details.get("key") or key_details.get("remnawave_link") or "—"
|
||||
|
||||
text = (
|
||||
f"<b>🔑 Информация о ключе</b>"
|
||||
f"\n\n<code>{key_details['key']}</code>"
|
||||
f"\n\n<code>{key_value}</code>"
|
||||
f"\n\n⏰ Дата истечения: <b>{key_details['expiry_date']} (UTC)</b>"
|
||||
f"\n🌐 Кластер: <b>{key_details['cluster_name']}</b>"
|
||||
f"\n🆔 ID клиента: <b>{key_details['tg_id']}</b>"
|
||||
@@ -603,24 +613,24 @@ async def process_user_search(
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
balance = await session.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
|
||||
|
||||
if balance is None:
|
||||
user_data = await session.fetchrow(
|
||||
"SELECT username, balance, created_at, updated_at FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
if not user_data:
|
||||
await message.answer(
|
||||
text="🚫 Пользователь с указанным ID не найден!",
|
||||
reply_markup=build_admin_back_kb(),
|
||||
)
|
||||
return
|
||||
|
||||
balance = int(balance)
|
||||
|
||||
user_data = await session.fetchrow("SELECT username, created_at, updated_at FROM users WHERE tg_id = $1", tg_id)
|
||||
username = await session.fetchval("SELECT username FROM users WHERE tg_id = $1", tg_id)
|
||||
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
|
||||
balance = int(user_data["balance"] or 0)
|
||||
username = user_data["username"]
|
||||
created_at = user_data["created_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
|
||||
updated_at = user_data["updated_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
|
||||
|
||||
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
|
||||
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
|
||||
|
||||
text = (
|
||||
f"<b>📊 Информация о пользователе</b>"
|
||||
f"\n\n🆔 ID: <b>{tg_id}</b>"
|
||||
@@ -642,34 +652,6 @@ async def process_user_search(
|
||||
await message.answer(text=text, reply_markup=kb)
|
||||
|
||||
|
||||
async def get_key_details(email, session):
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.client_id, k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
|
||||
FROM keys k
|
||||
JOIN connections c ON k.tg_id = c.tg_id
|
||||
WHERE k.email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
|
||||
if not record:
|
||||
return None
|
||||
|
||||
cluster_name = record["server_id"]
|
||||
expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
|
||||
|
||||
return {
|
||||
"client_id": record["client_id"],
|
||||
"balance": record["balance"],
|
||||
"tg_id": record["tg_id"],
|
||||
"key": record["key"],
|
||||
"cluster_name": cluster_name,
|
||||
"expiry_time": record["expiry_time"],
|
||||
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
|
||||
}
|
||||
|
||||
|
||||
async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exception | None:
|
||||
client_id = await get_client_id_by_email(email)
|
||||
|
||||
@@ -682,6 +664,8 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
|
||||
return ValueError(f"User with client_id {server_id} was not found")
|
||||
|
||||
clusters = await get_servers()
|
||||
added_days = max((expiry_time - int(time.time() * 1000)) / (1000 * 86400), 1)
|
||||
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
|
||||
|
||||
async def update_key_on_all_servers():
|
||||
tasks = [
|
||||
@@ -691,7 +675,7 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
|
||||
email,
|
||||
client_id,
|
||||
expiry_time,
|
||||
total_gb=TOTAL_GB,
|
||||
total_gb=total_gb,
|
||||
)
|
||||
)
|
||||
for cluster_name in clusters
|
||||
@@ -703,17 +687,6 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
|
||||
await update_key_expiry(client_id, expiry_time, session)
|
||||
|
||||
|
||||
async def set_user_balance(tg_id: int, balance: int, session: Any) -> None:
|
||||
try:
|
||||
await session.execute(
|
||||
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
|
||||
balance,
|
||||
tg_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_traffic"), IsAdminFilter())
|
||||
async def handle_user_traffic(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
|
||||
@@ -771,13 +744,17 @@ async def restore_trials(callback_query: types.CallbackQuery, session: Any):
|
||||
Восстанавливает пробники для пользователей, у которых нет активной подписки.
|
||||
"""
|
||||
query = """
|
||||
UPDATE connections
|
||||
UPDATE users
|
||||
SET trial = 0
|
||||
WHERE tg_id IN (
|
||||
SELECT DISTINCT c.tg_id
|
||||
FROM connections c
|
||||
LEFT JOIN keys k ON c.tg_id = k.tg_id
|
||||
WHERE k.tg_id IS NULL AND c.trial != 0
|
||||
SELECT u.tg_id
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT tg_id
|
||||
FROM keys
|
||||
WHERE expiry_time > EXTRACT(EPOCH FROM NOW()) * 1000
|
||||
) k ON u.tg_id = k.tg_id
|
||||
WHERE k.tg_id IS NULL AND u.trial != 0
|
||||
)
|
||||
"""
|
||||
await session.execute(query)
|
||||
@@ -786,7 +763,7 @@ async def restore_trials(callback_query: types.CallbackQuery, session: Any):
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✅ Пробники успешно восстановлены для пользователей, у которых нет активных подписок.",
|
||||
text="✅ Пробники успешно восстановлены для пользователей без активных подписок.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
@@ -814,11 +791,35 @@ async def handle_users_export_referrals(
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
|
||||
async def handle_create_key_select_cluster(
|
||||
async def handle_create_key_start(
|
||||
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
await state.update_data(tg_id=tg_id)
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
await state.set_state(UserEditorState.selecting_country)
|
||||
|
||||
rows = await session.fetch("SELECT DISTINCT server_name FROM servers ORDER BY server_name")
|
||||
countries = [row["server_name"] for row in rows]
|
||||
|
||||
if not countries:
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Нет доступных стран для создания ключа.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for country in countries:
|
||||
builder.button(text=country, callback_data=country)
|
||||
builder.adjust(1)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
"🌍 <b>Выберите страну для создания ключа:</b>", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(UserEditorState.selecting_cluster)
|
||||
|
||||
servers = await get_servers(session)
|
||||
@@ -832,10 +833,7 @@ async def handle_create_key_select_cluster(
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cluster in cluster_names:
|
||||
builder.button(
|
||||
text=f"🌐 {cluster}",
|
||||
callback_data=cluster
|
||||
)
|
||||
builder.button(text=f"🌐 {cluster}", callback_data=cluster)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
@@ -843,6 +841,23 @@ async def handle_create_key_select_cluster(
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(UserEditorState.selecting_country, IsAdminFilter())
|
||||
async def handle_create_key_country(callback_query: CallbackQuery, state: FSMContext):
|
||||
country = callback_query.data
|
||||
await state.update_data(country=country)
|
||||
await state.set_state(UserEditorState.selecting_duration)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for months, _ in RENEWAL_PRICES.items():
|
||||
builder.button(text=f"{months} мес.", callback_data=str(months))
|
||||
builder.adjust(1)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"🕒 <b>Выберите срок действия ключа для страны {country}:</b>", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(UserEditorState.selecting_cluster, IsAdminFilter())
|
||||
async def handle_create_key_cluster(callback_query: CallbackQuery, state: FSMContext):
|
||||
cluster_name = callback_query.data
|
||||
@@ -866,35 +881,35 @@ async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMCo
|
||||
try:
|
||||
months = int(callback_query.data)
|
||||
data = await state.get_data()
|
||||
|
||||
tg_id = data["tg_id"]
|
||||
cluster_name = data["cluster_name"]
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = generate_random_email()
|
||||
expiry = datetime.now(tz=timezone.utc) + timedelta(days=30 * months)
|
||||
expiry_ms = int(expiry.timestamp() * 1000)
|
||||
|
||||
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms)
|
||||
if USE_COUNTRY_SELECTION and "country" in data:
|
||||
country = data["country"]
|
||||
await create_key_on_cluster(country, tg_id, client_id, email, expiry_ms, plan=months, session=session)
|
||||
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
await state.clear()
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Ключ успешно создан для страны <b>{country}</b> на {months} мес.",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
|
||||
await store_key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
expiry_time=expiry_ms,
|
||||
key=public_link,
|
||||
server_id=cluster_name,
|
||||
session=session,
|
||||
)
|
||||
elif "cluster_name" in data:
|
||||
cluster_name = data["cluster_name"]
|
||||
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms, plan=months, session=session)
|
||||
|
||||
await state.clear()
|
||||
await state.clear()
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.!",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text("❌ Не удалось определить источник — страна или кластер.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ключа: {e}")
|
||||
|
||||
@@ -15,9 +15,7 @@ MY_SUBS = "📱 Мои подписки"
|
||||
BALANCE = "💰 Баланс"
|
||||
INVITE = "👥 Пригласить"
|
||||
GIFTS = "🎁 Подарить"
|
||||
TARRIFS = "💡 Тарифы"
|
||||
INSTRUCTIONS = "📘 Инструкции"
|
||||
INVITE = "👥 Пригласить друга"
|
||||
TOP_FIVE = "🏆 Топ-5"
|
||||
TRIAL_SUB = "🎁 Пробная подписка"
|
||||
|
||||
@@ -40,8 +38,7 @@ GIFT = "🎁 Подарить подписку"
|
||||
MY_GIFTS = "🎁 Мои подарки"
|
||||
GIFTS_MENU = "В меню подарков"
|
||||
SHARE_GIFT = "🎁 Поделиться подарком"
|
||||
APPLY = "✅ Подтвердить"
|
||||
CANCEL = "❌ Отмена"
|
||||
GET_GIFT = "🎁 Получить подарок"
|
||||
|
||||
# Создание и подключение ключей
|
||||
|
||||
@@ -83,6 +80,4 @@ TV_INSTRUCTIONS = "📖 Полная инструкция"
|
||||
PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
|
||||
PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
STARS_BOT = "🤖 Бот для покупки звезд"
|
||||
|
||||
@@ -8,8 +8,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import CAPTCHA_EMOJIS
|
||||
from handlers.texts import CAPTCHA_PROMPT_MSG
|
||||
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
|
||||
from logger import logger
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
import html
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import ADMIN_ID
|
||||
from database import (
|
||||
add_user,
|
||||
check_coupon_usage,
|
||||
check_user_exists,
|
||||
create_coupon_usage,
|
||||
get_coupon_by_code,
|
||||
get_keys,
|
||||
update_balance,
|
||||
update_coupon_usage_count,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU, COUPON_RESTART
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.keys.key_utils import renew_key_in_cluster
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.texts import (
|
||||
COUPON_ACTIVATED_SUCCESS_MSG,
|
||||
COUPON_ALREADY_USED_MSG,
|
||||
COUPON_INPUT_PROMPT,
|
||||
COUPON_NOT_FOUND_MSG,
|
||||
)
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
from handlers.utils import edit_or_send_message, format_days
|
||||
from logger import logger
|
||||
|
||||
|
||||
class CouponActivationState(StatesGroup):
|
||||
waiting_for_coupon_code = State()
|
||||
waiting_for_key_selection = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -54,32 +66,163 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
|
||||
@router.message(CouponActivationState.waiting_for_coupon_code)
|
||||
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
|
||||
coupon_code = message.text.strip()
|
||||
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=COUPON_RESTART, callback_data="activate_coupon"))
|
||||
|
||||
await message.answer(activation_result, reply_markup=builder.as_markup())
|
||||
|
||||
await state.clear()
|
||||
await activate_coupon(message, state, session, coupon_code=coupon_code)
|
||||
|
||||
|
||||
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
|
||||
async def activate_coupon(message: Message, state: FSMContext, session: Any, coupon_code: str, admin: bool = False):
|
||||
logger.info(f"Активация купона: {coupon_code}")
|
||||
coupon_record = await get_coupon_by_code(coupon_code, session)
|
||||
|
||||
if not coupon_record:
|
||||
return COUPON_NOT_FOUND_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
|
||||
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
|
||||
if coupon_record["usage_count"] >= coupon_record["usage_limit"] or coupon_record["is_used"]:
|
||||
await message.answer("❌ Лимит активаций купона исчерпан.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if usage_exists:
|
||||
return COUPON_ALREADY_USED_MSG
|
||||
user_id = message.chat.id
|
||||
|
||||
coupon_amount = coupon_record["amount"]
|
||||
usage = await check_coupon_usage(coupon_record["id"], user_id, session)
|
||||
if usage:
|
||||
await message.answer(COUPON_ALREADY_USED_MSG)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_coupon_usage_count(coupon_record["id"], session)
|
||||
await create_coupon_usage(coupon_record["id"], user_id, session)
|
||||
user_exists = await check_user_exists(user_id)
|
||||
if not user_exists:
|
||||
from_user = message.from_user
|
||||
await add_user(
|
||||
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,
|
||||
session=session,
|
||||
)
|
||||
|
||||
await update_balance(user_id, coupon_amount, session)
|
||||
return COUPON_ACTIVATED_SUCCESS_MSG.format(coupon_amount=coupon_amount)
|
||||
if coupon_record["amount"] > 0:
|
||||
try:
|
||||
await update_balance(user_id, coupon_record["amount"], session, skip_referral=True)
|
||||
await update_coupon_usage_count(coupon_record["id"], session)
|
||||
await create_coupon_usage(coupon_record["id"], user_id, session)
|
||||
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon_record['amount']} рублей.")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при активации купона на баланс: {e}")
|
||||
await message.answer("❌ Ошибка при активации купона.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if coupon_record["days"] is not None and coupon_record["days"] > 0:
|
||||
try:
|
||||
keys = await get_keys(user_id, session)
|
||||
active_keys = [k for k in keys if not k["is_frozen"]]
|
||||
|
||||
if not active_keys:
|
||||
await message.answer("❌ У вас нет активных подписок для продления.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
|
||||
|
||||
for key in active_keys:
|
||||
alias = key.get("alias")
|
||||
email = key["email"]
|
||||
client_id = key["client_id"]
|
||||
expiry_time = key.get("expiry_time")
|
||||
|
||||
key_display = html.escape(alias.strip() if alias else email)
|
||||
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
|
||||
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
|
||||
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon_record['id']}")
|
||||
|
||||
response_message += "</blockquote>"
|
||||
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
|
||||
builder.adjust(1)
|
||||
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
await state.set_state(CouponActivationState.waiting_for_key_selection)
|
||||
await state.update_data(coupon_id=coupon_record["id"], user_id=user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке купона на дни: {e}")
|
||||
await message.answer("❌ Ошибка при активации купона.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await message.answer("❌ Купон недействителен (нет суммы или дней).")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("extend_key|"))
|
||||
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
|
||||
parts = callback_query.data.split("|")
|
||||
client_id = parts[1]
|
||||
coupon_id = int(parts[2])
|
||||
|
||||
try:
|
||||
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
|
||||
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
|
||||
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
usage = await check_coupon_usage(coupon_id, callback_query.from_user.id, session)
|
||||
if usage:
|
||||
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
key = await session.fetchrow(
|
||||
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2", callback_query.from_user.id, client_id
|
||||
)
|
||||
if not key or key["is_frozen"]:
|
||||
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
now_ms = int(datetime.now().timestamp() * 1000)
|
||||
current_expiry = key["expiry_time"]
|
||||
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=key["server_id"], email=key["email"], client_id=client_id, new_expiry_time=new_expiry, total_gb=0
|
||||
)
|
||||
await update_key_expiry(client_id, new_expiry, session)
|
||||
|
||||
await update_coupon_usage_count(coupon["id"], session)
|
||||
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
|
||||
|
||||
alias = key.get("alias") or key["email"]
|
||||
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime(
|
||||
"%d.%m.%y, %H:%M"
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
|
||||
)
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при продлении ключа: {e}")
|
||||
await callback_query.message.edit_text("❌ Ошибка при активации купона.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_coupon_activation")
|
||||
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
|
||||
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exit_coupon_input")
|
||||
async def handle_exit_coupon_input(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
is_admin = callback_query.from_user.id in ADMIN_ID
|
||||
await process_callback_view_profile(callback_query.message, state, admin=is_admin)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
|
||||
@@ -2,13 +2,14 @@ __all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .key_management import router as management_router
|
||||
from .key_connect import router as connect_router
|
||||
from .key_freeze import router as freeze_router
|
||||
from .key_mode import router as key_mode_router
|
||||
from .key_renew import router as renew_router
|
||||
from .key_view import router as view_router
|
||||
from .keys import router as keys_router
|
||||
|
||||
|
||||
router = Router(name="keys_main_router")
|
||||
|
||||
router.include_routers(
|
||||
keys_router,
|
||||
management_router,
|
||||
)
|
||||
router.include_routers(keys_router, view_router, renew_router, freeze_router, connect_router, key_mode_router)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import os
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import qrcode
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, INSTRUCTIONS_BUTTON
|
||||
from handlers.buttons import (
|
||||
ANDROID,
|
||||
BACK,
|
||||
DOWNLOAD_ANDROID_BUTTON,
|
||||
DOWNLOAD_IOS_BUTTON,
|
||||
IMPORT_ANDROID,
|
||||
IMPORT_IOS,
|
||||
IPHONE,
|
||||
MAIN_MENU,
|
||||
MANUAL_INSTRUCTIONS,
|
||||
PC,
|
||||
TV,
|
||||
)
|
||||
from handlers.texts import (
|
||||
ANDROID_DESCRIPTION_TEMPLATE,
|
||||
CHOOSE_DEVICE_TEXT,
|
||||
IOS_DESCRIPTION_TEMPLATE,
|
||||
SUBSCRIPTION_DESCRIPTION,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_device|"))
|
||||
async def handle_connect_device(callback_query: CallbackQuery):
|
||||
try:
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
|
||||
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CHOOSE_DEVICE_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
|
||||
logger.error(f"Ошибка в handle_connect_device: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_phone|"))
|
||||
async def process_callback_connect_phone(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow(
|
||||
"""
|
||||
SELECT key FROM keys WHERE email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
|
||||
)
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_ios|"))
|
||||
async def process_callback_connect_ios(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
|
||||
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=description,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_android|"))
|
||||
async def process_callback_connect_android(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
|
||||
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=description,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("show_qr|"))
|
||||
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
|
||||
try:
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
|
||||
if not record:
|
||||
await callback_query.message.answer("❌ Подписка не найдена.")
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(record["key"])
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
qr_path = f"/tmp/qrcode_{record['email']}.png"
|
||||
with open(qr_path, "wb") as f:
|
||||
f.write(buffer.read())
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="🔲 <b>Ваш QR-код для подключения</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=qr_path,
|
||||
)
|
||||
|
||||
os.remove(qr_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
||||
@@ -0,0 +1,220 @@
|
||||
import time
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
TOTAL_GB,
|
||||
)
|
||||
from database import (
|
||||
get_key_details,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
APPLY,
|
||||
BACK,
|
||||
CANCEL,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
renew_key_in_cluster,
|
||||
toggle_client_on_cluster,
|
||||
)
|
||||
from handlers.texts import (
|
||||
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
SUBSCRIPTION_FROZEN_MSG,
|
||||
SUBSCRIPTION_UNFROZEN_MSG,
|
||||
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, handle_error
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
|
||||
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=APPLY,
|
||||
callback_data=f"unfreeze_subscription_confirm|{key_name}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=CANCEL,
|
||||
callback_data=f"view_key|{key_name}",
|
||||
),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=confirm_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
|
||||
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Размораживает (включает) подписку.
|
||||
"""
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
|
||||
if result["status"] == "success":
|
||||
now_ms = int(time.time() * 1000)
|
||||
leftover = record["expiry_time"]
|
||||
if leftover < 0:
|
||||
leftover = 0
|
||||
|
||||
new_expiry_time = now_ms + leftover
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = $1,
|
||||
is_frozen = FALSE
|
||||
WHERE tg_id = $2
|
||||
AND client_id = $3
|
||||
""",
|
||||
new_expiry_time,
|
||||
record["tg_id"],
|
||||
client_id,
|
||||
)
|
||||
added_days = max(leftover / (1000 * 86400), 0.01)
|
||||
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=cluster_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=total_gb,
|
||||
)
|
||||
text_ok = SUBSCRIPTION_UNFROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
text_error = (
|
||||
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_error,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freeze_subscription|"))
|
||||
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
|
||||
"""
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=APPLY,
|
||||
callback_data=f"freeze_subscription_confirm|{key_name}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=CANCEL,
|
||||
callback_data=f"view_key|{key_name}",
|
||||
),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=confirm_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
|
||||
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Замораживает (отключает) подписку.
|
||||
"""
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
|
||||
|
||||
if result["status"] == "success":
|
||||
now_ms = int(time.time() * 1000)
|
||||
time_left = record["expiry_time"] - now_ms
|
||||
if time_left < 0:
|
||||
time_left = 0
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = $1,
|
||||
is_frozen = TRUE
|
||||
WHERE tg_id = $2
|
||||
AND client_id = $3
|
||||
""",
|
||||
time_left,
|
||||
record["tg_id"],
|
||||
client_id,
|
||||
)
|
||||
|
||||
text_ok = SUBSCRIPTION_FROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
text_error = (
|
||||
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_error,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
|
||||
@@ -1,669 +0,0 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, FSInputFile
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
PUBLIC_LINK,
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
TRIAL_TIME,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
add_connection,
|
||||
check_connection_exists,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_key_details,
|
||||
get_trial,
|
||||
store_key,
|
||||
update_balance,
|
||||
update_trial,
|
||||
check_server_name_by_cluster,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
PC_BUTTON,
|
||||
SUPPORT,
|
||||
TV_BUTTON,
|
||||
)
|
||||
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.texts import (
|
||||
CREATING_CONNECTION_MSG,
|
||||
DISCOUNTS,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
SELECT_COUNTRY_MSG,
|
||||
SELECT_TARIFF_PLAN_MSG,
|
||||
key_message_success,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
|
||||
from logger import logger
|
||||
from panels.three_xui import delete_client
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
class Form(FSMContext):
|
||||
waiting_for_server_selection = "waiting_for_server_selection"
|
||||
waiting_for_key_name = "waiting_for_key_name"
|
||||
viewing_profile = "viewing_profile"
|
||||
waiting_for_message = "waiting_for_message"
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_key")
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
await handle_key_creation(tg_id, state, session, callback_query)
|
||||
|
||||
|
||||
async def handle_key_creation(
|
||||
tg_id: int,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery,
|
||||
):
|
||||
"""Создание ключа с учётом выбора тарифного плана."""
|
||||
current_time = datetime.now(moscow_tz)
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
|
||||
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
|
||||
await edit_or_send_message(
|
||||
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=None,
|
||||
)
|
||||
await state.update_data(is_trial=True)
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
|
||||
discount_text = ""
|
||||
if DISCOUNTS and plan_id in DISCOUNTS:
|
||||
discount_percentage = DISCOUNTS[plan_id]
|
||||
discount_text = f" ({discount_percentage}% скидка)"
|
||||
if index == len(RENEWAL_PRICES) - 1:
|
||||
discount_text = f" ({discount_percentage}% 🔥)"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {plan_id} мес. - {price}₽{discount_text}",
|
||||
callback_data=f"select_plan_{plan_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if isinstance(message_or_query, CallbackQuery):
|
||||
target_message = message_or_query.message
|
||||
else:
|
||||
target_message = message_or_query
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_TARIFF_PLAN_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_plan_"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan_id = callback_query.data.split("_")[-1]
|
||||
plan_price = RENEWAL_PRICES.get(plan_id)
|
||||
if plan_price is None:
|
||||
await callback_query.message.answer("🚫 Неверный тарифный план.")
|
||||
return
|
||||
duration_days = int(plan_id) * 30
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < plan_price:
|
||||
required_amount = plan_price - balance
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"plan_price": plan_price,
|
||||
"duration_days": duration_days,
|
||||
"required_amount": required_amount,
|
||||
},
|
||||
)
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
return
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
||||
await state.update_data(plan_id=plan_id)
|
||||
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
|
||||
|
||||
async def create_key(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
state: FSMContext | None,
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
old_key_name: str = None,
|
||||
plan: int = None,
|
||||
):
|
||||
"""Создаёт ключ с заданным сроком действия."""
|
||||
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
if not await check_connection_exists(tg_id):
|
||||
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
||||
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
logger.info(f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов")
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1",
|
||||
least_loaded_cluster,
|
||||
)
|
||||
if not servers:
|
||||
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=error_message, reply_markup=None, media_path=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
tasks = []
|
||||
for server in servers:
|
||||
server_info = {
|
||||
"server_name": server["server_name"],
|
||||
"api_url": server["api_url"],
|
||||
}
|
||||
task = asyncio.create_task(check_server_availability(server_info))
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in zip(servers, results):
|
||||
if result is True:
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=error_message, reply_markup=None, media_path=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
logger.info(f"[Country Selection] Список доступных серверов: {available_servers}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
ts = int(expiry_time.timestamp())
|
||||
for country in available_servers:
|
||||
if old_key_name:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
else:
|
||||
callback_data = f"select_country|{country}|{ts}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
if not existing_key:
|
||||
break
|
||||
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
try:
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
|
||||
)
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
||||
await store_key(
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
least_loaded_cluster,
|
||||
session,
|
||||
)
|
||||
|
||||
data = {}
|
||||
if state:
|
||||
data = await state.get_data()
|
||||
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
if data.get("plan_id"):
|
||||
plan_price = RENEWAL_PRICES.get(data["plan_id"])
|
||||
await update_balance(tg_id, -plan_price, session)
|
||||
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=error_message, reply_markup=None, media_path=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
expiry_time_local = expiry_time.replace(tzinfo=None).astimezone(moscow_tz)
|
||||
remaining_time = expiry_time_local - datetime.now(moscow_tz)
|
||||
days = remaining_time.days
|
||||
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
default_media_path = "img/pic.jpg"
|
||||
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=default_media_path,
|
||||
)
|
||||
|
||||
else:
|
||||
photo = FSInputFile(default_media_path)
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id,
|
||||
photo=photo,
|
||||
caption=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
if state:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_location|"))
|
||||
async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) < 2:
|
||||
await callback_query.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
old_key_name = data[1]
|
||||
record = await get_key_details(old_key_name, session)
|
||||
if not record:
|
||||
await callback_query.answer("❌ Ключ не найден", show_alert=True)
|
||||
return
|
||||
|
||||
expiry_timestamp = record["expiry_time"]
|
||||
ts = int(expiry_timestamp / 1000)
|
||||
|
||||
current_server = record["server_id"]
|
||||
|
||||
cluster_info = await check_server_name_by_cluster(current_server, session)
|
||||
if not cluster_info:
|
||||
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
|
||||
return
|
||||
|
||||
cluster_name = cluster_info["cluster_name"]
|
||||
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1 AND server_name != $2",
|
||||
cluster_name,
|
||||
current_server,
|
||||
)
|
||||
if not servers:
|
||||
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
tasks = []
|
||||
for server in servers:
|
||||
server_info = {
|
||||
"server_name": server["server_name"],
|
||||
"api_url": server["api_url"],
|
||||
}
|
||||
task = asyncio.create_task(check_server_availability(server_info))
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in zip(servers, results):
|
||||
if result is True:
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"Доступные страны для смены локации: {available_servers}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for country in available_servers:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
|
||||
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_country|"))
|
||||
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
"""
|
||||
Обрабатывает выбор страны.
|
||||
Формат callback data:
|
||||
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
|
||||
Если передан old_key_name – значит, происходит смена локации.
|
||||
"""
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) < 3:
|
||||
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
|
||||
return
|
||||
|
||||
selected_country = data[1]
|
||||
try:
|
||||
ts = int(data[2])
|
||||
except ValueError:
|
||||
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
|
||||
return
|
||||
|
||||
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
||||
|
||||
old_key_name = data[3] if len(data) > 3 else None
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
|
||||
logger.info(f"Получено время истечения (timestamp): {ts}")
|
||||
|
||||
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
|
||||
|
||||
|
||||
async def finalize_key_creation(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
selected_country: str,
|
||||
state: FSMContext | None,
|
||||
session: Any,
|
||||
callback_query: CallbackQuery,
|
||||
old_key_name: str = None,
|
||||
):
|
||||
"""Финализирует создание ключа с выбором стран."""
|
||||
|
||||
if not await check_connection_exists(tg_id):
|
||||
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
||||
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
|
||||
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
if old_key_name:
|
||||
old_key_details = await get_key_details(old_key_name, session)
|
||||
if not old_key_details:
|
||||
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
|
||||
return
|
||||
|
||||
key_name = old_key_name
|
||||
client_id = old_key_details["client_id"]
|
||||
email = old_key_details["email"]
|
||||
expiry_timestamp = old_key_details["expiry_time"]
|
||||
public_link = old_key_details["key"]
|
||||
else:
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
if not existing_key:
|
||||
break
|
||||
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
try:
|
||||
server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
|
||||
selected_country,
|
||||
)
|
||||
|
||||
if not server_info:
|
||||
raise ValueError(f"Сервер {selected_country} не найден.")
|
||||
|
||||
if old_key_name:
|
||||
old_server_id = old_key_details.get("server_id")
|
||||
if old_server_id:
|
||||
old_server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
|
||||
old_server_id,
|
||||
)
|
||||
|
||||
if old_server_info:
|
||||
xui = AsyncApi(
|
||||
old_server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
deletion_success = await delete_client(
|
||||
xui,
|
||||
old_server_info["inbound_id"],
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
if not deletion_success:
|
||||
logger.warning(f"Не удалось удалить клиента с сервера {old_server_id}, но продолжаем процесс.")
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
await create_client_on_server(
|
||||
server_info=server_info,
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
expiry_timestamp=expiry_timestamp,
|
||||
semaphore=semaphore,
|
||||
)
|
||||
|
||||
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
|
||||
|
||||
if old_key_name:
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET server_id = $1
|
||||
WHERE tg_id = $2 AND email = $3
|
||||
""",
|
||||
selected_country,
|
||||
tg_id,
|
||||
old_key_name,
|
||||
)
|
||||
else:
|
||||
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
""",
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
created_at,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
selected_country,
|
||||
)
|
||||
data = await state.get_data()
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
if data.get("plan_id"):
|
||||
plan_price = RENEWAL_PRICES.get(data["plan_id"])
|
||||
await update_balance(tg_id, -plan_price, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error while creating the key for user {tg_id}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
remaining_time = expiry_time - datetime.now(moscow_tz)
|
||||
days = remaining_time.days
|
||||
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
default_media_path = "img/pic.jpg"
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=default_media_path,
|
||||
)
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
|
||||
async def check_server_availability(server_info: dict) -> bool:
|
||||
"""
|
||||
Проверяет доступность сервера через API.
|
||||
|
||||
Args:
|
||||
server_info (dict): Информация о сервере (api_url, username, password).
|
||||
|
||||
Returns:
|
||||
bool: True, если сервер доступен, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await asyncio.wait_for(xui.login(), timeout=5.0)
|
||||
logger.info(f"Сервер {server_info.get('server_name', 'unknown')} доступен.")
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} не ответил вовремя.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} недоступен: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,16 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .key_cluster_mode import router as cluster_router
|
||||
from .key_country_mode import router as country_router
|
||||
from .key_create import router as create_router
|
||||
|
||||
|
||||
router = Router(name="key_mode_router")
|
||||
|
||||
router.include_routers(
|
||||
create_router,
|
||||
cluster_router,
|
||||
country_router,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import (
|
||||
get_key_details,
|
||||
get_trial,
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
from handlers.texts import (
|
||||
key_message_success,
|
||||
)
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
generate_random_email,
|
||||
get_least_loaded_cluster,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def key_cluster_mode(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
state,
|
||||
session,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
plan: int = None,
|
||||
):
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
if not existing_key:
|
||||
break
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
try:
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
await create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan, session)
|
||||
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
||||
|
||||
key_record = await get_key_details(email, session)
|
||||
if not key_record:
|
||||
raise ValueError(f"Ключ не найден после создания: {email}")
|
||||
|
||||
public_link = key_record.get("key")
|
||||
remnawave_link = key_record.get("remnawave_link")
|
||||
final_link = public_link or remnawave_link or ""
|
||||
|
||||
data = await state.get_data() if state else {}
|
||||
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
|
||||
if data.get("plan_id"):
|
||||
plan_price = RENEWAL_PRICES.get(data["plan_id"])
|
||||
await update_balance(tg_id, -plan_price, session)
|
||||
|
||||
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=error_message, reply_markup=None, media_path=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if await is_full_remnawave_cluster(least_loaded_cluster, session):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
web_app=WebAppInfo(url=final_link),
|
||||
)
|
||||
)
|
||||
elif CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
expiry_time_local = expiry_time.astimezone(moscow_tz)
|
||||
remaining_time = expiry_time_local - datetime.now(moscow_tz)
|
||||
days = remaining_time.days
|
||||
key_message_text = key_message_success(final_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
default_media_path = "img/pic.jpg"
|
||||
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=default_media_path,
|
||||
)
|
||||
else:
|
||||
photo = FSInputFile(default_media_path)
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id,
|
||||
photo=photo,
|
||||
caption=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
@@ -0,0 +1,511 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import asyncpg
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
PUBLIC_LINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import (
|
||||
add_user,
|
||||
check_server_name_by_cluster,
|
||||
check_user_exists,
|
||||
get_key_details,
|
||||
get_trial,
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
|
||||
from handlers.keys.key_utils import create_client_on_server
|
||||
from handlers.texts import (
|
||||
SELECT_COUNTRY_MSG,
|
||||
key_message_success,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import delete_client, get_xui_instance
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def key_country_mode(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
old_key_name: str = None,
|
||||
):
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url, panel_type FROM servers WHERE cluster_name = $1",
|
||||
least_loaded_cluster,
|
||||
)
|
||||
|
||||
if not servers:
|
||||
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
tasks = [asyncio.create_task(check_server_availability(server)) for server in servers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in zip(servers, results, strict=False):
|
||||
if result is True:
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[Country Selection] Доступные серверы для выбора: {available_servers}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
ts = int(expiry_time.timestamp())
|
||||
|
||||
for country in available_servers:
|
||||
if old_key_name:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
else:
|
||||
callback_data = f"select_country|{country}|{ts}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_location|"))
|
||||
async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) < 2:
|
||||
await callback_query.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
old_key_name = data[1]
|
||||
record = await get_key_details(old_key_name, session)
|
||||
if not record:
|
||||
await callback_query.answer("❌ Ключ не найден", show_alert=True)
|
||||
return
|
||||
|
||||
expiry_timestamp = record["expiry_time"]
|
||||
ts = int(expiry_timestamp / 1000)
|
||||
|
||||
current_server = record["server_id"]
|
||||
|
||||
cluster_info = await check_server_name_by_cluster(current_server, session)
|
||||
if not cluster_info:
|
||||
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
|
||||
return
|
||||
|
||||
cluster_name = cluster_info["cluster_name"]
|
||||
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url, panel_type, enabled, max_keys FROM servers WHERE cluster_name = $1 AND server_name != $2",
|
||||
cluster_name,
|
||||
current_server,
|
||||
)
|
||||
if not servers:
|
||||
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
server_info = {
|
||||
"server_name": server["server_name"],
|
||||
"api_url": server["api_url"],
|
||||
"panel_type": server["panel_type"],
|
||||
"enabled": server.get("enabled", True),
|
||||
"max_keys": server.get("max_keys"),
|
||||
}
|
||||
task = asyncio.create_task(check_server_availability(server_info))
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in zip(servers, results, strict=False):
|
||||
if result is True:
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"Доступные страны для смены локации: {available_servers}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for country in available_servers:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
|
||||
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_country|"))
|
||||
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
"""
|
||||
Обрабатывает выбор страны.
|
||||
Формат callback data:
|
||||
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
|
||||
Если передан old_key_name – значит, происходит смена локации.
|
||||
"""
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) < 3:
|
||||
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
|
||||
return
|
||||
|
||||
selected_country = data[1]
|
||||
try:
|
||||
ts = int(data[2])
|
||||
except ValueError:
|
||||
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
|
||||
return
|
||||
|
||||
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
||||
|
||||
old_key_name = data[3] if len(data) > 3 else None
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
|
||||
logger.info(f"Получено время истечения (timestamp): {ts}")
|
||||
|
||||
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
|
||||
|
||||
|
||||
async def finalize_key_creation(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
selected_country: str,
|
||||
state: FSMContext | None,
|
||||
session: Any,
|
||||
callback_query: CallbackQuery,
|
||||
old_key_name: str = None,
|
||||
):
|
||||
if not await check_user_exists(tg_id):
|
||||
if isinstance(callback_query, CallbackQuery):
|
||||
from_user = callback_query.from_user
|
||||
else:
|
||||
from_user = callback_query.from_user
|
||||
|
||||
await add_user(
|
||||
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,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
|
||||
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
if old_key_name:
|
||||
old_key_details = await get_key_details(old_key_name, session)
|
||||
if not old_key_details:
|
||||
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
|
||||
return
|
||||
|
||||
key_name = old_key_name
|
||||
client_id = old_key_details["client_id"]
|
||||
email = old_key_details["email"]
|
||||
expiry_timestamp = old_key_details["expiry_time"]
|
||||
else:
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
if not existing_key:
|
||||
break
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
remna = None
|
||||
|
||||
try:
|
||||
server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
|
||||
selected_country,
|
||||
)
|
||||
if not server_info:
|
||||
raise ValueError(f"Сервер {selected_country} не найден.")
|
||||
|
||||
panel_type = server_info["panel_type"].lower()
|
||||
|
||||
public_link = None
|
||||
remnawave_link = None
|
||||
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
if old_key_name:
|
||||
old_server_id = old_key_details.get("server_id")
|
||||
if old_server_id:
|
||||
old_server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
|
||||
old_server_id,
|
||||
)
|
||||
if old_server_info:
|
||||
old_panel_type = old_server_info["panel_type"].lower()
|
||||
try:
|
||||
if old_panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(old_server_info["api_url"])
|
||||
|
||||
await delete_client(
|
||||
xui,
|
||||
old_server_info["inbound_id"],
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
await session.execute(
|
||||
"UPDATE keys SET key = NULL WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
logger.info(f"[Delete] Удалён клиент {email} с 3x-ui сервера {old_server_id}")
|
||||
elif old_panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(old_server_info["api_url"])
|
||||
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await remna.delete_user(client_id)
|
||||
await session.execute(
|
||||
"UPDATE keys SET remnawave_link = NULL WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
logger.info(f"[Delete] Удалён клиент {client_id} с Remnawave сервера {old_server_id}")
|
||||
else:
|
||||
logger.warning(f"[Delete] Не удалось авторизоваться в Remnawave ({old_server_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Delete] Ошибка при удалении клиента с сервера {old_server_id}: {e}")
|
||||
|
||||
if panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
|
||||
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": [server_info["inbound_id"]],
|
||||
}
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
|
||||
|
||||
client_id = result.get("uuid")
|
||||
remnawave_link = result.get("subscriptionUrl")
|
||||
logger.info(f"[Key Creation] Remnawave пользователь создан: {result}")
|
||||
|
||||
if old_key_name:
|
||||
await session.execute(
|
||||
"UPDATE keys SET client_id = $1 WHERE tg_id = $2 AND email = $3",
|
||||
client_id,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
if panel_type == "3x-ui":
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
await create_client_on_server(
|
||||
server_info=server_info,
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
expiry_timestamp=expiry_timestamp,
|
||||
semaphore=semaphore,
|
||||
)
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
|
||||
|
||||
if old_key_name:
|
||||
await session.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE tg_id = $2 AND email = $3",
|
||||
selected_country,
|
||||
tg_id,
|
||||
old_key_name,
|
||||
)
|
||||
if panel_type == "3x-ui":
|
||||
await session.execute(
|
||||
"UPDATE keys SET key = $1 WHERE tg_id = $2 AND email = $3",
|
||||
public_link,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
elif panel_type == "remnawave":
|
||||
await session.execute(
|
||||
"UPDATE keys SET remnawave_link = $1 WHERE tg_id = $2 AND email = $3",
|
||||
remnawave_link,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
else:
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, remnawave_link, server_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""",
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
created_at,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
remnawave_link,
|
||||
selected_country,
|
||||
)
|
||||
data = await state.get_data()
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
if data.get("plan_id"):
|
||||
plan_price = RENEWAL_PRICES.get(data["plan_id"])
|
||||
await update_balance(tg_id, -plan_price, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
remaining_time = expiry_time - datetime.now(moscow_tz)
|
||||
days = remaining_time.days
|
||||
|
||||
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
|
||||
key_message_text = key_message_success(link_to_show, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path="img/pic.jpg",
|
||||
)
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def check_server_availability(server_info: dict, session: Any = None) -> bool:
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
enabled = server_info.get("enabled", True)
|
||||
max_keys = server_info.get("max_keys")
|
||||
|
||||
if not enabled:
|
||||
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
|
||||
return False
|
||||
|
||||
connection = None
|
||||
external_session = session is not None
|
||||
|
||||
try:
|
||||
if not external_session:
|
||||
connection = await asyncpg.connect(DATABASE_URL)
|
||||
session = connection
|
||||
|
||||
if max_keys is not None:
|
||||
count_query = "SELECT COUNT(*) FROM keys WHERE server_id = $1"
|
||||
key_count = await session.fetchval(count_query, server_name)
|
||||
if key_count >= max_keys:
|
||||
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}")
|
||||
return False
|
||||
finally:
|
||||
if connection:
|
||||
await connection.close()
|
||||
|
||||
try:
|
||||
if panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
|
||||
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
|
||||
return True
|
||||
|
||||
else:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await asyncio.wait_for(xui.login(), timeout=5.0)
|
||||
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
|
||||
return True
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,213 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
RENEWAL_PRICES,
|
||||
TRIAL_TIME,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_trial,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
)
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.texts import (
|
||||
CREATING_CONNECTION_MSG,
|
||||
DISCOUNTS,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
SELECT_TARIFF_PLAN_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from .key_cluster_mode import key_cluster_mode
|
||||
from .key_country_mode import key_country_mode
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
class Form(FSMContext):
|
||||
waiting_for_server_selection = "waiting_for_server_selection"
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_key")
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
await handle_key_creation(tg_id, state, session, callback_query)
|
||||
|
||||
|
||||
async def handle_key_creation(
|
||||
tg_id: int,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery,
|
||||
):
|
||||
"""Создание ключа с учётом выбора тарифного плана."""
|
||||
current_time = datetime.now(moscow_tz)
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
|
||||
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
|
||||
await edit_or_send_message(
|
||||
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=None,
|
||||
)
|
||||
await state.update_data(is_trial=True)
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
|
||||
discount_text = ""
|
||||
if DISCOUNTS and plan_id in DISCOUNTS:
|
||||
discount_percentage = DISCOUNTS[plan_id]
|
||||
discount_text = f" ({discount_percentage}% скидка)"
|
||||
if index == len(RENEWAL_PRICES) - 1:
|
||||
discount_text = f" ({discount_percentage}% 🔥)"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {plan_id} мес. - {price}₽{discount_text}",
|
||||
callback_data=f"select_plan_{plan_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if isinstance(message_or_query, CallbackQuery):
|
||||
target_message = message_or_query.message
|
||||
else:
|
||||
target_message = message_or_query
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_TARIFF_PLAN_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_plan_"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan_id = callback_query.data.split("_")[-1]
|
||||
plan_price = RENEWAL_PRICES.get(plan_id)
|
||||
if plan_price is None:
|
||||
await callback_query.message.answer("🚫 Неверный тарифный план.")
|
||||
return
|
||||
duration_days = int(plan_id) * 30
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < plan_price:
|
||||
required_amount = plan_price - balance
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"plan_price": plan_price,
|
||||
"duration_days": duration_days,
|
||||
"required_amount": required_amount,
|
||||
},
|
||||
)
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
return
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
||||
await state.update_data(plan_id=plan_id)
|
||||
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
|
||||
|
||||
|
||||
async def create_key(
|
||||
tg_id: int,
|
||||
expiry_time,
|
||||
state,
|
||||
session,
|
||||
message_or_query=None,
|
||||
old_key_name: str = None,
|
||||
plan: int = None,
|
||||
):
|
||||
"""
|
||||
Универсальная точка входа для создания ключа.
|
||||
Делегирует выполнение в зависимости от выбранного режима (страна или кластер).
|
||||
Также отвечает за первичное подключение пользователя.
|
||||
"""
|
||||
if not await check_user_exists(tg_id):
|
||||
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
|
||||
if from_user:
|
||||
await add_user(
|
||||
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,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
await key_country_mode(
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
state=state,
|
||||
session=session,
|
||||
message_or_query=message_or_query,
|
||||
old_key_name=old_key_name,
|
||||
)
|
||||
else:
|
||||
await key_cluster_mode(
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
state=state,
|
||||
session=session,
|
||||
message_or_query=message_or_query,
|
||||
plan=plan,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
RENEWAL_PRICES,
|
||||
TOTAL_GB,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_key_by_server,
|
||||
get_key_details,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.texts import (
|
||||
DISCOUNTS,
|
||||
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||
KEY_NOT_FOUND_MSG,
|
||||
PLAN_SELECTION_MSG,
|
||||
SUCCESS_RENEWAL_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, format_months
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for plan_id, price in RENEWAL_PRICES.items():
|
||||
months = int(plan_id)
|
||||
|
||||
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
|
||||
|
||||
button_text = f"📅 {format_months(months)} ({price} руб.)"
|
||||
if discount > 0:
|
||||
button_text += f" {discount}% скидка"
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"renew_plan|{months}|{client_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer("<b>Ключ не найден.</b>")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в process_callback_renew_key: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
|
||||
days_to_extend = 30 * int(plan)
|
||||
|
||||
total_gb = int((int(plan) or 1) * TOTAL_GB * 1024**3)
|
||||
|
||||
try:
|
||||
record = await get_key_by_server(tg_id, client_id, session)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
|
||||
if expiry_time <= current_time:
|
||||
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
else:
|
||||
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
|
||||
cost = RENEWAL_PRICES.get(plan)
|
||||
if cost is None:
|
||||
await callback_query.message.answer("❌ Неверный тарифный план.")
|
||||
return
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
if balance < cost:
|
||||
required_amount = cost - balance
|
||||
|
||||
logger.info(
|
||||
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
|
||||
)
|
||||
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"plan": plan,
|
||||
"client_id": client_id,
|
||||
"cost": cost,
|
||||
"required_amount": required_amount,
|
||||
"new_expiry_time": new_expiry_time,
|
||||
"total_gb": total_gb,
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
else:
|
||||
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
|
||||
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
|
||||
|
||||
else:
|
||||
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
|
||||
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
|
||||
|
||||
|
||||
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
|
||||
logger.info(f"[Info] Продление ключа {client_id} на {plan} мес. (Start)")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(months_formatted=format_months(int(plan)))
|
||||
|
||||
if callback_query:
|
||||
try:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
|
||||
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
|
||||
else:
|
||||
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_info = await get_key_details(email, conn)
|
||||
if not key_info:
|
||||
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
server_id = key_info["server_id"]
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
cluster_info = await check_server_name_by_cluster(server_id, conn)
|
||||
if not cluster_info:
|
||||
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
|
||||
await conn.close()
|
||||
return
|
||||
cluster_id = cluster_info["cluster_name"]
|
||||
else:
|
||||
cluster_id = server_id
|
||||
|
||||
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
await update_balance(tg_id, -cost, conn)
|
||||
await conn.close()
|
||||
|
||||
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
|
||||
@@ -1,34 +1,49 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB
|
||||
from database import get_servers, store_key, delete_notification
|
||||
from handlers.utils import get_least_loaded_cluster
|
||||
from bot import bot
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
LIMIT_IP,
|
||||
PUBLIC_LINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
SUPERNODE,
|
||||
TOTAL_GB,
|
||||
)
|
||||
from database import delete_notification, get_servers, store_key
|
||||
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
|
||||
cluster_id: str,
|
||||
tg_id: int,
|
||||
client_id: str,
|
||||
email: str,
|
||||
expiry_timestamp: int,
|
||||
plan: int = None,
|
||||
session=None,
|
||||
remnawave_link: str = None,
|
||||
):
|
||||
"""
|
||||
Создает ключ на всех серверах указанного кластера (или на конкретном сервере, если cluster_id — это имя сервера).
|
||||
"""
|
||||
try:
|
||||
servers = await get_servers()
|
||||
servers = await get_servers(include_enabled=True)
|
||||
cluster = servers.get(cluster_id)
|
||||
server_id_to_store = cluster_id
|
||||
|
||||
if not cluster:
|
||||
found_servers = []
|
||||
@@ -38,23 +53,118 @@ async def create_key_on_cluster(
|
||||
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
|
||||
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
remnawave_servers = [
|
||||
s
|
||||
for s in enabled_servers
|
||||
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, conn)
|
||||
]
|
||||
xui_servers = [
|
||||
s
|
||||
for s in enabled_servers
|
||||
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, conn)
|
||||
]
|
||||
|
||||
if not remnawave_servers and not xui_servers:
|
||||
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
|
||||
return
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
|
||||
if SUPERNODE:
|
||||
for server_info in cluster:
|
||||
await create_client_on_server(
|
||||
server_info, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan
|
||||
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:
|
||||
traffic_limit_bytes = int((plan or 1) * TOTAL_GB * 1024**3)
|
||||
short_uuid = None
|
||||
if remnawave_link and "/" in remnawave_link:
|
||||
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
|
||||
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"trafficLimitBytes": traffic_limit_bytes,
|
||||
"expireAt": expire_at,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": inbound_ids,
|
||||
}
|
||||
|
||||
if short_uuid:
|
||||
user_data["shortUuid"] = short_uuid
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
else:
|
||||
await asyncio.gather(
|
||||
*(
|
||||
create_client_on_server(
|
||||
server,
|
||||
tg_id,
|
||||
final_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
semaphore,
|
||||
plan=plan,
|
||||
)
|
||||
for server in xui_servers
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
await asyncio.gather(
|
||||
*(
|
||||
create_client_on_server(server, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan)
|
||||
for server in cluster
|
||||
),
|
||||
return_exceptions=True,
|
||||
|
||||
if (remnawave_created and remnawave_client_id) or xui_servers:
|
||||
await store_key(
|
||||
tg_id,
|
||||
final_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
key=public_link,
|
||||
server_id=server_id_to_store,
|
||||
session=session,
|
||||
remnawave_link=remnawave_key,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -75,12 +185,7 @@ async def create_client_on_server(
|
||||
Создает клиента на указанном сервере.
|
||||
"""
|
||||
async with semaphore:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
@@ -96,7 +201,7 @@ async def create_client_on_server(
|
||||
unique_email = email
|
||||
sub_id = unique_email
|
||||
|
||||
total_gb_value = int(TOTAL_GB) if plan is None else int(plan) * int(TOTAL_GB)
|
||||
total_gb_value = int((plan or 1) * TOTAL_GB * 1024**3)
|
||||
|
||||
await add_client(
|
||||
xui,
|
||||
@@ -150,35 +255,81 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
|
||||
notification_id = f"{email}_{notif}"
|
||||
await delete_notification(tg_id, notification_id, session=conn)
|
||||
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
|
||||
|
||||
remnawave_inbound_ids = []
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
continue
|
||||
if panel_type == "remnawave":
|
||||
inbound_uuid = server_info.get("inbound_id")
|
||||
if inbound_uuid:
|
||||
remnawave_inbound_ids.append(inbound_uuid)
|
||||
else:
|
||||
logger.warning(f"Не указан inbound_id для продления Remnawave на сервере {server_name}")
|
||||
|
||||
if SUPERNODE:
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
sub_id = email
|
||||
else:
|
||||
unique_email = email
|
||||
sub_id = unique_email
|
||||
|
||||
tasks.append(
|
||||
extend_client_key(
|
||||
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
|
||||
)
|
||||
if remnawave_inbound_ids:
|
||||
remnawave_server = next(
|
||||
(
|
||||
srv
|
||||
for srv in cluster
|
||||
if srv.get("panel_type", "").lower() == "remnawave"
|
||||
and srv.get("inbound_id") in remnawave_inbound_ids
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not remnawave_server:
|
||||
logger.error("❌ Не найден Remnawave сервер для продления")
|
||||
else:
|
||||
remna = RemnawaveAPI(remnawave_server["api_url"])
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if logged_in:
|
||||
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
|
||||
updated = await remna.update_user(
|
||||
uuid=client_id,
|
||||
expire_at=expire_iso,
|
||||
active_user_inbounds=remnawave_inbound_ids,
|
||||
traffic_limit_bytes=total_gb,
|
||||
)
|
||||
if updated:
|
||||
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
|
||||
await remna.reset_user_traffic(client_id)
|
||||
else:
|
||||
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}")
|
||||
else:
|
||||
logger.error("Не удалось войти в Remnawave API")
|
||||
|
||||
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":
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
|
||||
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
|
||||
|
||||
tasks.append(
|
||||
extend_client_key(
|
||||
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
|
||||
)
|
||||
)
|
||||
|
||||
elif panel_type != "remnawave":
|
||||
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_name}")
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
except Exception as e:
|
||||
@@ -204,32 +355,42 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
if not inbound_id:
|
||||
logger.warning(
|
||||
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
if panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if not logged_in:
|
||||
logger.error("Не удалось войти в Remnawave API")
|
||||
continue
|
||||
|
||||
success = await remna.delete_user(client_id)
|
||||
if success:
|
||||
logger.info(f"Клиент Remnawave {client_id} успешно удалён")
|
||||
else:
|
||||
logger.warning(f"Не удалось удалить клиента Remnawave {client_id}")
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
delete_client(
|
||||
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"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
continue
|
||||
|
||||
await delete_client(
|
||||
xui,
|
||||
int(inbound_id),
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
else:
|
||||
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_info.get('server_name')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
|
||||
@@ -238,7 +399,10 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
|
||||
async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id):
|
||||
"""
|
||||
Обновляет ключ на всех серверах указанного кластера (или сервера, если передано имя).
|
||||
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
|
||||
Работает с панелями 3x-ui и Remnawave. Использует новый client_id от Remnawave для всех серверов (если пересоздан).
|
||||
Если SUPERNODE активен — email делается уникальным на каждый сервер.
|
||||
Возвращает кортеж: (новый client_id, remnawave ссылка или None).
|
||||
"""
|
||||
try:
|
||||
servers = await get_servers()
|
||||
@@ -255,46 +419,84 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
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"])
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if logged_in:
|
||||
await remna.delete_user(client_id)
|
||||
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_iso,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": inbound_ids,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
add_client(
|
||||
xui,
|
||||
ClientConfig(
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
tg_id=tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_time,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
sub_id=email,
|
||||
),
|
||||
)
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
if SUPERNODE:
|
||||
sub_id = email
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
else:
|
||||
sub_id = email
|
||||
unique_email = email
|
||||
|
||||
config = ClientConfig(
|
||||
client_id=remnawave_client_id,
|
||||
email=unique_email,
|
||||
tg_id=tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_time,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
sub_id=sub_id,
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
tasks.append(add_client(xui, config))
|
||||
|
||||
logger.info(f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}")
|
||||
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"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}")
|
||||
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
@@ -324,20 +526,23 @@ async def update_subscription(tg_id: int, email: str, session: Any, cluster_over
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
new_cluster_id = cluster_override or await get_least_loaded_cluster()
|
||||
|
||||
await asyncio.gather(
|
||||
update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
new_client_id, remnawave_key = await update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id)
|
||||
|
||||
servers = await get_servers()
|
||||
cluster_servers = servers.get(new_cluster_id, [])
|
||||
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(
|
||||
tg_id,
|
||||
client_id,
|
||||
new_client_id,
|
||||
email,
|
||||
expiry_time,
|
||||
public_link,
|
||||
key=final_key_link,
|
||||
remnawave_link=remnawave_key,
|
||||
server_id=new_cluster_id,
|
||||
session=session,
|
||||
)
|
||||
@@ -345,71 +550,94 @@ async def update_subscription(tg_id: int, email: str, session: Any, cluster_over
|
||||
|
||||
async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает трафик пользователя на всех серверах, где у него есть ключ.
|
||||
|
||||
Args:
|
||||
session (Any): Сессия базы данных.
|
||||
tg_id (int): ID пользователя Telegram.
|
||||
email (str): Email пользователя.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Структура с данными о трафике.
|
||||
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
|
||||
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
|
||||
"""
|
||||
|
||||
query = "SELECT client_id, server_id FROM keys WHERE tg_id = $1 AND email = $2"
|
||||
rows = await session.fetch(query, tg_id, email)
|
||||
|
||||
if not rows:
|
||||
return {"status": "error", "message": "❌ У пользователя нет активных ключей."}
|
||||
return {"status": "error", "message": "У пользователя нет активных ключей."}
|
||||
|
||||
server_ids = {row["server_id"] for row in rows}
|
||||
|
||||
query_servers = """
|
||||
SELECT server_name, api_url FROM servers
|
||||
SELECT server_name, cluster_name, api_url, panel_type
|
||||
FROM servers
|
||||
WHERE server_name = ANY($1) OR cluster_name = ANY($1)
|
||||
"""
|
||||
server_rows = await session.fetch(query_servers, list(server_ids))
|
||||
|
||||
if not server_rows:
|
||||
logger.error(f"❌ Не найдено серверов для: {server_ids}")
|
||||
return {"status": "error", "message": f"❌ Серверы не найдены: {', '.join(server_ids)}"}
|
||||
logger.error(f"Не найдено серверов для: {server_ids}")
|
||||
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
|
||||
|
||||
servers_map = {row["server_name"]: row["api_url"] for row in server_rows}
|
||||
servers_map = {row["server_name"]: row for row in server_rows}
|
||||
|
||||
user_traffic_data = {}
|
||||
|
||||
async def fetch_traffic(api_url: str, client_id: str, server: str) -> tuple[str, Any]:
|
||||
"""
|
||||
Получает трафик с сервера для заданного client_id.
|
||||
Возвращает кортеж: (server, used_gb) или (server, ошибка).
|
||||
"""
|
||||
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
|
||||
try:
|
||||
traffic_info = await get_client_traffic(xui, client_id)
|
||||
if traffic_info["status"] == "success" and traffic_info["traffic"]:
|
||||
client_data = traffic_info["traffic"][0]
|
||||
used_gb = (client_data.up + client_data.down) / 1073741824
|
||||
return server, round(used_gb, 2)
|
||||
else:
|
||||
return server, "Ошибка получения трафика"
|
||||
except Exception as e:
|
||||
return server, f"Ошибка: {e}"
|
||||
|
||||
tasks = []
|
||||
|
||||
remnawave_client_id = None
|
||||
remnawave_checked = False
|
||||
|
||||
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"]
|
||||
if server_id in servers_map:
|
||||
api_url = servers_map[server_id]
|
||||
tasks.append(fetch_traffic(api_url, client_id, server_id))
|
||||
else:
|
||||
for server, api_url in servers_map.items():
|
||||
tasks.append(fetch_traffic(api_url, client_id, server))
|
||||
|
||||
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:
|
||||
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}
|
||||
|
||||
|
||||
@@ -446,12 +674,7 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
|
||||
tasks = []
|
||||
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
@@ -491,6 +714,7 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
|
||||
async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
|
||||
"""
|
||||
Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
|
||||
Работает с 3x-ui и Remnawave.
|
||||
|
||||
Args:
|
||||
cluster_id (str): ID кластера или имя сервера
|
||||
@@ -513,24 +737,56 @@ async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
api_url = server_info["api_url"]
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
remnawave_done = False
|
||||
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
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:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT client_id FROM keys WHERE email = $1 AND server_id = $2 LIMIT 1",
|
||||
email,
|
||||
cluster_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if not row:
|
||||
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
|
||||
continue
|
||||
|
||||
client_id = row["client_id"]
|
||||
|
||||
remna = RemnawaveAPI(api_url)
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if not logged_in:
|
||||
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
|
||||
continue
|
||||
|
||||
tasks.append(remna.reset_user_traffic(client_id))
|
||||
remnawave_done = True
|
||||
continue
|
||||
|
||||
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
|
||||
if panel_type == "3x-ui":
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
continue
|
||||
|
||||
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
|
||||
xui = await get_xui_instance(api_url)
|
||||
|
||||
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
|
||||
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"✅ Трафик клиента {email} успешно сброшен на всех серверах кластера {cluster_id}")
|
||||
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
|
||||
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
|
||||
QRCODE,
|
||||
TOGGLE_CLIENT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import (
|
||||
get_key_details,
|
||||
get_keys,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
ADD_SUB,
|
||||
ALIAS,
|
||||
BACK,
|
||||
CHANGE_LOCATION,
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
DELETE,
|
||||
FREEZE,
|
||||
MAIN_MENU,
|
||||
PC_BUTTON,
|
||||
QR,
|
||||
RENEW,
|
||||
RENEW_FULL,
|
||||
TV_BUTTON,
|
||||
UNFREEZE,
|
||||
)
|
||||
from handlers.texts import (
|
||||
FROZEN_SUBSCRIPTION_MSG,
|
||||
NO_SUBSCRIPTIONS_MSG,
|
||||
key_message,
|
||||
)
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
format_days,
|
||||
format_hours,
|
||||
format_minutes,
|
||||
get_russian_month,
|
||||
handle_error,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class RenameKeyState(StatesGroup):
|
||||
waiting_for_new_alias = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "view_keys")
|
||||
@router.message(F.text == "/subs")
|
||||
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
target_message = callback_query_or_message
|
||||
|
||||
try:
|
||||
records = await get_keys(target_message.chat.id, session)
|
||||
inline_keyboard, response_message = build_keys_response(records)
|
||||
image_path = os.path.join("img", "pic_keys.jpg")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении ключей: {e}"
|
||||
await target_message.answer(text=error_message)
|
||||
|
||||
|
||||
def build_keys_response(records):
|
||||
"""
|
||||
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
if records:
|
||||
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
alias = record.get("alias")
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record.get("expiry_time")
|
||||
|
||||
key_display = html.escape(alias.strip() if alias else email)
|
||||
|
||||
if expiry_time:
|
||||
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
|
||||
else:
|
||||
formatted_date_full = "без срока действия"
|
||||
|
||||
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
|
||||
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
|
||||
builder.row(key_button, rename_button)
|
||||
|
||||
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
|
||||
|
||||
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
|
||||
else:
|
||||
response_message = NO_SUBSCRIPTIONS_MSG
|
||||
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
inline_keyboard = builder.as_markup()
|
||||
return inline_keyboard, response_message
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("rename_key|"))
|
||||
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
|
||||
client_id = callback.data.split("|")[1]
|
||||
await state.set_state(RenameKeyState.waiting_for_new_alias)
|
||||
await state.update_data(client_id=client_id, target_message=callback.message)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text="✏️ Введите новое имя подписки (до 10 символов):",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
|
||||
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
|
||||
alias = message.text.strip()
|
||||
|
||||
if len(alias) > 10:
|
||||
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
|
||||
return
|
||||
|
||||
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
await message.answer(
|
||||
"❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод."
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
client_id = data.get("client_id")
|
||||
|
||||
try:
|
||||
await session.execute(
|
||||
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
|
||||
alias,
|
||||
message.chat.id,
|
||||
client_id,
|
||||
)
|
||||
except Exception as e:
|
||||
await message.answer("❌ Не удалось переименовать подписку.")
|
||||
logger.error(f"Ошибка при обновлении alias: {e}")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
await process_callback_or_message_view_keys(message, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("view_key|"))
|
||||
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
|
||||
return
|
||||
|
||||
is_frozen = record["is_frozen"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
if not os.path.isfile(image_path):
|
||||
await callback_query.message.answer("Файл изображения не найден.")
|
||||
return
|
||||
|
||||
if is_frozen:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=UNFREEZE,
|
||||
callback_data=f"unfreeze_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=FROZEN_SUBSCRIPTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
return
|
||||
|
||||
key = record.get("key")
|
||||
remnawave_link = record.get("remnawave_link")
|
||||
final_link = key or remnawave_link
|
||||
|
||||
expiry_time = record["expiry_time"]
|
||||
server_name = record["server_id"]
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
time_left = expiry_date - datetime.utcnow()
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла"
|
||||
else:
|
||||
total_seconds = int(time_left.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
days_left_message = (
|
||||
f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
|
||||
)
|
||||
|
||||
formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
|
||||
response_message = key_message(
|
||||
final_link,
|
||||
formatted_expiry_date,
|
||||
days_left_message,
|
||||
server_name,
|
||||
server_name if USE_COUNTRY_SELECTION else None,
|
||||
)
|
||||
|
||||
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
|
||||
if is_full_remnawave and final_link:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
web_app=WebAppInfo(url=final_link),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_PHONE,
|
||||
callback_data=f"connect_phone|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
if QRCODE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=QR,
|
||||
callback_data=f"show_qr|{key_name}",
|
||||
)
|
||||
)
|
||||
if ENABLE_DELETE_KEY_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
|
||||
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
|
||||
|
||||
if TOGGLE_CLIENT:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=FREEZE,
|
||||
callback_data=f"freeze_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
except Exception as e:
|
||||
await handle_error(
|
||||
tg_id,
|
||||
callback_query,
|
||||
f"Ошибка при получении информации о ключе: {e}",
|
||||
)
|
||||
@@ -1,754 +1,37 @@
|
||||
import asyncio
|
||||
import html
|
||||
import locale
|
||||
import os
|
||||
import time
|
||||
import re
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
import qrcode
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
DATABASE_URL,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
|
||||
PUBLIC_LINK,
|
||||
QRCODE,
|
||||
RENEWAL_PLANS,
|
||||
TOGGLE_CLIENT,
|
||||
TOTAL_GB,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
INSTRUCTIONS_BUTTON
|
||||
)
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
create_temporary_data,
|
||||
delete_key,
|
||||
get_balance,
|
||||
get_key_by_server,
|
||||
get_key_details,
|
||||
get_keys,
|
||||
get_servers,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
ADD_SUB,
|
||||
ALIAS,
|
||||
ANDROID,
|
||||
APPLY,
|
||||
BACK,
|
||||
CANCEL,
|
||||
CHANGE_LOCATION,
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
DELETE,
|
||||
DOWNLOAD_ANDROID_BUTTON,
|
||||
DOWNLOAD_IOS_BUTTON,
|
||||
FREEZE,
|
||||
IMPORT_ANDROID,
|
||||
IMPORT_IOS,
|
||||
IPHONE,
|
||||
MAIN_MENU,
|
||||
MANUAL_INSTRUCTIONS,
|
||||
PAYMENT,
|
||||
PC,
|
||||
PC_BUTTON,
|
||||
QR,
|
||||
RENEW,
|
||||
RENEW_FULL,
|
||||
TV,
|
||||
TV_BUTTON,
|
||||
UNFREEZE,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
toggle_client_on_cluster,
|
||||
update_subscription,
|
||||
)
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.keys.key_view import process_callback_view_key
|
||||
from handlers.texts import (
|
||||
ANDROID_DESCRIPTION_TEMPLATE,
|
||||
CHOOSE_DEVICE_TEXT,
|
||||
DELETE_KEY_CONFIRM_MSG,
|
||||
DISCOUNTS,
|
||||
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
FROZEN_SUBSCRIPTION_MSG,
|
||||
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||
IOS_DESCRIPTION_TEMPLATE,
|
||||
KEY_DELETED_MSG_SIMPLE,
|
||||
KEY_NOT_FOUND_MSG,
|
||||
NO_SUBSCRIPTIONS_MSG,
|
||||
PLAN_SELECTION_MSG,
|
||||
SUBSCRIPTION_DESCRIPTION,
|
||||
SUBSCRIPTION_FROZEN_MSG,
|
||||
SUBSCRIPTION_UNFROZEN_MSG,
|
||||
SUCCESS_RENEWAL_MSG,
|
||||
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
key_message,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, handle_error
|
||||
from logger import logger
|
||||
|
||||
|
||||
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class RenameKeyState(StatesGroup):
|
||||
waiting_for_new_alias = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "view_keys")
|
||||
@router.message(F.text == "/subs")
|
||||
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
target_message = callback_query_or_message
|
||||
|
||||
try:
|
||||
records = await get_keys(target_message.chat.id, session)
|
||||
inline_keyboard, response_message = build_keys_response(records)
|
||||
image_path = os.path.join("img", "pic_keys.jpg")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении ключей: {e}"
|
||||
await target_message.answer(text=error_message)
|
||||
|
||||
|
||||
def build_keys_response(records):
|
||||
"""
|
||||
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
if records:
|
||||
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
alias = record.get("alias")
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record.get("expiry_time")
|
||||
|
||||
key_display = html.escape(alias.strip() if alias else email)
|
||||
|
||||
if expiry_time:
|
||||
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
|
||||
else:
|
||||
formatted_date_full = "без срока действия"
|
||||
|
||||
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
|
||||
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
|
||||
builder.row(key_button, rename_button)
|
||||
|
||||
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
|
||||
|
||||
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
|
||||
else:
|
||||
response_message = NO_SUBSCRIPTIONS_MSG
|
||||
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
inline_keyboard = builder.as_markup()
|
||||
return inline_keyboard, response_message
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("rename_key|"))
|
||||
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
|
||||
client_id = callback.data.split("|")[1]
|
||||
await state.set_state(RenameKeyState.waiting_for_new_alias)
|
||||
await state.update_data(client_id=client_id, target_message=callback.message)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text="✏️ Введите новое имя подписки (до 10 символов):",
|
||||
reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
|
||||
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
|
||||
alias = message.text.strip()
|
||||
|
||||
if len(alias) > 10:
|
||||
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
|
||||
return
|
||||
|
||||
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
await message.answer("❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод.")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
client_id = data.get("client_id")
|
||||
|
||||
try:
|
||||
await session.execute(
|
||||
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
|
||||
alias,
|
||||
message.chat.id,
|
||||
client_id,
|
||||
)
|
||||
except Exception as e:
|
||||
await message.answer("❌ Не удалось переименовать подписку.")
|
||||
logger.error(f"Ошибка при обновлении alias: {e}")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
await process_callback_or_message_view_keys(message, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("view_key|"))
|
||||
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if record:
|
||||
is_frozen = record["is_frozen"]
|
||||
|
||||
if is_frozen:
|
||||
response_message = FROZEN_SUBSCRIPTION_MSG
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=UNFREEZE,
|
||||
callback_data=f"unfreeze_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
keyboard = builder.as_markup()
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
await callback_query.message.answer("Файл изображения не найден.")
|
||||
return
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=keyboard,
|
||||
media_path=image_path,
|
||||
)
|
||||
|
||||
else:
|
||||
key = record["key"]
|
||||
expiry_time = record["expiry_time"]
|
||||
server_name = record["server_id"]
|
||||
country = server_name
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0\nОсталось минут: 0"
|
||||
else:
|
||||
total_seconds = int(time_left.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
days_left_message = f"Осталось: <b>{days}</b> дней, <b>{hours}</b> часов, <b>{minutes}</b> минут"
|
||||
|
||||
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
|
||||
response_message = key_message(
|
||||
key,
|
||||
formatted_expiry_date,
|
||||
days_left_message,
|
||||
server_name,
|
||||
country if USE_COUNTRY_SELECTION else None,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if not key.startswith(PUBLIC_LINK) or ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_PHONE,
|
||||
callback_data=f"connect_phone|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
if QRCODE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=QR,
|
||||
callback_data=f"show_qr|{key_name}",
|
||||
)
|
||||
)
|
||||
if ENABLE_DELETE_KEY_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
|
||||
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
|
||||
|
||||
if TOGGLE_CLIENT:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=FREEZE,
|
||||
callback_data=f"freeze_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
keyboard = builder.as_markup()
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
await callback_query.message.answer("Файл изображения не найден.")
|
||||
return
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=keyboard,
|
||||
media_path=image_path,
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
|
||||
except Exception as e:
|
||||
await handle_error(
|
||||
tg_id,
|
||||
callback_query,
|
||||
f"Ошибка при получении информации о ключе: {e}",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("show_qr|"))
|
||||
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
|
||||
try:
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
|
||||
if not record:
|
||||
await callback_query.message.answer("❌ Подписка не найдена.")
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(record["key"])
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
qr_path = f"/tmp/qrcode_{record['email']}.png"
|
||||
with open(qr_path, "wb") as f:
|
||||
f.write(buffer.read())
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="🔲 <b>Ваш QR-код для подключения</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=qr_path,
|
||||
)
|
||||
|
||||
os.remove(qr_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_device|"))
|
||||
async def handle_connect_device(callback_query: CallbackQuery):
|
||||
try:
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
|
||||
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CHOOSE_DEVICE_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
|
||||
logger.error(f"Ошибка в handle_connect_device: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
|
||||
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=APPLY,
|
||||
callback_data=f"unfreeze_subscription_confirm|{key_name}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=CANCEL,
|
||||
callback_data=f"view_key|{key_name}",
|
||||
),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=confirm_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
|
||||
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Размораживает (включает) подписку.
|
||||
"""
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
|
||||
if result["status"] == "success":
|
||||
now_ms = int(time.time() * 1000)
|
||||
leftover = record["expiry_time"]
|
||||
if leftover < 0:
|
||||
leftover = 0
|
||||
|
||||
new_expiry_time = now_ms + leftover
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = $1,
|
||||
is_frozen = FALSE
|
||||
WHERE tg_id = $2
|
||||
AND client_id = $3
|
||||
""",
|
||||
new_expiry_time,
|
||||
record["tg_id"],
|
||||
client_id,
|
||||
)
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=cluster_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=TOTAL_GB,
|
||||
)
|
||||
text_ok = SUBSCRIPTION_UNFROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
text_error = (
|
||||
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_error,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freeze_subscription|"))
|
||||
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
|
||||
"""
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=APPLY,
|
||||
callback_data=f"freeze_subscription_confirm|{key_name}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=CANCEL,
|
||||
callback_data=f"view_key|{key_name}",
|
||||
),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=confirm_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
|
||||
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Замораживает (отключает) подписку.
|
||||
"""
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
|
||||
|
||||
if result["status"] == "success":
|
||||
now_ms = int(time.time() * 1000)
|
||||
time_left = record["expiry_time"] - now_ms
|
||||
if time_left < 0:
|
||||
time_left = 0
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = $1,
|
||||
is_frozen = TRUE
|
||||
WHERE tg_id = $2
|
||||
AND client_id = $3
|
||||
""",
|
||||
time_left,
|
||||
record["tg_id"],
|
||||
client_id,
|
||||
)
|
||||
|
||||
text_ok = SUBSCRIPTION_FROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
text_error = (
|
||||
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_error,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_phone|"))
|
||||
async def process_callback_connect_phone(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow(
|
||||
"""
|
||||
SELECT key FROM keys WHERE email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
|
||||
)
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_ios|"))
|
||||
async def process_callback_connect_ios(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
|
||||
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=description,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_android|"))
|
||||
async def process_callback_connect_android(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
|
||||
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=description,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("update_subscription|"))
|
||||
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
@@ -815,7 +98,7 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
|
||||
servers = await get_servers(session)
|
||||
|
||||
async def delete_key_from_servers():
|
||||
try: # lol
|
||||
try:
|
||||
tasks = []
|
||||
for cluster_id, _cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
@@ -835,204 +118,3 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for plan_id, plan_details in RENEWAL_PLANS.items():
|
||||
months = plan_details["months"]
|
||||
price = plan_details["price"]
|
||||
|
||||
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
|
||||
|
||||
button_text = f"📅 {months} месяц{'а' if months > 1 else ''} ({price} руб.)"
|
||||
if discount > 0:
|
||||
button_text += f" {discount}% скидка"
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"renew_plan|{months}|{client_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer("<b>Ключ не найден.</b>")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в process_callback_renew_key: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
|
||||
days_to_extend = 30 * int(plan)
|
||||
|
||||
gb_multiplier = {"1": 1, "3": 3, "6": 6, "12": 12}
|
||||
total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0
|
||||
|
||||
try:
|
||||
record = await get_key_by_server(tg_id, client_id, session)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
|
||||
if expiry_time <= current_time:
|
||||
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
else:
|
||||
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
|
||||
cost = RENEWAL_PLANS[plan]["price"]
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
if balance < cost:
|
||||
required_amount = cost - balance
|
||||
|
||||
logger.info(
|
||||
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
|
||||
)
|
||||
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"plan": plan,
|
||||
"client_id": client_id,
|
||||
"cost": cost,
|
||||
"required_amount": required_amount,
|
||||
"new_expiry_time": new_expiry_time,
|
||||
"total_gb": total_gb,
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
else:
|
||||
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
|
||||
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
|
||||
|
||||
else:
|
||||
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
|
||||
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
|
||||
|
||||
|
||||
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
|
||||
logger.info(
|
||||
f"[RENEW] Начинаю процесс продления ключа с параметрами: "
|
||||
f"tg_id={tg_id}, client_id={client_id}, email={email}, "
|
||||
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}, cost={cost}, "
|
||||
f"callback_query={'есть' if callback_query else 'отсутствует'}, plan={plan}"
|
||||
)
|
||||
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(months=plan)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if callback_query:
|
||||
try:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка редактирования сообщения в complete_key_renewal: {e}")
|
||||
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
|
||||
else:
|
||||
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
logger.info(f"[RENEW] Получение данных о ключе для email: {email}")
|
||||
key_info = await get_key_details(email, conn)
|
||||
if not key_info:
|
||||
logger.error(f"[RENEW] Ключ с client_id {client_id} для пользователя {tg_id} не найден.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
server_id = key_info["server_id"]
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
logger.info(f"[RENEW] USE_COUNTRY_SELECTION включён. Проверяю информацию о сервере {server_id}")
|
||||
cluster_info = await check_server_name_by_cluster(server_id, conn)
|
||||
if not cluster_info:
|
||||
logger.error(f"[RENEW] Сервер {server_id} не найден в таблице servers.")
|
||||
await conn.close()
|
||||
return
|
||||
cluster_id = cluster_info["cluster_name"]
|
||||
logger.info(f"[RENEW] Информация о сервере получена: {cluster_info}. Использую cluster_id: {cluster_id}")
|
||||
else:
|
||||
cluster_id = server_id
|
||||
logger.info(f"[RENEW] USE_COUNTRY_SELECTION выключен. Использую server_id в качестве cluster_id: {cluster_id}")
|
||||
|
||||
logger.info(f"[RENEW] Запуск продления ключа для пользователя {tg_id} на {plan} мес. в кластере {cluster_id}.")
|
||||
|
||||
async def renew_key_on_cluster():
|
||||
logger.info(
|
||||
f"[RENEW] Запуск renew_key_on_cluster с параметрами: "
|
||||
f"cluster_id={cluster_id}, email={email}, client_id={client_id}, "
|
||||
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}"
|
||||
)
|
||||
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
|
||||
logger.info("[RENEW] Продление ключа на сервере завершено. Обновляю срок действия в базе данных.")
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
logger.info("[RENEW] Срок действия ключа обновлён. Обновляю баланс пользователя.")
|
||||
await update_balance(tg_id, -cost, conn)
|
||||
logger.info(f"[RENEW] Ключ {client_id} успешно продлён на {plan} мес. для пользователя {tg_id}.")
|
||||
|
||||
logger.info("[RENEW] Инициализация процесса продления ключа в кластере.")
|
||||
await renew_key_on_cluster()
|
||||
|
||||
logger.info("[RENEW] Процесс продления ключа завершён. Закрываю соединение с базой данных.")
|
||||
await conn.close()
|
||||
|
||||
@@ -5,11 +5,8 @@ import re
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -19,7 +16,6 @@ from config import (
|
||||
SUPERNODE,
|
||||
SUPPORT_CHAT_URL,
|
||||
TOTAL_GB,
|
||||
TRANSITION_DATE_STR,
|
||||
USERNAME_BOT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
@@ -29,148 +25,67 @@ from logger import logger
|
||||
|
||||
|
||||
async def fetch_url_content(url: str, identifier: str) -> list[str]:
|
||||
"""
|
||||
Получает содержимое подписки по URL и декодирует его.
|
||||
|
||||
Args:
|
||||
url: URL для получения содержимого
|
||||
identifier: Идентификатор пользователя (tg_id или email)
|
||||
|
||||
Returns:
|
||||
Список строк из подписки
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Получение URL: {url} для идентификатора: {identifier}")
|
||||
timeout = aiohttp.ClientTimeout(total=5)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, ssl=False) as response:
|
||||
if response.status == 200:
|
||||
content = await response.text()
|
||||
logger.info(f"Успешно получен контент с {url} для идентификатора: {identifier}")
|
||||
return base64.b64decode(content).decode("utf-8").split("\n")
|
||||
else:
|
||||
logger.error(
|
||||
f"Не удалось получить {url} для идентификатора: {identifier}, статус: {response.status}"
|
||||
)
|
||||
return []
|
||||
except TimeoutError:
|
||||
logger.error(f"Таймаут при получении {url} для идентификатора: {identifier}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении {url} для идентификатора: {identifier}: {e}")
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]:
|
||||
"""
|
||||
Объединяет строки подписки из нескольких URL, удаляя дубликаты.
|
||||
|
||||
Args:
|
||||
urls: Список URL для получения подписок
|
||||
identifier: Идентификатор пользователя (tg_id или email)
|
||||
query_string: Строка запроса для добавления к URL
|
||||
|
||||
Returns:
|
||||
Список уникальных строк из всех подписок
|
||||
"""
|
||||
if SUPERNODE:
|
||||
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
|
||||
if not urls:
|
||||
return []
|
||||
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
|
||||
return await fetch_url_content(url_with_query, identifier)
|
||||
|
||||
logger.info(f"Начинаем объединение подписок для идентификатора: {identifier}, запрос: {query_string}")
|
||||
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
|
||||
logger.info(f"Составлены URL-адреса: {urls_with_query}")
|
||||
|
||||
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
all_lines = set()
|
||||
for lines in results:
|
||||
all_lines.update(filter(None, lines))
|
||||
logger.info(
|
||||
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для идентификатора: {identifier}"
|
||||
)
|
||||
return list(all_lines)
|
||||
|
||||
|
||||
async def get_subscription_urls(server_id: str, email: str, conn) -> list[str]:
|
||||
"""
|
||||
Получает список URL-адресов для подписки в зависимости от режима выбора страны.
|
||||
|
||||
Args:
|
||||
server_id: Идентификатор сервера или кластера
|
||||
email: Email пользователя
|
||||
conn: Соединение с базой данных
|
||||
|
||||
Returns:
|
||||
Список URL-адресов для подписки
|
||||
"""
|
||||
async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]:
|
||||
urls = []
|
||||
if USE_COUNTRY_SELECTION:
|
||||
logger.info(f"Режим выбора страны активен. Ищем сервер {server_id} в БД.")
|
||||
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
|
||||
if not server_data:
|
||||
logger.warning(f"Не найден сервер {server_id} в БД!")
|
||||
return []
|
||||
subscription_url = server_data["subscription_url"]
|
||||
urls = [f"{subscription_url}/{email}"]
|
||||
logger.info(f"Используем подписку {urls[0]}")
|
||||
return urls
|
||||
if server_data and server_data["subscription_url"]:
|
||||
urls.append(f"{server_data['subscription_url']}/{email}")
|
||||
else:
|
||||
servers = await get_servers(conn)
|
||||
cluster_servers = servers.get(server_id, [])
|
||||
for server in cluster_servers:
|
||||
if url := server.get("subscription_url"):
|
||||
urls.append(f"{url}/{email}")
|
||||
|
||||
if include_remnawave_key:
|
||||
urls.append(include_remnawave_key)
|
||||
|
||||
servers = await get_servers(conn)
|
||||
logger.info(f"Режим выбора страны отключен. Используем кластер {server_id}.")
|
||||
cluster_servers = servers.get(server_id, [])
|
||||
if not cluster_servers:
|
||||
logger.warning(f"Не найдены сервера для {server_id}")
|
||||
return []
|
||||
urls = [f"{server['subscription_url']}/{email}" for server in cluster_servers]
|
||||
logger.info(f"Найдено {len(urls)} URL-адресов в кластере {server_id}")
|
||||
return urls
|
||||
|
||||
|
||||
def get_transition_timestamp() -> int:
|
||||
"""
|
||||
Получает временную метку перехода с учетом часового пояса Москвы.
|
||||
|
||||
Returns:
|
||||
Временная метка перехода в миллисекундах
|
||||
"""
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
transition_date_naive = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S")
|
||||
transition_date = moscow_tz.localize(transition_date_naive)
|
||||
transition_timestamp_ms = int(transition_date.timestamp() * 1000)
|
||||
return transition_timestamp_ms
|
||||
|
||||
|
||||
def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None) -> str:
|
||||
"""
|
||||
Рассчитывает информацию о трафике на основе подписок.
|
||||
|
||||
Args:
|
||||
cleaned_subscriptions: Список строк подписки
|
||||
expiry_time_ms: Время истечения подписки в миллисекундах
|
||||
|
||||
Returns:
|
||||
Строка с информацией о трафике
|
||||
"""
|
||||
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
|
||||
|
||||
if TOTAL_GB != 0:
|
||||
country_remaining = {}
|
||||
for line in cleaned_subscriptions:
|
||||
if "#" not in line:
|
||||
continue
|
||||
|
||||
try:
|
||||
_, meta = line.split("#", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
parts = meta.split("-")
|
||||
country = parts[0].strip()
|
||||
remaining_str = parts[1].strip() if len(parts) == 2 else ""
|
||||
|
||||
if remaining_str:
|
||||
remaining_str = remaining_str.replace(",", ".")
|
||||
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
|
||||
@@ -179,89 +94,50 @@ def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | No
|
||||
unit = m_total.group(2).upper()
|
||||
remaining_bytes = convert_to_bytes(value, unit)
|
||||
country_remaining[country] = remaining_bytes
|
||||
|
||||
num_countries = len(country_remaining)
|
||||
issued_per_country = TOTAL_GB
|
||||
total_traffic_bytes = issued_per_country * num_countries
|
||||
consumed_traffic_bytes = total_traffic_bytes - sum(country_remaining.values())
|
||||
|
||||
if consumed_traffic_bytes < 0:
|
||||
consumed_traffic_bytes = 0
|
||||
else:
|
||||
consumed_traffic_bytes = 1
|
||||
total_traffic_bytes = 0
|
||||
|
||||
return f"upload=0; download={consumed_traffic_bytes}; total={total_traffic_bytes}; expire={expire_timestamp}"
|
||||
|
||||
|
||||
def clean_subscription_line(line: str) -> str:
|
||||
"""
|
||||
Очищает строку подписки, оставляя только нужную информацию.
|
||||
|
||||
Args:
|
||||
line: Исходная строка подписки
|
||||
|
||||
Returns:
|
||||
Очищенная строка подписки
|
||||
"""
|
||||
if "#" not in line:
|
||||
return line
|
||||
|
||||
try:
|
||||
base, meta = line.split("#", 1)
|
||||
except ValueError:
|
||||
return line
|
||||
|
||||
parts = meta.split("-")
|
||||
country = parts[0].strip() if parts else ""
|
||||
traffic = ""
|
||||
|
||||
for part in parts[1:]:
|
||||
part_decoded = urllib.parse.unquote(part).strip()
|
||||
if re.search(r"\d+(?:[.,]\d+)?\s*(?:GB|MB|KB|TB)", part_decoded, re.IGNORECASE):
|
||||
traffic = part_decoded
|
||||
break
|
||||
|
||||
meta_clean = f"{country} - {traffic}" if traffic else country
|
||||
return base + "#" + meta_clean
|
||||
|
||||
|
||||
def format_time_left(expiry_time_ms: int | None) -> str:
|
||||
"""
|
||||
Форматирует оставшееся время подписки.
|
||||
|
||||
Args:
|
||||
expiry_time_ms: Время истечения подписки в миллисекундах
|
||||
|
||||
Returns:
|
||||
Отформатированная строка с оставшимся временем
|
||||
"""
|
||||
if not expiry_time_ms:
|
||||
return "N/A"
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
remaining_sec = max((expiry_time_ms - now_ms) / 1000, 0)
|
||||
days = int(remaining_sec // 86400)
|
||||
hours = int((remaining_sec % 86400) // 3600)
|
||||
|
||||
return f"{days}D,{hours}H ⏳" if days else f"{hours}H ⏳"
|
||||
|
||||
|
||||
def prepare_headers(
|
||||
user_agent: str, project_name: str, subscription_info: str, subscription_userinfo: str
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Подготавливает заголовки ответа в зависимости от User-Agent клиента.
|
||||
|
||||
Args:
|
||||
user_agent: User-Agent клиента
|
||||
project_name: Название проекта
|
||||
subscription_info: Информация о подписке
|
||||
subscription_userinfo: Информация о трафике
|
||||
|
||||
Returns:
|
||||
Словарь с заголовками ответа
|
||||
"""
|
||||
if "Happ" in user_agent:
|
||||
encoded_project_name = f"{project_name}"
|
||||
announce_str = f"↖️Бот | {subscription_info} | Поддержка↗️"
|
||||
@@ -278,13 +154,23 @@ def prepare_headers(
|
||||
elif "Hiddify" in user_agent:
|
||||
parts = subscription_info.split(" - ")[0].split(": ")
|
||||
key_info = parts[1] if len(parts) > 1 else parts[0]
|
||||
|
||||
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
|
||||
return {
|
||||
"profile-update-interval": "3",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"subscription-userinfo": subscription_userinfo,
|
||||
}
|
||||
elif "v2raytun" in user_agent:
|
||||
encoded_project_name = f"{project_name}\n{subscription_info}"
|
||||
announce_str = "🔑 Выберите сервер ⬇️ | 💬 Поддержка ➡️"
|
||||
return {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"update-always": "true",
|
||||
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
|
||||
"announce-url": f"{SUPPORT_CHAT_URL}",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
}
|
||||
else:
|
||||
encoded_project_name = f"{project_name}\n{subscription_info}"
|
||||
return {
|
||||
@@ -295,62 +181,36 @@ def prepare_headers(
|
||||
}
|
||||
|
||||
|
||||
async def handle_subscription(request: web.Request, old_subscription: bool = False) -> web.Response:
|
||||
"""
|
||||
Обрабатывает запрос на подписку (старую или новую).
|
||||
|
||||
Args:
|
||||
request: Объект запроса
|
||||
old_subscription: Флаг, указывающий на тип подписки (старая или новая)
|
||||
|
||||
Returns:
|
||||
Ответ с подпиской или сообщением об ошибке
|
||||
"""
|
||||
async def handle_subscription(request: web.Request) -> web.Response:
|
||||
email = request.match_info.get("email")
|
||||
tg_id = request.match_info.get("tg_id") if not old_subscription else None
|
||||
tg_id = request.match_info.get("tg_id")
|
||||
|
||||
if not email or (not old_subscription and not tg_id):
|
||||
logger.warning("Получен запрос с отсутствующими параметрами")
|
||||
if not email or not tg_id:
|
||||
return web.Response(text="❌ Неверные параметры запроса.", status=400)
|
||||
|
||||
logger.info(
|
||||
f"Обработка запроса для {'старого' if old_subscription else 'нового'} клиента: email={email}, tg_id={tg_id}"
|
||||
)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
client_data = await get_key_details(email, conn)
|
||||
if not client_data:
|
||||
logger.warning(f"Клиент с email {email} не найден в базе.")
|
||||
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
|
||||
|
||||
stored_tg_id = client_data.get("tg_id")
|
||||
server_id = client_data["server_id"]
|
||||
|
||||
if not old_subscription and int(tg_id) != int(stored_tg_id):
|
||||
logger.warning(f"Неверный tg_id для клиента с email {email}.")
|
||||
if int(tg_id) != int(stored_tg_id):
|
||||
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
|
||||
|
||||
if old_subscription:
|
||||
created_at_ms = client_data["created_at"]
|
||||
created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000)
|
||||
logger.info(f"created_at для {email}: {created_at_datetime}, server_id: {server_id}")
|
||||
|
||||
transition_timestamp_ms = get_transition_timestamp()
|
||||
logger.info(f"Время перехода (с учетом часового пояса Москвы): {transition_timestamp_ms}")
|
||||
|
||||
if created_at_ms >= transition_timestamp_ms:
|
||||
logger.info(f"Клиент с email {email} является новым.")
|
||||
return web.Response(text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.", status=400)
|
||||
|
||||
expiry_time_ms = client_data.get("expiry_time")
|
||||
time_left = format_time_left(expiry_time_ms)
|
||||
|
||||
urls = await get_subscription_urls(server_id, email, conn)
|
||||
urls = await get_subscription_urls(
|
||||
server_id, email, conn, include_remnawave_key=client_data.get("remnawave_link")
|
||||
)
|
||||
|
||||
if not urls:
|
||||
return web.Response(text="❌ Сервер не найден.", status=404)
|
||||
|
||||
query_string = request.query_string if not old_subscription else ""
|
||||
query_string = request.query_string
|
||||
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string)
|
||||
random.shuffle(combined_subscriptions)
|
||||
|
||||
@@ -363,17 +223,6 @@ async def handle_subscription(request: web.Request, old_subscription: bool = Fal
|
||||
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms)
|
||||
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
|
||||
|
||||
logger.info(f"Возвращаем объединенные подписки для email: {email}")
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def handle_old_subscription(request: web.Request) -> web.Response:
|
||||
"""Обработка запроса для старых клиентов."""
|
||||
return await handle_subscription(request, old_subscription=True)
|
||||
|
||||
|
||||
async def handle_new_subscription(request: web.Request) -> web.Response:
|
||||
"""Обработка запроса для новых клиентов."""
|
||||
return await handle_subscription(request, old_subscription=False)
|
||||
|
||||
@@ -23,13 +23,14 @@ from config import (
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
check_notifications_bulk,
|
||||
delete_key,
|
||||
delete_notification,
|
||||
get_all_keys,
|
||||
get_balance,
|
||||
get_last_notification_time,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
delete_notification
|
||||
)
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
|
||||
from handlers.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
|
||||
@@ -44,9 +45,10 @@ from handlers.texts import (
|
||||
KEY_RENEWED,
|
||||
KEY_RENEWED_TEMP_MSG,
|
||||
)
|
||||
from handlers.utils import format_hours, format_minutes
|
||||
from logger import logger
|
||||
|
||||
from .notify_utils import send_notification
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
|
||||
|
||||
|
||||
@@ -65,7 +67,7 @@ async def periodic_notifications(bot: Bot):
|
||||
"""
|
||||
while True:
|
||||
if notification_lock.locked():
|
||||
logger.warning("⛔ Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
|
||||
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
continue
|
||||
|
||||
@@ -78,7 +80,7 @@ async def periodic_notifications(bot: Bot):
|
||||
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
|
||||
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
logger.info("🚀 Запуск обработки уведомлений")
|
||||
logger.info("Запуск обработки уведомлений")
|
||||
|
||||
try:
|
||||
keys = await get_all_keys(session=conn)
|
||||
@@ -101,19 +103,22 @@ async def periodic_notifications(bot: Bot):
|
||||
await notify_users_no_traffic(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.info("✅ Завершена обработка уведомлений")
|
||||
logger.info("Завершена обработка уведомлений")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в periodic_notifications: {e}")
|
||||
logger.error(f"Ошибка в periodic_notifications: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("🔌 Соединение с базой данных закрыто.")
|
||||
logger.info("Соединение с базой данных закрыто.")
|
||||
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
|
||||
|
||||
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_24h: int, keys: list):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 24 часа.")
|
||||
|
||||
expiring_keys = [
|
||||
@@ -121,24 +126,29 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_24h", 24, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
notification_id = f"{email}_key_24h"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
days_left_message = (
|
||||
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
@@ -146,7 +156,7 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
|
||||
notification_text = KEY_EXPIRY_24H.format(
|
||||
email=email,
|
||||
days_left_message=days_left_message,
|
||||
hours_left_formatted=hours_left_formatted,
|
||||
formatted_expiry_date=formatted_expiry_date,
|
||||
)
|
||||
|
||||
@@ -154,14 +164,19 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
try:
|
||||
await send_notification(bot, tg_id, "notify_24h.jpg", notification_text, keyboard)
|
||||
logger.info(f"Отправлено уведомление об истечении подписки через 24 часа для пользователя {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_24h.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
logger.info("✅ Обработка всех уведомлений за 24 часа завершена.")
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 24 часа.")
|
||||
|
||||
logger.info("Обработка всех уведомлений за 24 часа завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
@@ -176,24 +191,29 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_10h", 10, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
notification_id = f"{email}_key_10h"
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_message = (
|
||||
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
@@ -201,7 +221,7 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
|
||||
notification_text = KEY_EXPIRY_10H.format(
|
||||
email=email,
|
||||
hours_left_message=hours_left_message,
|
||||
hours_left_formatted=hours_left_formatted,
|
||||
formatted_expiry_date=formatted_expiry_date,
|
||||
)
|
||||
|
||||
@@ -214,14 +234,19 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
try:
|
||||
await send_notification(bot, tg_id, "notify_10h.jpg", notification_text, keyboard)
|
||||
logger.info(f"Отправлено уведомление об истечении подписки через 10 часов для пользователя {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_10h.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
logger.info("✅ Обработка всех уведомлений за 10 часов завершена.")
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 10 часов.")
|
||||
|
||||
logger.info("Обработка всех уведомлений за 10 часов завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
@@ -234,6 +259,12 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
|
||||
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expired_keys]
|
||||
emails = [key.get("email", "") for key in expired_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_expired", 0, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expired_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
@@ -241,11 +272,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
server_id = key.get("server_id")
|
||||
notification_id = f"{email}_key_expired"
|
||||
|
||||
try:
|
||||
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения времени последнего уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
|
||||
|
||||
if NOTIFY_RENEW_EXPIRED:
|
||||
try:
|
||||
@@ -284,22 +311,17 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
try:
|
||||
await send_notification(
|
||||
bot,
|
||||
tg_id,
|
||||
"notify_expired.jpg",
|
||||
KEY_DELETED_MSG.format(email=email),
|
||||
keyboard,
|
||||
)
|
||||
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление об удалении пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": KEY_DELETED_MSG.format(email=email),
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if last_notification_time is None:
|
||||
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
|
||||
keyboard = build_notification_kb(email)
|
||||
|
||||
if NOTIFY_DELETE_DELAY > 0:
|
||||
@@ -308,45 +330,38 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
|
||||
if hours > 0:
|
||||
if minutes > 0:
|
||||
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
|
||||
time_str = f"{hours} {hour_suffix} и {minutes} минут"
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(email=email, time_str=time_str)
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
|
||||
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
|
||||
)
|
||||
else:
|
||||
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
|
||||
time_str = f"{hours} {hour_suffix}"
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(email=email, time_str=time_str)
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
|
||||
email=email, hours_formatted=format_hours(hours)
|
||||
)
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(email=email, minutes=NOTIFY_DELETE_DELAY)
|
||||
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
|
||||
email=email, minutes_formatted=format_minutes(minutes)
|
||||
)
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
|
||||
|
||||
try:
|
||||
await send_notification(
|
||||
bot,
|
||||
tg_id,
|
||||
"notify_expired.jpg",
|
||||
delay_message,
|
||||
keyboard,
|
||||
)
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
logger.info(
|
||||
f"📢 Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление о продлении подписки пользователю {tg_id}: {e}")
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": delay_message,
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
logger.info("✅ Обработка истекших ключей завершена.")
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истекших ключах.")
|
||||
|
||||
logger.info("Обработка истекших ключей завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def process_auto_renew_or_notify(
|
||||
bot,
|
||||
conn,
|
||||
key: dict,
|
||||
notification_id: str,
|
||||
renewal_period_months: int,
|
||||
standard_photo: str,
|
||||
standard_caption: str
|
||||
bot, conn, key: dict, notification_id: str, renewal_period_months: int, standard_photo: str, standard_caption: str
|
||||
):
|
||||
"""
|
||||
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
|
||||
@@ -385,13 +400,14 @@ async def process_auto_renew_or_notify(
|
||||
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
|
||||
|
||||
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
|
||||
total_gb = int(renewal_period_months * TOTAL_GB * 1024**3)
|
||||
|
||||
logger.info(
|
||||
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
|
||||
)
|
||||
|
||||
try:
|
||||
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, TOTAL_GB)
|
||||
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, total_gb)
|
||||
await update_balance(tg_id, -renewal_cost, session=conn)
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
|
||||
@@ -407,13 +423,22 @@ async def process_auto_renew_or_notify(
|
||||
)
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
if result:
|
||||
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
if result:
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
@@ -10,6 +10,23 @@ from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def send_messages_with_limit(bot: Bot, messages: list[dict], messages_per_second: int = 25):
|
||||
"""
|
||||
Отправляет сообщения с ограничением по количеству сообщений в секунду.
|
||||
"""
|
||||
batch_size = messages_per_second
|
||||
for i in range(0, len(messages), batch_size):
|
||||
batch = messages[i : i + batch_size]
|
||||
tasks = []
|
||||
for msg in batch:
|
||||
tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке сообщений в батче: {e}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
|
||||
def rate_limited_send(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
while True:
|
||||
@@ -34,25 +51,17 @@ def rate_limited_send(func):
|
||||
async def send_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
image_filename: str,
|
||||
image_filename: str | None,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Отправляет уведомление пользователю.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота для отправки сообщений
|
||||
tg_id: Telegram ID пользователя
|
||||
image_filename: Имя файла изображения в директории img
|
||||
caption: Текст сообщения
|
||||
keyboard: Клавиатура для сообщения (опционально)
|
||||
|
||||
Returns:
|
||||
bool: True если сообщение успешно отправлено, False в случае ошибки
|
||||
"""
|
||||
photo_path = os.path.join("img", image_filename)
|
||||
if image_filename is None:
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
|
||||
photo_path = os.path.join("img", image_filename)
|
||||
if os.path.isfile(photo_path):
|
||||
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
|
||||
else:
|
||||
|
||||
@@ -12,13 +12,17 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
check_notifications_bulk,
|
||||
create_blocked_user,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.keys.key_utils import get_user_traffic
|
||||
from handlers.texts import TRIAL_INACTIVE_BONUS_MSG, TRIAL_INACTIVE_FIRST_MSG, ZERO_TRAFFIC_MSG
|
||||
from handlers.utils import format_days
|
||||
from logger import logger
|
||||
from handlers.utils import format_days
|
||||
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
|
||||
|
||||
router = Router()
|
||||
@@ -34,76 +38,54 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
"""
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
|
||||
inactive_trial_users = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, username, first_name, last_name FROM users
|
||||
WHERE tg_id IN (
|
||||
SELECT tg_id FROM connections
|
||||
WHERE trial IN (0, -1)
|
||||
)
|
||||
AND tg_id NOT IN (
|
||||
SELECT tg_id FROM blocked_users
|
||||
)
|
||||
AND tg_id NOT IN (
|
||||
SELECT DISTINCT tg_id FROM keys
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info(f"👥 Найдено {len(inactive_trial_users)} неактивных пользователей.")
|
||||
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
|
||||
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
||||
|
||||
for user in inactive_trial_users:
|
||||
messages = []
|
||||
|
||||
for user in users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user["username"]
|
||||
first_name = user["first_name"]
|
||||
last_name = user["last_name"]
|
||||
display_name = username or first_name or last_name or "Пользователь"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
trial_extended = user["last_notification_time"] is not None
|
||||
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
|
||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||
display_name=display_name,
|
||||
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
|
||||
total_days_formatted=format_days(total_days),
|
||||
)
|
||||
await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
|
||||
else:
|
||||
message = TRIAL_INACTIVE_FIRST_MSG.format(
|
||||
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
|
||||
)
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(tg_id, "inactive_trial", hours=NOTIFY_INACTIVE, session=conn)
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
if can_notify:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
trial_extended = await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notifications
|
||||
WHERE tg_id = $1 AND notification_type = 'inactive_trial'
|
||||
)
|
||||
""",
|
||||
tg_id,
|
||||
)
|
||||
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
|
||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||
display_name=display_name, NOTIFY_EXTRA_DAYS=NOTIFY_EXTRA_DAYS, total_days=total_days
|
||||
)
|
||||
await conn.execute("UPDATE connections SET trial = -1 WHERE tg_id = $1", tg_id)
|
||||
else:
|
||||
message = TRIAL_INACTIVE_FIRST_MSG.format(display_name=display_name, TRIAL_TIME=TRIAL_TIME)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при обработке пользователя {tg_id}: {e}")
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -175,11 +157,14 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
|
||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
result = await send_notification(bot, tg_id, None, message, keyboard)
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
|
||||
)
|
||||
if result:
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
@@ -10,8 +14,19 @@ from config import (
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
)
|
||||
from handlers.buttons import CRYPTOBOT, MAIN_MENU, ROBOKASSA, STARS, YOOKASSA, YOOMONEY
|
||||
from handlers.texts import PAYMENT_METHODS_MSG
|
||||
from database import get_last_payments
|
||||
from handlers.buttons import (
|
||||
BALANCE_HISTORY,
|
||||
COUPON,
|
||||
CRYPTOBOT,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
ROBOKASSA,
|
||||
STARS,
|
||||
YOOKASSA,
|
||||
YOOMONEY,
|
||||
)
|
||||
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
@@ -69,3 +84,63 @@ async def handle_pay(callback_query: CallbackQuery):
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance")
|
||||
async def balance_handler(callback_query: CallbackQuery, session: Any):
|
||||
result = await session.fetchrow(
|
||||
"SELECT balance FROM users WHERE tg_id = $1",
|
||||
callback_query.from_user.id,
|
||||
)
|
||||
balance = result["balance"] if result else 0.0
|
||||
balance = int(balance)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
|
||||
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
|
||||
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,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance_history")
|
||||
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
records = await get_last_payments(callback_query.from_user.id, session)
|
||||
|
||||
if records:
|
||||
history_text = BALANCE_HISTORY_HEADER
|
||||
for record in records:
|
||||
amount = record["amount"]
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
history_text += (
|
||||
f"<b>Сумма:</b> {amount}₽\n"
|
||||
f"<b>Способ оплаты:</b> {payment_system}\n"
|
||||
f"<b>Статус:</b> {status}\n"
|
||||
f"<b>Дата:</b> {date}\n\n"
|
||||
)
|
||||
else:
|
||||
history_text = "❌ У вас пока нет операций с балансом."
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=history_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
@@ -19,9 +19,9 @@ from config import (
|
||||
from robokassa import HashAlgorithm, Robokassa
|
||||
|
||||
from database import (
|
||||
add_connection,
|
||||
add_user,
|
||||
check_user_exists,
|
||||
add_payment,
|
||||
check_connection_exists,
|
||||
get_key_count,
|
||||
get_temporary_data,
|
||||
update_balance,
|
||||
@@ -96,10 +96,20 @@ async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, st
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_connection_exists(tg_id)
|
||||
exists = await check_user_exists(tg_id)
|
||||
if not exists:
|
||||
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
||||
logger.info(f"Created new connection for user {tg_id} with balance 0.0.")
|
||||
from_user = callback_query.from_user
|
||||
await add_user(
|
||||
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,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
|
||||
|
||||
|
||||
await callback_query.message.delete()
|
||||
|
||||
|
||||
@@ -1,63 +1,41 @@
|
||||
import html
|
||||
from io import BytesIO
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
import qrcode
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineQuery,
|
||||
InlineQueryResultArticle,
|
||||
InputTextMessageContent,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
ADMIN_ID,
|
||||
DATABASE_URL,
|
||||
GIFT_BUTTON,
|
||||
INLINE_MODE,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
NEWS_MESSAGE,
|
||||
REFERRAL_BUTTON,
|
||||
REFERRAL_OFFERS,
|
||||
RENEWAL_PLANS,
|
||||
SHOW_START_MENU_ONCE,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
TRIAL_TIME,
|
||||
USERNAME_BOT,
|
||||
)
|
||||
from database import get_balance, get_key_count, get_last_payments, get_referral_stats, get_trial
|
||||
from database import get_balance, get_key_count, get_trial
|
||||
from handlers.buttons import (
|
||||
ABOUT_VPN,
|
||||
ADD_SUB,
|
||||
BACK,
|
||||
BALANCE,
|
||||
BALANCE_HISTORY,
|
||||
COUPON,
|
||||
GIFTS,
|
||||
INSTRUCTIONS,
|
||||
INVITE,
|
||||
MAIN_MENU,
|
||||
MY_SUBS,
|
||||
PAYMENT,
|
||||
QR,
|
||||
TOP_FIVE,
|
||||
TRIAL_SUB
|
||||
TRIAL_SUB,
|
||||
)
|
||||
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, INVITE_TEXT_NON_INLINE, TOP_REFERRALS_TEXT
|
||||
from logger import logger
|
||||
|
||||
from .admin.panel.keyboard import AdminPanelCallback
|
||||
from .texts import get_referral_link, invite_message_send, profile_message_send
|
||||
from .texts import profile_message_send
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
@@ -132,7 +110,7 @@ async def process_callback_view_profile(
|
||||
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"))
|
||||
@@ -149,246 +127,3 @@ async def process_callback_view_profile(
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance")
|
||||
async def balance_handler(callback_query: CallbackQuery, session: Any):
|
||||
result = await session.fetchrow(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1",
|
||||
callback_query.from_user.id,
|
||||
)
|
||||
balance = result["balance"] if result else 0.0
|
||||
balance = int(balance)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
|
||||
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance_history")
|
||||
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
records = await get_last_payments(callback_query.from_user.id, session)
|
||||
|
||||
if records:
|
||||
history_text = BALANCE_HISTORY_HEADER
|
||||
for record in records:
|
||||
amount = record["amount"]
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
history_text += (
|
||||
f"<b>Сумма:</b> {amount}₽\n"
|
||||
f"<b>Способ оплаты:</b> {payment_system}\n"
|
||||
f"<b>Статус:</b> {status}\n"
|
||||
f"<b>Дата:</b> {date}\n\n"
|
||||
)
|
||||
else:
|
||||
history_text = "❌ У вас пока нет операций с балансом."
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=history_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text == "/tariffs")
|
||||
@router.callback_query(F.data == "view_tariffs")
|
||||
async def view_tariffs_handler(callback_query: CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
image_path = os.path.join("img", "tariffs.jpg")
|
||||
tariffs_message = "<b>🚀 Доступные тарифы VPN:</b>\n\n" + "\n".join([
|
||||
f"{months} {'месяц' if months == '1' else 'месяца' if int(months) in [2, 3, 4] else 'месяцев'}: "
|
||||
f"{RENEWAL_PLANS[months]['price']} "
|
||||
f"{'💳' if months == '1' else '🌟' if months == '3' else '🔥' if months == '6' else '🚀'} рублей"
|
||||
for months in sorted(RENEWAL_PLANS.keys(), key=int)
|
||||
])
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=tariffs_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "invite")
|
||||
@router.message(F.text == "/invite")
|
||||
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
|
||||
chat_id = None
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
chat_id = callback_query_or_message.message.chat.id
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
chat_id = callback_query_or_message.chat.id
|
||||
target_message = callback_query_or_message
|
||||
|
||||
referral_link = get_referral_link(chat_id)
|
||||
referral_stats = await get_referral_stats(chat_id)
|
||||
invite_message = invite_message_send(referral_link, referral_stats)
|
||||
image_path = os.path.join("img", "pic_invite.jpg")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
builder.button(text=INVITE, switch_inline_query="invite")
|
||||
else:
|
||||
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
|
||||
builder.button(text=INVITE, switch_inline_query=invite_text)
|
||||
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
|
||||
if TOP_REFERRAL_BUTTON:
|
||||
builder.button(text=TOP_FIVE, callback_data="top_referrals")
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=invite_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
|
||||
async def inline_referral_handler(inline_query: InlineQuery):
|
||||
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
|
||||
|
||||
results: list[InlineQueryResultArticle] = []
|
||||
|
||||
for index, offer in enumerate(REFERRAL_OFFERS):
|
||||
description = offer["description"][:64]
|
||||
message_text = offer["message"].format(trial_time=TRIAL_TIME)[:4096]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
|
||||
|
||||
results.append(
|
||||
InlineQueryResultArticle(
|
||||
id=str(index),
|
||||
title=offer["title"],
|
||||
description=description,
|
||||
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
)
|
||||
|
||||
await inline_query.answer(results=results, cache_time=86400, is_personal=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("show_referral_qr|"))
|
||||
async def show_referral_qr(callback_query: CallbackQuery):
|
||||
try:
|
||||
chat_id = callback_query.data.split("|")[1]
|
||||
referral_link = get_referral_link(chat_id)
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(referral_link)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
|
||||
with open(qr_path, "wb") as f:
|
||||
f.write(buffer.read())
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="📷 <b>Ваш QR-код для реферальной ссылки.</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=qr_path,
|
||||
)
|
||||
|
||||
os.remove(qr_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "top_referrals")
|
||||
async def top_referrals_handler(callback_query: CallbackQuery):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
user_referral_count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1",
|
||||
callback_query.from_user.id
|
||||
) or 0
|
||||
|
||||
personal_block = "Твоё место в рейтинге:\n"
|
||||
if user_referral_count > 0:
|
||||
user_position = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*) + 1 FROM (
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM referrals
|
||||
GROUP BY referrer_tg_id
|
||||
HAVING COUNT(*) > $1
|
||||
) AS better_users
|
||||
""",
|
||||
user_referral_count
|
||||
)
|
||||
personal_block += f"{user_position}. {callback_query.from_user.id} - {user_referral_count} чел."
|
||||
else:
|
||||
personal_block += "Ты еще не приглашал пользователей в проект."
|
||||
|
||||
top_referrals = await conn.fetch(
|
||||
"""
|
||||
SELECT referrer_tg_id, COUNT(*) as referral_count
|
||||
FROM referrals
|
||||
GROUP BY referrer_tg_id
|
||||
ORDER BY referral_count DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
)
|
||||
|
||||
is_admin = callback_query.from_user.id in ADMIN_ID
|
||||
rows = ""
|
||||
for i, row in enumerate(top_referrals, 1):
|
||||
tg_id = str(row["referrer_tg_id"])
|
||||
count = row["referral_count"]
|
||||
display_id = tg_id if is_admin else f"{tg_id[:5]}*****"
|
||||
rows += f"{i}. {display_id} - {count} чел.\n"
|
||||
|
||||
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import os
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import qrcode
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineQuery,
|
||||
InlineQueryResultArticle,
|
||||
InputTextMessageContent,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
ADMIN_ID,
|
||||
DATABASE_URL,
|
||||
INLINE_MODE,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
TRIAL_TIME,
|
||||
USERNAME_BOT,
|
||||
)
|
||||
from database import add_referral, add_user, check_user_exists, get_referral_by_referred_id, get_referral_stats
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
INVITE,
|
||||
MAIN_MENU,
|
||||
QR,
|
||||
TOP_FIVE,
|
||||
)
|
||||
from handlers.texts import (
|
||||
INVITE_TEXT_NON_INLINE,
|
||||
NEW_REFERRAL_NOTIFICATION,
|
||||
REFERRAL_OFFERS,
|
||||
REFERRAL_SUCCESS_MSG,
|
||||
TOP_REFERRALS_TEXT,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
from .texts import get_referral_link, invite_message_send
|
||||
from .utils import edit_or_send_message, format_days
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "invite")
|
||||
@router.message(F.text == "/invite")
|
||||
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
|
||||
chat_id = None
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
chat_id = callback_query_or_message.message.chat.id
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
chat_id = callback_query_or_message.chat.id
|
||||
target_message = callback_query_or_message
|
||||
|
||||
referral_link = get_referral_link(chat_id)
|
||||
referral_stats = await get_referral_stats(chat_id)
|
||||
invite_message = invite_message_send(referral_link, referral_stats)
|
||||
image_path = os.path.join("img", "pic_invite.jpg")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
builder.button(text=INVITE, switch_inline_query="invite")
|
||||
else:
|
||||
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
|
||||
builder.button(text=INVITE, switch_inline_query=invite_text)
|
||||
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
|
||||
if TOP_REFERRAL_BUTTON:
|
||||
builder.button(text=TOP_FIVE, callback_data="top_referrals")
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=invite_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
|
||||
async def inline_referral_handler(inline_query: InlineQuery):
|
||||
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
|
||||
trial_time_formatted = format_days(TRIAL_TIME)
|
||||
results: list[InlineQueryResultArticle] = []
|
||||
|
||||
for index, offer in enumerate(REFERRAL_OFFERS):
|
||||
description = offer["description"][:64]
|
||||
message_text = offer["message"].format(trial_time=TRIAL_TIME, trial_time_formatted=trial_time_formatted)[:4096]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
|
||||
|
||||
results.append(
|
||||
InlineQueryResultArticle(
|
||||
id=str(index),
|
||||
title=offer["title"],
|
||||
description=description,
|
||||
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
)
|
||||
|
||||
await inline_query.answer(results=results, cache_time=86400, is_personal=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("show_referral_qr|"))
|
||||
async def show_referral_qr(callback_query: CallbackQuery):
|
||||
try:
|
||||
chat_id = callback_query.data.split("|")[1]
|
||||
referral_link = get_referral_link(chat_id)
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(referral_link)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
|
||||
with open(qr_path, "wb") as f:
|
||||
f.write(buffer.read())
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="📷 <b>Ваш QR-код для реферальной ссылки.</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=qr_path,
|
||||
)
|
||||
|
||||
os.remove(qr_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "top_referrals")
|
||||
async def top_referrals_handler(callback_query: CallbackQuery):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
user_referral_count = (
|
||||
await conn.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", callback_query.from_user.id)
|
||||
or 0
|
||||
)
|
||||
|
||||
personal_block = "Твоё место в рейтинге:\n"
|
||||
if user_referral_count > 0:
|
||||
user_position = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*) + 1 FROM (
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM referrals
|
||||
GROUP BY referrer_tg_id
|
||||
HAVING COUNT(*) > $1
|
||||
) AS better_users
|
||||
""",
|
||||
user_referral_count,
|
||||
)
|
||||
personal_block += f"{user_position}. {callback_query.from_user.id} - {user_referral_count} чел."
|
||||
else:
|
||||
personal_block += "Ты еще не приглашал пользователей в проект."
|
||||
|
||||
top_referrals = await conn.fetch(
|
||||
"""
|
||||
SELECT referrer_tg_id, COUNT(*) as referral_count
|
||||
FROM referrals
|
||||
GROUP BY referrer_tg_id
|
||||
ORDER BY referral_count DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
)
|
||||
|
||||
is_admin = callback_query.from_user.id in ADMIN_ID
|
||||
rows = ""
|
||||
for i, row in enumerate(top_referrals, 1):
|
||||
tg_id = str(row["referrer_tg_id"])
|
||||
count = row["referral_count"]
|
||||
display_id = tg_id if is_admin else f"{tg_id[:5]}*****"
|
||||
rows += f"{i}. {display_id} - {count} чел.\n"
|
||||
|
||||
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def handle_referral_link(referral_code: str, message: Message, state: FSMContext, session: Any):
|
||||
try:
|
||||
referrer_tg_id = int(referral_code)
|
||||
user_exists_now = await check_user_exists(message.chat.id)
|
||||
|
||||
if referrer_tg_id == message.chat.id:
|
||||
await message.answer("❌ Вы не можете быть реферальной ссылкой самого себя.")
|
||||
return
|
||||
|
||||
if user_exists_now:
|
||||
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
|
||||
return
|
||||
|
||||
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
|
||||
if existing_referral:
|
||||
await message.answer("❌ Вы уже использовали реферальную ссылку.")
|
||||
return
|
||||
|
||||
await add_referral(message.chat.id, referrer_tg_id, session)
|
||||
|
||||
from_user = message.from_user
|
||||
await add_user(
|
||||
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,
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
referrer_tg_id,
|
||||
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
|
||||
|
||||
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}")
|
||||
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
|
||||
return
|
||||
@@ -24,24 +24,17 @@ from config import (
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import (
|
||||
add_connection,
|
||||
add_referral,
|
||||
check_connection_exists,
|
||||
get_referral_by_referred_id,
|
||||
add_user,
|
||||
check_user_exists,
|
||||
get_trial,
|
||||
update_balance,
|
||||
)
|
||||
from handlers.admin.coupons.coupons_handler import handle_coupon_activation
|
||||
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT
|
||||
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT, TRIAL_SUB
|
||||
from handlers.captcha import generate_captcha
|
||||
from handlers.keys.key_management import create_key
|
||||
from handlers.coupons import activate_coupon
|
||||
from handlers.payments.gift import handle_gift_link
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.texts import (
|
||||
COUPON_SUCCESS_MSG,
|
||||
GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG,
|
||||
NEW_REFERRAL_NOTIFICATION,
|
||||
NOT_SUBSCRIBED_YET_MSG,
|
||||
REFERRAL_SUCCESS_MSG,
|
||||
SUBSCRIPTION_CHECK_ERROR_MSG,
|
||||
SUBSCRIPTION_CONFIRMED_MSG,
|
||||
SUBSCRIPTION_REQUIRED_MSG,
|
||||
@@ -51,6 +44,7 @@ from handlers.texts import (
|
||||
from logger import logger
|
||||
|
||||
from .admin.panel.keyboard import AdminPanelCallback
|
||||
from .refferal import handle_referral_link
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
@@ -66,17 +60,18 @@ async def handle_start_callback_query(
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True):
|
||||
"""Обрабатывает команду /start, включая логику проверки подписки, рефералов и подарков."""
|
||||
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
|
||||
|
||||
if CAPTCHA_ENABLE and captcha:
|
||||
captcha_data = await generate_captcha(message, state)
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=captcha_data["text"],
|
||||
reply_markup=captcha_data["markup"],
|
||||
)
|
||||
return
|
||||
user_exists = await check_user_exists(message.chat.id)
|
||||
if not user_exists:
|
||||
captcha_data = await generate_captcha(message, state)
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=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)
|
||||
@@ -112,123 +107,6 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
await process_start_logic(message, state, session, admin, text_to_process)
|
||||
|
||||
|
||||
async def process_start_logic(
|
||||
message: Message, state: FSMContext, session: Any, admin: bool, text_to_process: str = None
|
||||
):
|
||||
text = text_to_process if text_to_process is not None else message.text
|
||||
if text:
|
||||
try:
|
||||
if "coupons_" in text:
|
||||
logger.info(f"Обнаружена ссылка на купон: {text}")
|
||||
user_id = message.chat.id
|
||||
await handle_coupon_activation(message, state, session, admin, text=text, user_id=user_id)
|
||||
return
|
||||
|
||||
if "gift_" in text:
|
||||
parts = text.split("gift_")[1].split("_")
|
||||
if len(parts) < 2:
|
||||
await message.answer("❌ Неверный формат ссылки на подарок.")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
gift_id = parts[0]
|
||||
async with session.transaction():
|
||||
gift_info = await session.fetchrow(
|
||||
"""
|
||||
SELECT sender_tg_id, selected_months, expiry_time, is_used, recipient_tg_id
|
||||
FROM gifts
|
||||
WHERE gift_id = $1
|
||||
FOR UPDATE
|
||||
""",
|
||||
gift_id,
|
||||
)
|
||||
if not gift_info:
|
||||
await message.answer(GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG)
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
if gift_info["is_used"]:
|
||||
await message.answer("Этот подарок уже был использован.")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
if gift_info["sender_tg_id"] == message.chat.id:
|
||||
await message.answer("❌ Вы не можете получить подарок от самого себя.")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
if gift_info["recipient_tg_id"]:
|
||||
await message.answer("❌ Этот подарок уже был активирован другим пользователем.")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
|
||||
if not existing_referral:
|
||||
await add_referral(message.chat.id, gift_info["sender_tg_id"], session)
|
||||
|
||||
connection_exists = await check_connection_exists(message.chat.id)
|
||||
if not connection_exists:
|
||||
await add_connection(tg_id=message.chat.id, session=session)
|
||||
|
||||
await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", message.chat.id)
|
||||
|
||||
await create_key(
|
||||
message.chat.id,
|
||||
gift_info["expiry_time"].replace(tzinfo=None),
|
||||
state,
|
||||
session,
|
||||
message,
|
||||
)
|
||||
await session.execute(
|
||||
"UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1 WHERE gift_id = $2",
|
||||
message.chat.id,
|
||||
gift_id,
|
||||
)
|
||||
await message.answer(
|
||||
f"🎉 Ваш подарок на {gift_info['selected_months']} "
|
||||
f"{'месяц' if gift_info['selected_months'] == 1 else 'месяца' if gift_info['selected_months'] in [2, 3, 4] else 'месяцев'} активирован!"
|
||||
)
|
||||
return
|
||||
|
||||
if "referral_" in text:
|
||||
try:
|
||||
referrer_tg_id = int(text.split("referral_")[1])
|
||||
connection_exists_now = await check_connection_exists(message.chat.id)
|
||||
if connection_exists_now:
|
||||
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
if referrer_tg_id == message.chat.id:
|
||||
await message.answer("❌ Вы не можете быть рефералом самого себя.")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
|
||||
if existing_referral:
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
await add_referral(message.chat.id, referrer_tg_id, session)
|
||||
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
||||
try:
|
||||
await bot.send_message(
|
||||
referrer_tg_id,
|
||||
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
logger.info("Пользователь зашел без реферальной ссылки, подарка или купона.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке текста {message.text} — {e}", exc_info=True)
|
||||
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
|
||||
final_exists = await check_connection_exists(message.chat.id)
|
||||
if final_exists:
|
||||
if SHOW_START_MENU_ONCE:
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
else:
|
||||
return await show_start_menu(message, admin, session)
|
||||
else:
|
||||
await add_connection(tg_id=message.chat.id, session=session)
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
|
||||
@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
|
||||
@@ -258,6 +136,117 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
|
||||
await callback_query.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
|
||||
|
||||
|
||||
async def process_start_logic(
|
||||
message: Message, state: FSMContext, session: Any, admin: bool, text_to_process: str = None
|
||||
):
|
||||
text = text_to_process or message.text or message.caption
|
||||
|
||||
if not text:
|
||||
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {message.chat.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]
|
||||
|
||||
try:
|
||||
gift_detected = False
|
||||
text_parts = text.split("-")
|
||||
|
||||
for part in text_parts:
|
||||
if "coupons" in part:
|
||||
logger.info(f"Обнаружена ссылка на купон: {part}")
|
||||
coupon_code = part.split("coupons")[1].strip("_")
|
||||
await activate_coupon(message, state, session, coupon_code=coupon_code, admin=admin)
|
||||
continue
|
||||
|
||||
if "gift" in part:
|
||||
gift_raw = part.split("gift")[1].strip("_")
|
||||
parts = gift_raw.split("_")
|
||||
if len(parts) < 2:
|
||||
await message.answer("❌ Неверный формат ссылки на подарок.")
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
gift_id = parts[0]
|
||||
sender_id = parts[1]
|
||||
logger.info(f"[GIFT] Обнаружен подарок {gift_id} от {sender_id}")
|
||||
await handle_gift_link(gift_id, message, state, session)
|
||||
gift_detected = True
|
||||
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)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
continue
|
||||
|
||||
if "utm" in part:
|
||||
utm_code = part
|
||||
logger.info(f"Обнаружена ссылка на UTM: {utm_code}")
|
||||
await handle_utm_link(utm_code, message, state, session)
|
||||
continue
|
||||
|
||||
if gift_detected:
|
||||
return
|
||||
|
||||
user_exists = await check_user_exists(message.chat.id)
|
||||
if not user_exists:
|
||||
from_user = message.from_user
|
||||
await add_user(
|
||||
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,
|
||||
session=session,
|
||||
)
|
||||
|
||||
trial_status = await get_trial(message.chat.id, session)
|
||||
|
||||
if SHOW_START_MENU_ONCE:
|
||||
if trial_status > 0:
|
||||
await process_callback_view_profile(message, state, admin)
|
||||
else:
|
||||
await show_start_menu(message, admin, session)
|
||||
else:
|
||||
await show_start_menu(message, admin, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке текста {message.text} — {e}", exc_info=True)
|
||||
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
|
||||
|
||||
async def handle_utm_link(utm_code, message, state, session):
|
||||
exists = await session.fetchval("SELECT 1 FROM tracking_sources WHERE code = $1", utm_code)
|
||||
if exists:
|
||||
current_code = await session.fetchval("SELECT source_code FROM users WHERE tg_id = $1", message.chat.id)
|
||||
if current_code is None:
|
||||
user_exists = await check_user_exists(message.chat.id)
|
||||
from_user = message.from_user
|
||||
if not user_exists:
|
||||
await add_user(
|
||||
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,
|
||||
session=session,
|
||||
source_code=utm_code,
|
||||
)
|
||||
else:
|
||||
await session.execute("UPDATE users SET source_code = $1 WHERE tg_id = $2", utm_code, message.chat.id)
|
||||
logger.info(f"[UTM] Привязана {utm_code} к пользователю {message.chat.id}")
|
||||
else:
|
||||
await message.answer("❌ UTM ссылка не найдена.")
|
||||
|
||||
|
||||
async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
"""Функция для отображения стандартного меню через редактирование сообщения.
|
||||
Если редактирование не удалось, отправляем новое сообщение."""
|
||||
@@ -270,7 +259,9 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
trial_status = await get_trial(message.chat.id, session)
|
||||
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
|
||||
if trial_status == 0:
|
||||
builder.row(InlineKeyboardButton(text="🎁 Пробная подписка", callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
else:
|
||||
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
|
||||
|
||||
@@ -287,7 +278,7 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
|
||||
if admin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
|
||||
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
|
||||
@@ -314,11 +305,12 @@ async def handle_about_vpn(callback_query: CallbackQuery):
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
|
||||
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=None,
|
||||
media_path=image_path,
|
||||
force_text=False,
|
||||
)
|
||||
|
||||
@@ -10,97 +10,105 @@ import asyncpg
|
||||
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
|
||||
|
||||
from datetime import datetime
|
||||
from bot import bot
|
||||
from config import DATABASE_URL
|
||||
from config import ADMIN_ID, DATABASE_URL
|
||||
from database import get_all_keys, get_servers
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def get_usd_rate():
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get("https://www.cbr-xml-daily.ru/daily_json.js") as response:
|
||||
if response.status == 200:
|
||||
data = await response.text()
|
||||
usd = float(json.loads(data)["Valute"]["USD"]["Value"])
|
||||
else:
|
||||
usd = float(100)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error fetching USD rate: {e}")
|
||||
usd = float(100)
|
||||
return usd
|
||||
|
||||
|
||||
def sanitize_key_name(key_name: str) -> str:
|
||||
"""
|
||||
Очищает название ключа, оставляя только допустимые символы.
|
||||
|
||||
Args:
|
||||
key_name (str): Исходное название ключа.
|
||||
|
||||
Returns:
|
||||
str: Очищенное название ключа в нижнем регистре.
|
||||
"""
|
||||
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
|
||||
|
||||
|
||||
def generate_random_email(length: int = 6) -> str:
|
||||
def generate_random_email(length: int = 8) -> str:
|
||||
"""
|
||||
Генерирует случайный email с заданной длиной.
|
||||
|
||||
Args:
|
||||
length (int, optional): Длина случайной строки. По умолчанию 6.
|
||||
|
||||
Returns:
|
||||
str: Сгенерированная случайная строка.
|
||||
"""
|
||||
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
|
||||
|
||||
|
||||
async def get_least_loaded_cluster() -> str:
|
||||
"""
|
||||
Определяет кластер с наименьшей загрузкой.
|
||||
|
||||
Returns:
|
||||
str: Идентификатор наименее загруженного кластера.
|
||||
Возвращает кластер с наименьшей загрузкой, где есть хотя бы один сервер с доступным лимитом.
|
||||
"""
|
||||
servers = await get_servers()
|
||||
server_to_cluster = {}
|
||||
cluster_loads = dict.fromkeys(servers.keys(), 0)
|
||||
cluster_loads = {}
|
||||
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
cluster_loads[cluster_name] = 0
|
||||
for server in cluster_servers:
|
||||
server_to_cluster[server["server_name"]] = cluster_name
|
||||
logger.info(f"Сопоставление серверов и кластеров: {server_to_cluster}")
|
||||
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
keys = await get_all_keys(conn)
|
||||
for key in keys:
|
||||
server_id = key["server_id"]
|
||||
|
||||
cluster_id = server_to_cluster.get(server_id, server_id)
|
||||
|
||||
if cluster_id in cluster_loads:
|
||||
cluster_loads[cluster_id] += 1
|
||||
else:
|
||||
logger.warning(f"⚠️ Сервер {server_id} не найден в известных кластерах!")
|
||||
logger.info(f"Загруженность кластеров после запроса к БД: {cluster_loads}")
|
||||
if not cluster_loads:
|
||||
logger.warning("⚠️ В базе данных или конфигурации нет кластеров!")
|
||||
|
||||
available_clusters = {}
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
for server in cluster_servers:
|
||||
if server.get("enabled", True) and await check_server_key_limit(server, conn):
|
||||
available_clusters[cluster_name] = cluster_loads[cluster_name]
|
||||
break
|
||||
|
||||
if not available_clusters:
|
||||
logger.warning("❌ Нет доступных кластеров с лимитом ключей!")
|
||||
return "cluster1"
|
||||
least_loaded_cluster = min(cluster_loads, key=lambda k: (cluster_loads[k], k))
|
||||
logger.info(f"✅ Выбран наименее загруженный кластер: {least_loaded_cluster}")
|
||||
|
||||
least_loaded_cluster = min(available_clusters, key=lambda k: (available_clusters[k], k))
|
||||
logger.info(f"✅ Выбран наименее загруженный кластер с лимитом: {least_loaded_cluster}")
|
||||
|
||||
return least_loaded_cluster
|
||||
|
||||
|
||||
async def check_server_key_limit(server_info: dict, conn) -> bool:
|
||||
"""
|
||||
Универсальная проверка лимита ключей для сервера в режимах кластеров и стран.
|
||||
"""
|
||||
server_name = server_info.get("server_name")
|
||||
cluster_name = server_info.get("cluster_name")
|
||||
max_keys = server_info.get("max_keys")
|
||||
|
||||
if not max_keys:
|
||||
return True
|
||||
|
||||
identifier = cluster_name if cluster_name else server_name
|
||||
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", identifier)
|
||||
|
||||
if total_keys >= max_keys:
|
||||
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}")
|
||||
return False
|
||||
|
||||
usage_percent = total_keys / max_keys
|
||||
|
||||
if usage_percent >= 0.9:
|
||||
notif_key = f"server_warn_{server_name}"
|
||||
already_sent = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = 0 AND notification_type = $1)", notif_key
|
||||
)
|
||||
if not already_sent:
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
await bot.send_message(
|
||||
admin_id,
|
||||
f"⚠️ Сервер <b>{server_name}</b> почти заполнен ({int(usage_percent * 100)}%)."
|
||||
f"\nРекомендуется создать новый для балансировки.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await conn.execute(
|
||||
"INSERT INTO notifications (tg_id, notification_type) VALUES (0, $1) ON CONFLICT DO NOTHING",
|
||||
notif_key,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
|
||||
"""
|
||||
Обрабатывает ошибку, отправляя сообщение пользователю.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор пользователя в Telegram.
|
||||
callback_query (Optional[object], optional): Объект запроса обратного вызова. По умолчанию None.
|
||||
message (str, optional): Текст сообщения об ошибке. По умолчанию пустая строка.
|
||||
"""
|
||||
try:
|
||||
if callback_query and hasattr(callback_query, "message"):
|
||||
@@ -115,63 +123,55 @@ async def handle_error(tg_id: int, callback_query: object | None = None, message
|
||||
logger.error(f"Ошибка при обработке ошибки: {e}")
|
||||
|
||||
|
||||
def format_time_until_deletion(seconds: int) -> str:
|
||||
if seconds <= 0:
|
||||
return "0 минут"
|
||||
|
||||
days = seconds // (3600 * 24)
|
||||
hours = (seconds % (3600 * 24)) // 3600
|
||||
minutes = (seconds % 3600 + 59) // 60
|
||||
|
||||
parts = []
|
||||
|
||||
if days > 0:
|
||||
if days == 1:
|
||||
parts.append(f"{days} день")
|
||||
elif 2 <= days <= 4:
|
||||
parts.append(f"{days} дня")
|
||||
else:
|
||||
parts.append(f"{days} дней")
|
||||
|
||||
if hours > 0:
|
||||
if hours == 1:
|
||||
parts.append(f"{hours} час")
|
||||
elif 2 <= hours <= 4:
|
||||
parts.append(f"{hours} часа")
|
||||
else:
|
||||
parts.append(f"{hours} часов")
|
||||
|
||||
if minutes > 0 and days == 0:
|
||||
if minutes == 1:
|
||||
parts.append("1 минута")
|
||||
elif 2 <= minutes <= 4:
|
||||
parts.append(f"{minutes} минуты")
|
||||
else:
|
||||
parts.append(f"{minutes} минут")
|
||||
|
||||
return " и ".join(parts) if parts else "менее минуты"
|
||||
|
||||
|
||||
def get_plural_form(num: int, form1: str, form2: str, form3: str) -> str:
|
||||
"""Универсальная функция для получения правильной формы множественного числа"""
|
||||
n = abs(num) % 100
|
||||
if 10 < n < 20:
|
||||
return form3
|
||||
return {1: form1, 2: form2, 3: form2, 4: form2}.get(n % 10, form3)
|
||||
|
||||
|
||||
def format_months(months: int) -> str:
|
||||
"""Форматирует количество месяцев с правильным склонением"""
|
||||
if months <= 0:
|
||||
return "0 месяцев"
|
||||
return f"{months} {get_plural_form(months, 'месяц', 'месяца', 'месяцев')}"
|
||||
|
||||
|
||||
def format_days(days: int) -> str:
|
||||
"""
|
||||
Форматирует количество дней с правильным склонением.
|
||||
|
||||
Args:
|
||||
days (int): Количество дней.
|
||||
|
||||
Returns:
|
||||
str: Строка с числом и склонённым словом "день/дня/дней".
|
||||
"""
|
||||
if days <= 0:
|
||||
return "0 дней"
|
||||
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
|
||||
|
||||
def format_hours(hours: int) -> str:
|
||||
"""Форматирует количество часов с правильным склонением"""
|
||||
if hours <= 0:
|
||||
return "0 часов"
|
||||
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
|
||||
|
||||
def format_minutes(minutes: int) -> str:
|
||||
"""Форматирует количество минут с правильным склонением"""
|
||||
if minutes <= 0:
|
||||
return "0 минут"
|
||||
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
|
||||
|
||||
|
||||
def format_hours(hours: int) -> str:
|
||||
"""Форматирует количество часов с правильным склонением"""
|
||||
if hours <= 0:
|
||||
return "0 часов"
|
||||
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
|
||||
|
||||
|
||||
def format_minutes(minutes: int) -> str:
|
||||
"""Форматирует количество минут с правильным склонением"""
|
||||
if minutes <= 0:
|
||||
return "0 минут"
|
||||
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
|
||||
|
||||
|
||||
async def edit_or_send_message(
|
||||
target_message: Message,
|
||||
@@ -183,15 +183,6 @@ async def edit_or_send_message(
|
||||
):
|
||||
"""
|
||||
Универсальная функция для редактирования исходного сообщения target_message.
|
||||
|
||||
- Если media_path указан и существует, считается, что сообщение содержит фото, и используется редактирование медиа
|
||||
(замена фото и подписи) через edit_media. Если редактирование не удаётся, отправляется новое сообщение с фото.
|
||||
|
||||
- Если media_path не указан:
|
||||
- Если force_text=False и target_message уже имеет caption, пытаемся отредактировать подпись (edit_caption).
|
||||
- Иначе (или если редактирование caption не удалось) — редактируем текст (edit_text).
|
||||
|
||||
В случае неудачи fallback – отправка нового сообщения.
|
||||
"""
|
||||
if media_path and os.path.isfile(media_path):
|
||||
async with aiofiles.open(media_path, "rb") as f:
|
||||
@@ -236,11 +227,6 @@ async def edit_or_send_message(
|
||||
def convert_to_bytes(value: float, unit: str) -> int:
|
||||
"""
|
||||
Конвертирует значение с указанной единицей измерения в байты.
|
||||
Args:
|
||||
value (float): Числовое значение.
|
||||
unit (str): Единица измерения ('KB', 'MB', 'GB', 'TB').
|
||||
Returns:
|
||||
int: Количество байт.
|
||||
"""
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
@@ -248,3 +234,68 @@ def convert_to_bytes(value: float, unit: str) -> int:
|
||||
TB = GB * 1024
|
||||
units = {"KB": KB, "MB": MB, "GB": GB, "TB": TB}
|
||||
return int(value * units.get(unit.upper(), 1))
|
||||
|
||||
|
||||
async def is_full_remnawave_cluster(cluster_id: str, session) -> bool:
|
||||
"""
|
||||
Универсальная проверка:
|
||||
- Если cluster_id — это имя кластера, проверяет, что все его сервера используют Remnawave.
|
||||
- Если cluster_id — это имя одиночного сервера, проверяет, что он Remnawave.
|
||||
"""
|
||||
cluster_servers = await session.fetch(
|
||||
"SELECT panel_type FROM servers WHERE cluster_name = $1",
|
||||
cluster_id,
|
||||
)
|
||||
|
||||
if cluster_servers:
|
||||
panel_types = [s["panel_type"].lower() for s in cluster_servers if s.get("panel_type")]
|
||||
return all(pt == "remnawave" for pt in panel_types)
|
||||
|
||||
server = await session.fetchrow(
|
||||
"SELECT panel_type FROM servers WHERE server_name = $1",
|
||||
cluster_id,
|
||||
)
|
||||
return server and server["panel_type"].lower() == "remnawave"
|
||||
|
||||
|
||||
def sanitize_key_name(key_name: str) -> str:
|
||||
"""
|
||||
Очищает название ключа, оставляя только допустимые символы.
|
||||
|
||||
Args:
|
||||
key_name (str): Исходное название ключа.
|
||||
|
||||
Returns:
|
||||
str: Очищенное название ключа в нижнем регистре.
|
||||
"""
|
||||
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
|
||||
|
||||
|
||||
RUSSIAN_MONTHS = {
|
||||
'January': 'Января',
|
||||
'February': 'Февраля',
|
||||
'March': 'Марта',
|
||||
'April': 'Апреля',
|
||||
'May': 'Мая',
|
||||
'June': 'Июня',
|
||||
'July': 'Июля',
|
||||
'August': 'Августа',
|
||||
'September': 'Сентября',
|
||||
'October': 'Октября',
|
||||
'November': 'Ноября',
|
||||
'December': 'Декабря'
|
||||
}
|
||||
|
||||
|
||||
def get_russian_month(date: datetime) -> str:
|
||||
"""
|
||||
Преобразует английское название месяца в русское.
|
||||
|
||||
Args:
|
||||
date: Объект datetime, из которого извлекается месяц.
|
||||
|
||||
Returns:
|
||||
Название месяца на русском языке.
|
||||
"""
|
||||
english_month = date.strftime("%B")
|
||||
return RUSSIAN_MONTHS.get(english_month, english_month)
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 590 KiB |
@@ -8,6 +8,8 @@ from .loggings import LoggingMiddleware
|
||||
from .session import SessionMiddleware
|
||||
from .throttling import ThrottlingMiddleware
|
||||
from .user import UserMiddleware
|
||||
from .maintenance import MaintenanceModeMiddleware
|
||||
|
||||
|
||||
|
||||
def register_middleware(
|
||||
@@ -16,40 +18,27 @@ def register_middleware(
|
||||
exclude: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
"""Регистрирует middleware в диспетчере.
|
||||
|
||||
Args:
|
||||
dispatcher: Экземпляр диспетчера Aiogram
|
||||
middlewares: Опциональный список middleware для регистрации.
|
||||
Если не указан, регистрируются все стандартные middleware.
|
||||
exclude: Опциональный список имен middleware, которые нужно исключить из регистрации.
|
||||
Применяется только если middlewares не указан.
|
||||
"""
|
||||
# Если middleware не указаны, используем стандартный набор
|
||||
if middlewares is None:
|
||||
# Словарь всех доступных middleware
|
||||
available_middlewares = {
|
||||
"admin": AdminMiddleware(),
|
||||
"session": SessionMiddleware(),
|
||||
"maintenance": MaintenanceModeMiddleware(),
|
||||
"logging": LoggingMiddleware(),
|
||||
"throttling": ThrottlingMiddleware(),
|
||||
"user": UserMiddleware(),
|
||||
}
|
||||
|
||||
# Фильтруем middleware по списку исключений
|
||||
exclude_set = set(exclude or [])
|
||||
middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set]
|
||||
|
||||
# Регистрируем middleware для всех типов обработчиков
|
||||
handlers = [
|
||||
dispatcher.message,
|
||||
dispatcher.callback_query,
|
||||
dispatcher.inline_query,
|
||||
# Можно добавить другие типы обработчиков при необходимости
|
||||
]
|
||||
|
||||
# Регистрируем каждый middleware для каждого типа обработчика
|
||||
for middleware in middlewares:
|
||||
# Если передан класс, а не экземпляр, создаем экземпляр
|
||||
if isinstance(middleware, type):
|
||||
middleware = middleware()
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from config import ADMIN_ID
|
||||
|
||||
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
|
||||
|
||||
if user_id and user_id not in ADMIN_ID:
|
||||
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
|
||||
return
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -51,6 +51,7 @@ class UserMiddleware(BaseMiddleware):
|
||||
language_code=user.language_code,
|
||||
is_bot=user.is_bot,
|
||||
session=session,
|
||||
only_if_exists=True,
|
||||
)
|
||||
|
||||
logger.debug(f"Получены данные пользователя из БД: {user.id}")
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import py3xui
|
||||
|
||||
from config import LIMIT_IP, SUPERNODE
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
LIMIT_IP,
|
||||
SUPERNODE,
|
||||
USE_XUI_TOKEN,
|
||||
XUI_TOKEN,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -24,21 +35,39 @@ class ClientConfig:
|
||||
sub_id: str
|
||||
|
||||
|
||||
_xui_instance_cache: dict[str, tuple[AsyncApi, float]] = {}
|
||||
SESSION_TTL = 1800
|
||||
|
||||
|
||||
async def get_xui_instance(api_url: str) -> AsyncApi:
|
||||
key = f"{api_url}|{ADMIN_USERNAME}"
|
||||
current_time = time.time()
|
||||
|
||||
xui_entry = _xui_instance_cache.get(key)
|
||||
if xui_entry:
|
||||
xui, last_login = xui_entry
|
||||
if current_time - last_login < SESSION_TTL:
|
||||
return xui
|
||||
else:
|
||||
logger.info("[XUI Cache] Сессия устарела (>30 минут), переподключение...")
|
||||
await xui.login()
|
||||
_xui_instance_cache[key] = (xui, current_time)
|
||||
return xui
|
||||
|
||||
xui = AsyncApi(
|
||||
api_url,
|
||||
ADMIN_USERNAME,
|
||||
ADMIN_PASSWORD,
|
||||
token=XUI_TOKEN if USE_XUI_TOKEN else None,
|
||||
logger=logger,
|
||||
)
|
||||
await xui.login()
|
||||
_xui_instance_cache[key] = (xui, current_time)
|
||||
return xui
|
||||
|
||||
|
||||
async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, Any]:
|
||||
"""
|
||||
Добавляет клиента на сервер через 3x-ui.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
config: Конфигурация клиента
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результат операции в формате
|
||||
{'status': 'success'|'failed'|'duplicate', 'error': str, 'email': str}
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
client = py3xui.Client(
|
||||
id=config.client_id,
|
||||
email=config.email.lower(),
|
||||
@@ -53,7 +82,6 @@ async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, An
|
||||
|
||||
response = await xui.client.add(config.inbound_id, [client])
|
||||
logger.info(f"Клиент {config.email} успешно добавлен с ID {config.client_id}")
|
||||
|
||||
return response if response else {"status": "failed"}
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
@@ -80,31 +108,10 @@ async def extend_client_key(
|
||||
sub_id: str,
|
||||
tg_id: int,
|
||||
) -> bool | None:
|
||||
"""
|
||||
Обновляет срок действия ключа клиента.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
inbound_id: ID входящего соединения
|
||||
email: Email клиента
|
||||
new_expiry_time: Новое время истечения
|
||||
client_id: ID клиента
|
||||
total_gb: Общий объем трафика
|
||||
sub_id: ID подписки
|
||||
|
||||
Returns:
|
||||
Optional[bool]: True если успешно, False если ошибка, None если клиент не найден
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} не найден.")
|
||||
return None
|
||||
|
||||
if not client.id:
|
||||
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
|
||||
if not client or not client.id:
|
||||
logger.warning(f"Клиент с email {email} не найден или не имеет ID.")
|
||||
return None
|
||||
|
||||
logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}")
|
||||
@@ -152,8 +159,6 @@ async def delete_client(
|
||||
bool: True если удаление успешно, False в противном случае
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
if SUPERNODE:
|
||||
await xui.client.delete(inbound_id, client_id)
|
||||
logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE)")
|
||||
@@ -179,20 +184,8 @@ async def delete_client(
|
||||
|
||||
|
||||
async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о трафике пользователя по client_id.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
client_id: UUID клиента
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Информация о трафике пользователя или ошибка
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
traffic_data = await xui.client.get_traffic_by_id(client_id)
|
||||
|
||||
if not traffic_data:
|
||||
logger.warning(f"Трафик для клиента {client_id} не найден.")
|
||||
return {"status": "not_found", "client_id": client_id}
|
||||
@@ -210,24 +203,8 @@ async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str,
|
||||
|
||||
|
||||
async def toggle_client(xui: py3xui.AsyncApi, inbound_id: int, email: str, client_id: str, enable: bool = True) -> bool:
|
||||
"""
|
||||
Функция для включения/отключения клиента на сервере 3x-ui.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
inbound_id: ID инбаунда
|
||||
email: Email клиента
|
||||
client_id: UUID клиента
|
||||
enable: True для включения, False для отключения
|
||||
|
||||
Returns:
|
||||
bool: True при успешном выполнении, False при ошибке
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import csv
|
||||
from datetime import datetime
|
||||
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
@@ -12,18 +13,17 @@ async def export_users_csv(session: Any) -> BufferedInputFile:
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
u.tg_id,
|
||||
u.username,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.language_code,
|
||||
u.is_bot,
|
||||
c.balance,
|
||||
c.trial,
|
||||
u.created_at -- Добавляем дату регистрации
|
||||
FROM users u
|
||||
LEFT JOIN connections c ON u.tg_id = c.tg_id
|
||||
ORDER BY u.created_at ASC -- Сортировка от старых к новым
|
||||
tg_id,
|
||||
username,
|
||||
first_name,
|
||||
last_name,
|
||||
language_code,
|
||||
is_bot,
|
||||
balance,
|
||||
trial,
|
||||
created_at
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
|
||||
users = await session.fetch(query)
|
||||
@@ -173,7 +173,7 @@ async def export_hot_leads_csv(session: Any) -> BufferedInputFile:
|
||||
|
||||
async def export_keys_csv(session) -> BufferedInputFile:
|
||||
"""
|
||||
Экспорт подписок в CSV.
|
||||
Экспорт подписок в CSV с нормальными датами.
|
||||
"""
|
||||
keys = await session.fetch("""
|
||||
SELECT tg_id, client_id, email, created_at, expiry_time, key, server_id, is_frozen, alias
|
||||
@@ -185,9 +185,12 @@ async def export_keys_csv(session) -> BufferedInputFile:
|
||||
buffer.write("tg_id,client_id,email,created_at,expiry_time,key,server_id,is_frozen,alias\n")
|
||||
|
||||
for row in keys:
|
||||
created_at = datetime.utcfromtimestamp(row['created_at'] / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
||||
expiry_time = datetime.utcfromtimestamp(row['expiry_time'] / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
buffer.write(
|
||||
f"{row['tg_id']},{row['client_id']},{row['email']},"
|
||||
f"{row['created_at']},{row['expiry_time']},{row['key']},"
|
||||
f"{created_at},{expiry_time},{row['key']},"
|
||||
f"{row['server_id']},{row['is_frozen']},{row['alias'] or ''}\n"
|
||||
)
|
||||
|
||||
|
||||