Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aef1eb2861 | |||
| b57d79d65c | |||
| 3e452c4c1b | |||
| 31582080c6 | |||
| d85dad2a9e | |||
| 7f15e208e1 | |||
| ae291e2ae7 | |||
| 7fd15da4a6 | |||
| 4fdbaca7e8 | |||
| 54180c6b28 | |||
| b7db9be638 | |||
| 76d6476ea7 | |||
| 70867dccec | |||
| dd7144cd5b | |||
| 29f54a408d | |||
| bff7d3ede0 | |||
| 2a5a254cae | |||
| f7db7f445b | |||
| ec9016f958 | |||
| d73a765866 | |||
| eb70e38a02 | |||
| b587cf527e | |||
| a8ce3f65ee | |||
| 5200e25e88 | |||
| e676213102 | |||
| 1f56820ce5 | |||
| 58937a405d | |||
| b7d13576b8 | |||
| 0828bca2ed | |||
| c1dc10e5ed | |||
| 50eba2d093 | |||
| ea6d9d300f | |||
| a06ee6f395 | |||
| 86e678de08 | |||
| 772a677424 | |||
| cccb963da2 | |||
| 2b3ce6ce55 | |||
| 1c119b1d7a | |||
| 4043380411 | |||
| c3d71bdc28 | |||
| dc6f73c539 | |||
| 476a8cb842 | |||
| bc81c64cbe | |||
| 812264722a | |||
| 0d06f61d40 | |||
| 2634facd51 | |||
| a92b6d1e86 | |||
| 4cfbcd8590 | |||
| 73ba081779 | |||
| 3aa6c26940 | |||
| dc8ff4a007 | |||
| 6390a18327 | |||
| a5d9dad826 | |||
| 06e6b30d31 | |||
| f9e404a7c2 | |||
| 3920992cee | |||
| fcb6dd9773 | |||
| 6d42647bf6 | |||
| 6e3b33e335 | |||
| 063ade1a2a | |||
| 56ffbd654e | |||
| 47777c7ee8 | |||
| 27d021171e | |||
| d4b00fb494 | |||
| 7816fa535c | |||
| 0208c48212 | |||
| 4295e4b5ec | |||
| 4fb13a089a | |||
| f1841caea7 | |||
| 4a67f9233d | |||
| e55a89af8e | |||
| 1f172f1a21 | |||
| 3f7bcb090a | |||
| a3b049dc1f | |||
| bf2d225c55 | |||
| 33b15b8486 | |||
| 19a273baf4 | |||
| 1c205f4a24 | |||
| f2a47de5da | |||
| 372933e230 | |||
| 11236ac64e | |||
| 46f71a7a6e | |||
| 70293fe387 | |||
| c7915a45b8 | |||
| 8b016c7010 | |||
| 17032811a4 | |||
| ff591fc387 | |||
| ece35bb90a | |||
| dfec3c5748 | |||
| 29ecb61ad2 | |||
| 79fd3cde5d | |||
| 5f4a23e808 | |||
| 3d10e2c1fe | |||
| 6b8507253e | |||
| 90d9c0bc54 | |||
| 71301f90ae | |||
| 472a3ad69d | |||
| 5cd63bc89d | |||
| 77bd87a2b5 | |||
| 53483ce6aa | |||
| a9e546091a | |||
| 924b5e15c5 | |||
| ae9b5b1bf7 | |||
| adb669fa9e | |||
| 2daf2fadc8 | |||
| c9b0cd2db1 | |||
| 098459f3ce | |||
| 64d25967c9 | |||
| 5804a8e87a | |||
| 388fbb5afc | |||
| d3e502ce43 | |||
| af497f644a | |||
| e57680f3a7 | |||
| c7eff5cc3e | |||
| 3ccbf00259 | |||
| 6066775cf4 | |||
| 92ec071e6b | |||
| 8e070349ba | |||
| 80360691f2 | |||
| 2f0c22abe8 | |||
| 9c7cbe403b | |||
| fcdfd3a484 | |||
| 8f1ceb4e66 | |||
| 2db29fba9d | |||
| d0125c1f33 | |||
| 0dba6a6e42 | |||
| 1d6ae5fa07 |
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Сохраняем текущие изменения
|
||||
echo "Сохранение текущих изменений..."
|
||||
git stash -q --keep-index
|
||||
|
||||
# Запускаем форматирование кода
|
||||
echo "Запуск Ruff format..."
|
||||
ruff format . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
# Запускаем проверку и исправление кода
|
||||
echo "Запуск Ruff check с автоисправлением..."
|
||||
ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
|
||||
|
||||
# Добавляем изменения, внесенные форматированием
|
||||
git add -u
|
||||
|
||||
# Восстанавливаем сохраненные изменения
|
||||
echo "Восстановление сохраненных изменений..."
|
||||
git stash pop -q
|
||||
|
||||
# Выход с кодом 0 (успешно)
|
||||
exit 0
|
||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 867 KiB After Width: | Height: | Size: 867 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||
|
||||
<img src="./img/preview.gif" alt="SoloBot" width="1600">
|
||||
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
|
||||
|
||||
# Описание
|
||||
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
|
||||
@@ -60,7 +60,7 @@
|
||||
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
|
||||
|
||||
|
||||
<img src="./img/preview_2.gif" alt="SoloBot" width="1600">
|
||||
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
|
||||
|
||||
### Кастомизация
|
||||
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
|
||||
|
||||
@@ -42,6 +42,24 @@ CREATE TABLE IF NOT EXISTS keys
|
||||
PRIMARY KEY (tg_id, client_id)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'is_frozen'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN is_frozen BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'alias'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN alias TEXT;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals
|
||||
(
|
||||
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
@@ -54,11 +72,22 @@ CREATE TABLE IF NOT EXISTS coupons
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
days INTEGER CHECK (days > 0 OR days IS NULL),
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'coupons' AND column_name = 'days'
|
||||
) THEN
|
||||
ALTER TABLE coupons ADD COLUMN days INTEGER CHECK (days > 0 OR days IS NULL);
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupon_usages
|
||||
(
|
||||
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
import aiofiles
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
from bot import bot
|
||||
@@ -48,17 +49,14 @@ def _create_database_backup() -> tuple[str | None, Exception | None]:
|
||||
"""
|
||||
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
|
||||
# Создаем директорию для бэкапов, если она не существует
|
||||
backup_dir = Path(BACK_DIR)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
|
||||
|
||||
try:
|
||||
# Устанавливаем пароль PostgreSQL через переменную окружения
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
# Запускаем pg_dump для создания бэкапа
|
||||
subprocess.run(
|
||||
[
|
||||
"pg_dump",
|
||||
@@ -87,7 +85,6 @@ def _create_database_backup() -> tuple[str | None, Exception | None]:
|
||||
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
|
||||
return None, e
|
||||
finally:
|
||||
# Удаляем переменную окружения с паролем
|
||||
if "PGPASSWORD" in os.environ:
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
@@ -104,10 +101,8 @@ def _cleanup_old_backups() -> Exception | None:
|
||||
if not backup_dir.exists():
|
||||
return None
|
||||
|
||||
# Вычисляем дату, старше которой нужно удалить файлы
|
||||
cutoff_date = datetime.now() - timedelta(days=3)
|
||||
|
||||
# Находим и удаляем старые файлы бэкапов
|
||||
for backup_file in backup_dir.glob("*.sql"):
|
||||
if backup_file.is_file():
|
||||
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
@@ -152,7 +147,6 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
|
||||
filename = os.path.basename(backup_file_path)
|
||||
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
|
||||
|
||||
# Отправляем файл каждому администратору
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
await bot.send_document(chat_id=admin_id, document=backup_input_file)
|
||||
|
||||
@@ -14,11 +14,12 @@ from filters.private import IsPrivateFilter
|
||||
from logger import logger
|
||||
from middlewares import register_middleware
|
||||
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
version = "4.0"
|
||||
version = "4.1-Release"
|
||||
|
||||
register_middleware(dp)
|
||||
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt, Confirm
|
||||
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')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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"
|
||||
|
||||
BACK_DIR = os.path.expanduser("~/.solobot_backup")
|
||||
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
|
||||
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
SERVICE_NAME = BOT_SERVICE
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def print_logo():
|
||||
logo = Text("""
|
||||
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
|
||||
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
|
||||
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
|
||||
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
|
||||
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
|
||||
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
|
||||
""", style="bold cyan")
|
||||
console.print(logo)
|
||||
|
||||
|
||||
def backup_project():
|
||||
console.print("[yellow]📦 Создаётся резервная копия проекта...[/yellow]")
|
||||
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
|
||||
subprocess.run(["rm", "-rf", BACK_DIR])
|
||||
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
|
||||
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
|
||||
|
||||
|
||||
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 install_git_if_needed():
|
||||
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
|
||||
console.print("[blue]Установка Git...[/blue]")
|
||||
os.system("sudo apt update && sudo apt install -y git")
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
for line in f:
|
||||
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def get_remote_version(branch="main"):
|
||||
try:
|
||||
url = f"https://raw.githubusercontent.com/Vladless/Solo_bot/{branch}/bot.py"
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
for line in response.text.splitlines():
|
||||
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def update_from_beta():
|
||||
local_version = get_local_version()
|
||||
remote_version = get_remote_version(branch="dev")
|
||||
|
||||
if local_version and remote_version:
|
||||
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
|
||||
if local_version == remote_version:
|
||||
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||
return
|
||||
else:
|
||||
console.print("[red]⚠️ Не удалось определить версии.[/red]")
|
||||
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
return
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
install_rsync_if_needed()
|
||||
|
||||
os.chdir(PROJECT_DIR)
|
||||
|
||||
console.print("[cyan]📥 Клонируем репозиторий dev во временную папку...[/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(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
install_dependencies()
|
||||
restart_service()
|
||||
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
|
||||
|
||||
|
||||
def update_from_release():
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
|
||||
return
|
||||
|
||||
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
|
||||
)
|
||||
releases = response.json()[:3]
|
||||
tag_choices = [r["tag_name"] for r in releases]
|
||||
|
||||
if not tag_choices:
|
||||
raise ValueError("Не удалось получить список релизов")
|
||||
|
||||
console.print("\n[bold green]Доступные релизы:[/bold green]")
|
||||
for idx, tag in enumerate(tag_choices, 1):
|
||||
console.print(f"[cyan]{idx}.[/cyan] {tag}")
|
||||
|
||||
selected = Prompt.ask(
|
||||
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
|
||||
)
|
||||
tag_name = tag_choices[int(selected) - 1]
|
||||
|
||||
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)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
install_dependencies()
|
||||
restart_service()
|
||||
console.print(f"[green]✅ Обновление до релиза {tag_name} завершено.[/green]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
|
||||
|
||||
|
||||
def show_update_menu():
|
||||
table = Table(title="Выберите способ обновления", title_style="bold green")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Источник", style="white")
|
||||
table.add_row("1", "Обновить до BETA")
|
||||
table.add_row("2", "Обновить до последнего релиза")
|
||||
table.add_row("3", "Назад в меню")
|
||||
|
||||
console.print(table)
|
||||
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
|
||||
|
||||
if choice == "1":
|
||||
update_from_beta()
|
||||
elif choice == "2":
|
||||
update_from_release()
|
||||
|
||||
|
||||
def show_menu():
|
||||
table = Table(title="Solobot CLI", 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)")
|
||||
table.add_row("4", "Остановить бота (systemd)")
|
||||
table.add_row("5", "Показать логи (80 строк)")
|
||||
table.add_row("6", "Показать статус")
|
||||
table.add_row("7", "Обновить Solobot")
|
||||
table.add_row("8", "Выход")
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
console.print("[bold red]⛔ Требуется запуск от имени root или через sudo.[/bold red]")
|
||||
sys.exit(1)
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+68
-52
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -62,12 +63,10 @@ async def init_db(file_path: str = "assets/schema.sql"):
|
||||
with open(file_path) as file:
|
||||
sql_content = file.read()
|
||||
|
||||
statements = [stmt.strip() for stmt in sql_content.split(";") if stmt.strip()]
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
try:
|
||||
for statement in statements:
|
||||
await conn.execute(statement)
|
||||
await conn.execute(sql_content)
|
||||
except Exception as e:
|
||||
logger.error(f"Error while executing SQL statement: {e}")
|
||||
finally:
|
||||
@@ -125,33 +124,36 @@ async def check_server_name_by_cluster(server_name: str, session: Any) -> dict |
|
||||
raise
|
||||
|
||||
|
||||
async def create_coupon(coupon_code: str, amount: float, usage_limit: int, session: Any):
|
||||
async def create_coupon(coupon_code: str, amount: int, usage_limit: int, session: Any, days: int = None):
|
||||
"""
|
||||
Создает новый купон в базе данных.
|
||||
|
||||
Args:
|
||||
coupon_code (str): Уникальный код купона.
|
||||
amount (float): Сумма, которую дает купон.
|
||||
amount (int): Сумма, которую дает купон (0 для купонов на дни).
|
||||
usage_limit (int): Максимальное количество использований купона.
|
||||
session (Any): Сессия базы данных для выполнения запроса.
|
||||
days (int, optional): Количество дней для продления подписки.
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при создании купона.
|
||||
|
||||
Example:
|
||||
await create_coupon('SALE50', 50.0, 5, session)
|
||||
await create_coupon('SALE50', 50, 5, session)
|
||||
await create_coupon('DAYS10', 0, 50, session, days=10)
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT INTO coupons (code, amount, usage_limit, usage_count, is_used)
|
||||
VALUES ($1, $2, $3, 0, FALSE)
|
||||
""",
|
||||
INSERT INTO coupons (code, amount, usage_limit, usage_count, is_used, days)
|
||||
VALUES ($1, $2, $3, 0, FALSE, $4)
|
||||
""",
|
||||
coupon_code,
|
||||
amount,
|
||||
usage_limit,
|
||||
days,
|
||||
)
|
||||
logger.info(f"Успешно создан купон с кодом {coupon_code} на сумму {amount}")
|
||||
logger.info(f"Успешно создан купон с кодом {coupon_code} на сумму {amount} или {days} дней")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона {coupon_code}: {e}")
|
||||
raise
|
||||
@@ -171,7 +173,8 @@ async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None:
|
||||
- usage_limit (int): Лимит использований
|
||||
- usage_count (int): Текущее количество использований
|
||||
- is_used (bool): Флаг использования
|
||||
- amount (float): Сумма купона
|
||||
- amount (int): Сумма купона
|
||||
- days (int): Количество дней (если есть)
|
||||
|
||||
Raises:
|
||||
Exception: В случае ошибки при выполнении запроса
|
||||
@@ -179,7 +182,7 @@ async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None:
|
||||
try:
|
||||
result = await session.fetchrow(
|
||||
"""
|
||||
SELECT id, usage_limit, usage_count, is_used, amount
|
||||
SELECT id, usage_limit, usage_count, is_used, amount, days
|
||||
FROM coupons
|
||||
WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE
|
||||
""",
|
||||
@@ -214,7 +217,7 @@ async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10):
|
||||
offset = (page - 1) * per_page
|
||||
coupons = await session.fetch(
|
||||
"""
|
||||
SELECT code, amount, usage_limit, usage_count
|
||||
SELECT id, code, amount, usage_limit, usage_count, days, is_used -- Добавлено id
|
||||
FROM coupons
|
||||
ORDER BY id
|
||||
LIMIT $1 OFFSET $2
|
||||
@@ -222,12 +225,9 @@ async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10):
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
|
||||
total_count = await session.fetchval("SELECT COUNT(*) FROM coupons")
|
||||
total_pages = -(-total_count // per_page) # Округление вверх
|
||||
|
||||
total_pages = -(-total_count // per_page)
|
||||
logger.info(f"Успешно получено {len(coupons)} купонов из базы данных (страница {page})")
|
||||
|
||||
return {"coupons": coupons, "total": total_count, "pages": total_pages, "current_page": page}
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка при получении списка купонов: {e}")
|
||||
@@ -417,6 +417,14 @@ async def store_key(
|
||||
raise
|
||||
|
||||
|
||||
async def get_clusters(session) -> list[str]:
|
||||
"""
|
||||
Получает список уникальных имён кластеров из таблицы servers.
|
||||
"""
|
||||
rows = await session.fetch("SELECT DISTINCT cluster_name FROM servers ORDER BY cluster_name")
|
||||
return [row["cluster_name"] for row in rows]
|
||||
|
||||
|
||||
async def get_keys(tg_id: int, session: Any):
|
||||
"""
|
||||
Получает список ключей для указанного пользователя.
|
||||
@@ -542,8 +550,8 @@ async def update_balance(
|
||||
amount: float,
|
||||
session: Any = None,
|
||||
is_admin: bool = False,
|
||||
skip_referral: bool = False, # <- флаг "пропустить реферальное начисление"
|
||||
skip_cashback: bool = False, # <- флаг "пропустить кэшбэк"
|
||||
skip_referral: bool = False,
|
||||
skip_cashback: bool = False,
|
||||
):
|
||||
"""
|
||||
Обновляет баланс пользователя в базе данных.
|
||||
@@ -556,7 +564,6 @@ async def update_balance(
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
session = conn
|
||||
|
||||
# Если пополнение не от админа и не сказали пропустить кэшбэк
|
||||
if CASHBACK > 0 and amount > 0 and not is_admin and not skip_cashback:
|
||||
extra = amount * (CASHBACK / 100.0)
|
||||
else:
|
||||
@@ -582,7 +589,6 @@ async def update_balance(
|
||||
f"({'+ кешбэк' if extra > 0 else 'без кешбэка'}), стало: {new_balance}"
|
||||
)
|
||||
|
||||
# Если не админ и не пропустили реферальное начисление — обрабатываем реферальную цепочку
|
||||
if not is_admin and not skip_referral:
|
||||
await handle_referral_on_balance_update(tg_id, int(amount))
|
||||
|
||||
@@ -672,6 +678,9 @@ async def handle_referral_on_balance_update(tg_id: int, amount: float):
|
||||
tg_id (int): Идентификатор Telegram пользователя, пополнившего баланс
|
||||
amount (float): Сумма пополнения баланса
|
||||
"""
|
||||
|
||||
if amount <= 0:
|
||||
return
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
@@ -849,14 +858,12 @@ async def get_total_referral_bonus(conn, referrer_tg_id: int, max_levels: int) -
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
{
|
||||
" ".join(
|
||||
[
|
||||
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * ep.amount"
|
||||
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
|
||||
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
|
||||
for level in REFERRAL_BONUS_PERCENTAGES
|
||||
]
|
||||
)
|
||||
" ".join([
|
||||
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * ep.amount"
|
||||
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
|
||||
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
|
||||
for level in REFERRAL_BONUS_PERCENTAGES
|
||||
])
|
||||
}
|
||||
ELSE 0
|
||||
END
|
||||
@@ -895,14 +902,12 @@ async def get_total_referral_bonus(conn, referrer_tg_id: int, max_levels: int) -
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
{
|
||||
" ".join(
|
||||
[
|
||||
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * p.amount"
|
||||
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
|
||||
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
|
||||
for level in REFERRAL_BONUS_PERCENTAGES
|
||||
]
|
||||
)
|
||||
" ".join([
|
||||
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * p.amount"
|
||||
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
|
||||
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
|
||||
for level in REFERRAL_BONUS_PERCENTAGES
|
||||
])
|
||||
}
|
||||
ELSE 0
|
||||
END
|
||||
@@ -1229,6 +1234,21 @@ async def add_notification(tg_id: int, notification_type: str, session: Any):
|
||||
raise
|
||||
|
||||
|
||||
async def delete_notification(tg_id: int, notification_type: str, session):
|
||||
"""
|
||||
Удаляет уведомление пользователя по типу (например: 'email_key_expired').
|
||||
"""
|
||||
try:
|
||||
await session.execute(
|
||||
"DELETE FROM notifications WHERE tg_id = $1 AND notification_type = $2",
|
||||
tg_id,
|
||||
notification_type,
|
||||
)
|
||||
logger.info(f"🗑 Уведомление '{notification_type}' для пользователя {tg_id} удалено.")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при удалении уведомления '{notification_type}' для пользователя {tg_id}: {e}")
|
||||
|
||||
|
||||
async def check_notification_time(tg_id: int, notification_type: str, hours: int = 12, session: Any = None) -> bool:
|
||||
"""
|
||||
Проверяет, прошло ли указанное количество часов с момента последнего уведомления.
|
||||
@@ -1336,14 +1356,12 @@ async def get_servers(session: Any = None):
|
||||
if cluster_name not in servers:
|
||||
servers[cluster_name] = []
|
||||
|
||||
servers[cluster_name].append(
|
||||
{
|
||||
"server_name": row["server_name"],
|
||||
"api_url": row["api_url"],
|
||||
"subscription_url": row["subscription_url"],
|
||||
"inbound_id": row["inbound_id"],
|
||||
}
|
||||
)
|
||||
servers[cluster_name].append({
|
||||
"server_name": row["server_name"],
|
||||
"api_url": row["api_url"],
|
||||
"subscription_url": row["subscription_url"],
|
||||
"inbound_id": row["inbound_id"],
|
||||
})
|
||||
|
||||
return servers
|
||||
|
||||
@@ -1424,7 +1442,7 @@ async def store_gift_link(
|
||||
async def get_key_details(email, session):
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.server_id, k.key, k.email, k.expiry_time, k.client_id, k.created_at, c.tg_id, c.balance
|
||||
SELECT k.server_id, k.key, k.email, k.is_frozen, k.expiry_time, k.client_id, k.created_at, c.tg_id, c.balance
|
||||
FROM keys k
|
||||
JOIN connections c ON k.tg_id = c.tg_id
|
||||
WHERE k.email = $1
|
||||
@@ -1462,6 +1480,7 @@ async def get_key_details(email, session):
|
||||
"balance": record["balance"],
|
||||
"tg_id": record["tg_id"],
|
||||
"email": record["email"],
|
||||
"is_frozen": record["is_frozen"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1665,12 +1684,12 @@ async def get_last_payments(tg_id: int, session: Any):
|
||||
raise
|
||||
|
||||
|
||||
async def get_coupon_details(coupon_id: str, session: Any):
|
||||
async def get_coupon_details(coupon_id: int, session: Any):
|
||||
"""
|
||||
Получает детали купона по его ID.
|
||||
|
||||
Args:
|
||||
coupon_id (str): ID купона
|
||||
coupon_id (int): ID купона
|
||||
session (Any): Сессия базы данных
|
||||
|
||||
Returns:
|
||||
@@ -1682,20 +1701,17 @@ async def get_coupon_details(coupon_id: str, session: Any):
|
||||
try:
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT id, code, discount, usage_count, usage_limit, is_used
|
||||
SELECT id, code, amount, days, usage_count, usage_limit, is_used
|
||||
FROM coupons
|
||||
WHERE id = $1
|
||||
""",
|
||||
coupon_id,
|
||||
)
|
||||
|
||||
if record:
|
||||
logger.info(f"Успешно получены детали купона {coupon_id}")
|
||||
return dict(record)
|
||||
|
||||
logger.warning(f"Купон {coupon_id} не найден")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении деталей купона {coupon_id}: {e}")
|
||||
raise
|
||||
|
||||
@@ -14,6 +14,7 @@ from .payments import router as payments_router
|
||||
from .profile import router as profile_router
|
||||
from .start import router as start_router
|
||||
|
||||
|
||||
router = Router(name="handlers_main_router")
|
||||
|
||||
router.include_routers(
|
||||
|
||||
@@ -2,21 +2,26 @@ __all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .admin_backups import router as backups_router
|
||||
from .admin_bans import router as bans_router
|
||||
from .admin_coupons import router as coupons_router
|
||||
from .admin_panel import router as panel_router
|
||||
from .admin_restart import router as restart_router
|
||||
from .admin_sender import router as sender_router
|
||||
from .admin_servers import router as servers_router
|
||||
from .admin_stats import router as stats_router
|
||||
from .admin_users import router as users_router
|
||||
from .backups import router as backups_router
|
||||
from .bans import router as bans_router
|
||||
from .clusters import router as clusters_router
|
||||
from .coupons import router as coupons_router
|
||||
from .management import router as management_router
|
||||
from .panel import router as panel_router
|
||||
from .restart import router as restart_router
|
||||
from .sender import router as sender_router
|
||||
from .servers import router as servers_router
|
||||
from .stats import router as stats_router
|
||||
from .users import router as users_router
|
||||
|
||||
|
||||
router = Router(name="admins_main_router")
|
||||
|
||||
router.include_routers(
|
||||
panel_router,
|
||||
management_router,
|
||||
servers_router,
|
||||
clusters_router,
|
||||
users_router,
|
||||
stats_router,
|
||||
backups_router,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
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, Message
|
||||
|
||||
from config import USERNAME_BOT
|
||||
from database import create_coupon, delete_coupon, get_all_coupons
|
||||
from filters.admin import IsAdminFilter
|
||||
from keyboards.admin.coupons_kb import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_data = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons(
|
||||
callback_query: CallbackQuery,
|
||||
):
|
||||
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons_create"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("coupons"),
|
||||
)
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
|
||||
async def handle_coupon_data_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
kb = build_admin_back_kb("coupons")
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
coupon_amount = float(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
|
||||
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"💰 Сумма: <b>{coupon_amount} рублей</b> \n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}</code>\n"
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=kb)
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons_list"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
data = AdminPanelCallback.unpack(callback_query.data)
|
||||
page = data.page if data.page is not None else 1
|
||||
per_page = 10
|
||||
result = await get_all_coupons(session, page, per_page)
|
||||
coupons = result["coupons"]
|
||||
|
||||
if not coupons:
|
||||
await callback_query.message.edit_text(
|
||||
text="❌ На данный момент нет доступных купонов!",
|
||||
reply_markup=build_admin_back_kb("coupons"),
|
||||
)
|
||||
return
|
||||
|
||||
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
for coupon in coupons:
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{USERNAME_BOT}?start=coupons_{coupon['code']}</code>\n"
|
||||
)
|
||||
await callback_query.message.edit_text(text=coupon_list, reply_markup=kb)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||
await callback_query.message.answer("Произошла ошибка при получении списка купонов.")
|
||||
|
||||
|
||||
@router.callback_query(AdminCouponDeleteCallback.filter(), IsAdminFilter())
|
||||
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
|
||||
coupon_code = callback_data.coupon_code
|
||||
try:
|
||||
result = await delete_coupon(coupon_code, session)
|
||||
if result:
|
||||
await callback_query.message.edit_text(f"Купон {coupon_code} удалён!")
|
||||
else:
|
||||
await callback_query.message.edit_text(f"❌ Купон с кодом {coupon_code} не найден.", show_alert=True)
|
||||
await update_coupons_list(callback_query.message, session)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
await callback_query.message.edit_text("Произошла ошибка при удалении купона.", show_alert=True)
|
||||
|
||||
|
||||
async def update_coupons_list(message, session: Any, page: int = 1):
|
||||
per_page = 10
|
||||
result = await get_all_coupons(session, page, per_page)
|
||||
coupons = result["coupons"]
|
||||
|
||||
if not coupons:
|
||||
await message.edit_text(
|
||||
text="❌ На данный момент нет доступных купонов!",
|
||||
reply_markup=build_admin_back_kb("coupons"),
|
||||
)
|
||||
return
|
||||
|
||||
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
for coupon in coupons:
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{USERNAME_BOT}?start=coupons_{coupon['code']}</code>\n"
|
||||
)
|
||||
await message.edit_text(text=coupon_list, reply_markup=kb)
|
||||
@@ -1,491 +0,0 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
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
|
||||
from database import check_unique_server_name, delete_server, get_servers
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
|
||||
from keyboards.admin.servers_kb import (
|
||||
AdminServerEditorCallback,
|
||||
build_clusters_editor_kb,
|
||||
build_delete_server_kb,
|
||||
build_manage_cluster_kb,
|
||||
build_manage_server_kb,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminServersEditor(StatesGroup):
|
||||
waiting_for_cluster_name = State()
|
||||
waiting_for_api_url = State()
|
||||
waiting_for_inbound_id = State()
|
||||
waiting_for_server_name = State()
|
||||
waiting_for_subscription_url = State()
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "servers"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_servers(callback_query: CallbackQuery):
|
||||
servers = await get_servers()
|
||||
|
||||
text = (
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
|
||||
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_clusters_editor_kb(servers),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "clusters_add"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🔧 <b>Введите имя нового кластера:</b>\n\n"
|
||||
"<b>Имя кластера должно быть уникальным!</b>\n"
|
||||
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("servers"))
|
||||
|
||||
await state.set_state(AdminServersEditor.waiting_for_cluster_name)
|
||||
|
||||
|
||||
@router.message(AdminServersEditor.waiting_for_cluster_name, IsAdminFilter())
|
||||
async def handle_cluster_name_input(message: Message, state: FSMContext):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("servers")
|
||||
)
|
||||
return
|
||||
|
||||
cluster_name = message.text.strip()
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminServersEditor.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.message(AdminServersEditor.waiting_for_server_name, IsAdminFilter())
|
||||
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("servers")
|
||||
)
|
||||
return
|
||||
|
||||
server_name = message.text.strip()
|
||||
|
||||
if len(server_name) > 14:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 10 символов. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
if not await check_unique_server_name(server_name, session, cluster_name):
|
||||
await message.answer(
|
||||
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(server_name=server_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"API URL должен быть в следующем формате:\n\n"
|
||||
"<code>https://your_domain:port/panel_path</code>\n\n"
|
||||
"URL должен быть без слэша на конце!\n"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminServersEditor.waiting_for_api_url)
|
||||
|
||||
|
||||
@router.message(AdminServersEditor.waiting_for_api_url, IsAdminFilter())
|
||||
async def handle_api_url_input(message: Message, state: FSMContext, session: Any):
|
||||
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("servers"),
|
||||
)
|
||||
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"
|
||||
"Subscription URL должен быть в следующем формате:\n\n"
|
||||
"<code>https://your_domain:port_sub/sub_path</code>\n\n"
|
||||
"URL должен быть без слэша и имени клиента на конце!\n"
|
||||
"Его можно увидеть в панели 3x-ui в информации о клиенте."
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminServersEditor.waiting_for_subscription_url)
|
||||
|
||||
|
||||
@router.message(AdminServersEditor.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("servers"),
|
||||
)
|
||||
return
|
||||
|
||||
subscription_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(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,
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
await state.set_state(AdminServersEditor.waiting_for_inbound_id)
|
||||
|
||||
|
||||
@router.message(AdminServersEditor.waiting_for_inbound_id, IsAdminFilter())
|
||||
async def handle_inbound_id_input(message: Message, state: FSMContext):
|
||||
inbound_id = message.text.strip()
|
||||
|
||||
if not inbound_id.isdigit():
|
||||
await message.answer(
|
||||
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
return
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
""",
|
||||
cluster_name,
|
||||
server_name,
|
||||
api_url,
|
||||
subscription_url,
|
||||
inbound_id,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_manage"), IsAdminFilter())
|
||||
async def handle_clusters_manage(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"🔧 Управление серверами для кластера {cluster_name}",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_availability"), IsAdminFilter())
|
||||
async def handle_servers_availability(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
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=text)
|
||||
|
||||
result_text = f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
|
||||
total_online_users = 0
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
|
||||
|
||||
try:
|
||||
await xui.login()
|
||||
online_users = len(await xui.client.online())
|
||||
total_online_users += online_users
|
||||
result_text += f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
|
||||
|
||||
except Exception as e:
|
||||
result_text += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
|
||||
|
||||
result_text += f"\n👥 Общее количество активных пользователей в кластере: {total_online_users}."
|
||||
|
||||
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("servers"))
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_manage"), IsAdminFilter())
|
||||
async def handle_servers_manage(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback):
|
||||
server_name = callback_data.data
|
||||
servers = await get_servers()
|
||||
|
||||
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 server:
|
||||
api_url = server["api_url"]
|
||||
subscription_url = server["subscription_url"]
|
||||
inbound_id = server["inbound_id"]
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(text="❌ Сервер не найден.")
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete"), IsAdminFilter())
|
||||
async def handle_servers_delete(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback):
|
||||
server_name = callback_data.data
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
|
||||
reply_markup=build_delete_server_kb(server_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"), IsAdminFilter())
|
||||
async def handle_servers_delete_confirm(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
|
||||
):
|
||||
server_name = callback_data.data
|
||||
|
||||
await delete_server(server_name, session)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"🗑️ Сервер {server_name} успешно удален.", reply_markup=build_admin_back_kb("servers")
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_add"), IsAdminFilter())
|
||||
async def handle_servers_add(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, state: FSMContext
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminServersEditor.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_backup"), IsAdminFilter())
|
||||
async def handle_clusters_backup(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
await create_backup_and_send_to_admins(xui)
|
||||
|
||||
text = (
|
||||
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_sync"), IsAdminFilter())
|
||||
async def handle_clusters_sync(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
try:
|
||||
query_keys = """
|
||||
SELECT tg_id, client_id, email, expiry_time
|
||||
FROM keys
|
||||
WHERE server_id = $1
|
||||
"""
|
||||
keys_to_sync = await session.fetch(query_keys, cluster_name)
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.answer(
|
||||
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
return
|
||||
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
await create_key_on_cluster(
|
||||
cluster_name,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
)
|
||||
await asyncio.sleep(0.6)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}")
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
|
||||
reply_markup=build_admin_back_kb("servers"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("servers")
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
||||
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||
"""Запрашивает у администратора новый домен."""
|
||||
await state.set_state(AdminServersEditor.waiting_for_new_domain)
|
||||
await callback_query.message.edit_text(
|
||||
text="🌐 Введите новый домен (без https://):\nПример: pocomachodomen.ru",
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminServersEditor.waiting_for_new_domain)
|
||||
async def process_new_domain(message: Message, state: FSMContext, session: asyncpg.Connection):
|
||||
"""Обновляет домен в таблице keys."""
|
||||
new_domain = message.text.strip()
|
||||
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
|
||||
|
||||
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
await message.answer(
|
||||
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
new_domain_url = f"https://{new_domain}"
|
||||
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
|
||||
|
||||
query = """
|
||||
UPDATE keys
|
||||
SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT)
|
||||
WHERE key NOT LIKE $1 || '%'
|
||||
"""
|
||||
try:
|
||||
await session.execute(query, new_domain_url)
|
||||
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||
await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin"))
|
||||
return
|
||||
|
||||
try:
|
||||
sample = await session.fetchrow("SELECT key FROM keys LIMIT 1")
|
||||
logger.info(f"[DomainChange] Пример обновленной записи: {sample}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||
|
||||
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
|
||||
await state.clear()
|
||||
@@ -1,120 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
|
||||
from keyboards.admin.stats_kb import build_stats_kb
|
||||
from logger import logger
|
||||
from utils.csv_export import export_payments_csv, export_users_csv
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_stats(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
total_users = await session.fetchval("SELECT COUNT(*) FROM users")
|
||||
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
|
||||
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
|
||||
|
||||
total_payments_today = int(
|
||||
await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE")
|
||||
)
|
||||
total_payments_week = int(
|
||||
await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
)
|
||||
total_payments_month = int(
|
||||
await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
)
|
||||
total_payments_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
|
||||
|
||||
registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
|
||||
registrations_week = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
registrations_month = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
|
||||
users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
|
||||
|
||||
active_keys = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
expired_keys = total_keys - active_keys
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
stats_message = (
|
||||
f"📊 <b>Подробная статистика проекта:</b>\n\n"
|
||||
f"👥 Пользователи:\n"
|
||||
f" 📅 За день: <b>{registrations_today}</b>\n"
|
||||
f" 📆 За неделю: <b>{registrations_week}</b>\n"
|
||||
f" 📆 За месяц: <b>{registrations_month}</b>\n"
|
||||
f" 🌐 За все время: <b>{total_users}</b>\n\n"
|
||||
f"🌟 Активные пользователи:\n"
|
||||
f" 🌟 Активных сегодня: <b>{users_updated_today}</b>\n\n"
|
||||
f"👥 Рефералы:\n"
|
||||
f" 🤝 Всего привлечено: <b>{total_referrals}</b>\n\n"
|
||||
f"🔑 Ключи:\n"
|
||||
f" 🌈 Всего сгенерировано: <b>{total_keys}</b>\n"
|
||||
f" ✅ Действующих: <b>{active_keys}</b>\n"
|
||||
f" ❌ Просроченных: <b>{expired_keys}</b>\n\n"
|
||||
f"💰 Финансовая статистика:\n"
|
||||
f" 📅 За день: <b>{total_payments_today} ₽</b>\n"
|
||||
f" 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
|
||||
f" 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
|
||||
f" 🏦 За все время: <b>{total_payments_all_time} ₽</b>\n\n"
|
||||
f" ⏳ Последнее обновление: {update_time}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" not in str(e): # skip when Telegram message is not modified
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
|
||||
try:
|
||||
export = await export_users_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт пользователей в CSV")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
|
||||
try:
|
||||
export = await export_payments_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт платежей в CSV")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .backups_handler import router
|
||||
@@ -3,7 +3,9 @@ from aiogram.types import CallbackQuery
|
||||
|
||||
from backup import backup_database
|
||||
from filters.admin import IsAdminFilter
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .bans_handler import router
|
||||
@@ -5,8 +5,10 @@ from aiogram.types import BufferedInputFile, CallbackQuery
|
||||
|
||||
from database import delete_user_data
|
||||
from filters.admin import IsAdminFilter
|
||||
from keyboards.admin.bans_kb import build_bans_kb
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_bans_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
def build_bans_kb() -> InlineKeyboardMarkup:
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .clusters_handler import router
|
||||
@@ -0,0 +1,829 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
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 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 logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminClusterCallback,
|
||||
AdminServerCallback,
|
||||
build_cluster_management_kb,
|
||||
build_clusters_editor_kb,
|
||||
build_manage_cluster_kb,
|
||||
build_sync_cluster_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminClusterStates(StatesGroup):
|
||||
waiting_for_cluster_name = State()
|
||||
waiting_for_api_url = State()
|
||||
waiting_for_inbound_id = State()
|
||||
waiting_for_server_name = State()
|
||||
waiting_for_subscription_url = State()
|
||||
waiting_for_days_input = State()
|
||||
waiting_for_new_cluster_name = State()
|
||||
waiting_for_new_server_name = State()
|
||||
waiting_for_server_transfer = State()
|
||||
waiting_for_cluster_transfer = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "clusters"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_servers(callback_query: CallbackQuery):
|
||||
servers = await get_servers()
|
||||
|
||||
text = (
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
|
||||
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_clusters_editor_kb(servers),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🔧 <b>Введите имя нового кластера:</b>\n\n"
|
||||
"<b>Имя должно быть уникальным!</b>\n"
|
||||
"<b>Имя не должно превышать 12 символов!</b>\n\n"
|
||||
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
|
||||
async def handle_cluster_name_input(message: Message, state: FSMContext):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
|
||||
)
|
||||
return
|
||||
|
||||
if len(message.text) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
cluster_name = message.text.strip()
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
|
||||
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
|
||||
)
|
||||
return
|
||||
|
||||
server_name = message.text.strip()
|
||||
|
||||
if len(server_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
if not await check_unique_server_name(server_name, session, cluster_name):
|
||||
await message.answer(
|
||||
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(server_name=server_name)
|
||||
|
||||
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>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_api_url)
|
||||
|
||||
|
||||
@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"),
|
||||
)
|
||||
|
||||
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("/")
|
||||
|
||||
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,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
|
||||
async def handle_inbound_id_input(message: Message, state: FSMContext):
|
||||
inbound_id = message.text.strip()
|
||||
|
||||
if not inbound_id.isdigit():
|
||||
await message.answer(
|
||||
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
""",
|
||||
cluster_name,
|
||||
server_name,
|
||||
api_url,
|
||||
subscription_url,
|
||||
inbound_id,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_clusters_manage(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
|
||||
):
|
||||
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_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
|
||||
async def handle_cluster_availability(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
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=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)
|
||||
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"
|
||||
except Exception as e:
|
||||
result_text += f"❌ <b>{server['server_name']}</b> - ошибка: {str(e) if str(e).strip() else 'Сервер недоступен'}\n"
|
||||
|
||||
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
|
||||
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
|
||||
async def handle_clusters_backup(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await create_backup_and_send_to_admins(xui)
|
||||
|
||||
text = (
|
||||
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
|
||||
async def handle_sync(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
|
||||
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_sync_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
|
||||
async def handle_sync_server(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
|
||||
server_name = callback_data.data
|
||||
|
||||
try:
|
||||
query_keys = """
|
||||
SELECT s.*, k.tg_id, k.client_id, k.email, k.expiry_time
|
||||
FROM servers s
|
||||
JOIN keys k ON s.cluster_name = k.server_id
|
||||
WHERE s.server_name = $1;
|
||||
"""
|
||||
keys_to_sync = await session.fetch(query_keys, server_name)
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
text = f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
)
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": key["api_url"],
|
||||
"inbound_id": key["inbound_id"],
|
||||
"server_name": key["server_name"],
|
||||
},
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
semaphore,
|
||||
)
|
||||
await asyncio.sleep(0.6)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters")
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
|
||||
async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
try:
|
||||
query_keys = """
|
||||
SELECT tg_id, client_id, email, expiry_time
|
||||
FROM keys
|
||||
WHERE server_id = $1
|
||||
"""
|
||||
keys_to_sync = await session.fetch(query_keys, cluster_name)
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
text = f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
)
|
||||
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
await create_key_on_cluster(
|
||||
cluster_name,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
)
|
||||
await asyncio.sleep(0.6)
|
||||
except Exception as 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")
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
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
|
||||
await state.set_state(AdminClusterStates.waiting_for_days_input)
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
|
||||
async def handle_days_input(message: Message, state: FSMContext, session: Any):
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
if days <= 0:
|
||||
raise ValueError
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
now = int(time.time() * 1000)
|
||||
add_ms = days * 86400 * 1000
|
||||
|
||||
keys = await session.fetch(
|
||||
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
|
||||
cluster_name,
|
||||
)
|
||||
|
||||
if not keys:
|
||||
await message.answer("❌ Нет подписок в этом кластере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
for key in keys:
|
||||
new_expiry = (key["expiry_time"] or now) + add_ms
|
||||
await renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=TOTAL_GB,
|
||||
)
|
||||
await update_key_expiry(key["client_id"], new_expiry, session)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
|
||||
)
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней.")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении дней: {e}")
|
||||
await message.answer("❌ Произошла ошибка при продлении времени.")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||
async def handle_rename_cluster(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
|
||||
cluster_name = callback_data.data
|
||||
await state.update_data(old_cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
|
||||
"▸ Имя должно быть уникальным.\n"
|
||||
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||
"📌 <i>Пример:</i> <code>new_cluster</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
|
||||
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: Any):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
new_cluster_name = message.text.strip()
|
||||
if len(new_cluster_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
old_cluster_name = user_data.get("old_cluster_name")
|
||||
|
||||
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
|
||||
)
|
||||
if existing_cluster:
|
||||
await message.answer(
|
||||
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
|
||||
new_cluster_name,
|
||||
old_cluster_name
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
|
||||
await message.answer(
|
||||
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||
async def handle_rename_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
old_server_name = callback_data.data
|
||||
|
||||
servers = await get_servers()
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers.items():
|
||||
for server in server_list:
|
||||
if server["server_name"] == old_server_name:
|
||||
cluster_name = c_name
|
||||
break
|
||||
if cluster_name:
|
||||
break
|
||||
|
||||
if not cluster_name:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
|
||||
"▸ Имя должно быть уникальным в пределах кластера.\n"
|
||||
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||
"📌 <i>Пример:</i> <code>new_server</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
|
||||
async def handle_new_server_name_input(message: Message, state: FSMContext, session: Any):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
new_server_name = message.text.strip()
|
||||
if len(new_server_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
old_server_name = user_data.get("old_server_name")
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
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
|
||||
)
|
||||
if existing_server:
|
||||
await message.answer(
|
||||
text=f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. Введите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await conn.execute(
|
||||
"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}'!"
|
||||
|
||||
await message.answer(
|
||||
text=final_text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
|
||||
await message.answer(
|
||||
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("transfer_to_server|"))
|
||||
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext):
|
||||
data = callback_query.data.split("|")
|
||||
new_server_name = data[1]
|
||||
old_server_name = data[2]
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
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(
|
||||
"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⚠️ Не забудьте сделать \"Синхронизацию\"."
|
||||
final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=final_text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("transfer_to_cluster|"))
|
||||
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext):
|
||||
data = callback_query.data.split("|")
|
||||
new_cluster_name = data[1]
|
||||
old_cluster_name = data[2]
|
||||
old_server_name = data[3]
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
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_cluster_name
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
|
||||
cluster_name,
|
||||
old_server_name
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно перенесены в кластер '{new_cluster_name}', сервер '{old_server_name}' и кластер '{old_cluster_name}' удалены!\n\n⚠️ Не забудьте сделать \"Синхронизацию\".",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
await state.clear()
|
||||
@@ -0,0 +1,132 @@
|
||||
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 ..servers.keyboard import AdminServerCallback
|
||||
|
||||
|
||||
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
|
||||
action: str
|
||||
data: str | None = None
|
||||
|
||||
|
||||
def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
cluster_names = list(servers.keys())
|
||||
for i in range(0, len(cluster_names), 2):
|
||||
builder.row(*[
|
||||
InlineKeyboardButton(
|
||||
text=f"⚙️ {name}",
|
||||
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
|
||||
)
|
||||
for name in cluster_names[i : i + 2]
|
||||
])
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Добавить кластер", callback_data=AdminClusterCallback(action="add").pack())
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for server in cluster_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🌍 {server['server_name']}",
|
||||
callback_data=AdminServerCallback(action="manage", data=server["server_name"]).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить сервер",
|
||||
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность",
|
||||
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Синхронизация",
|
||||
callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💾 Создать бэкап",
|
||||
callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Добавить время",
|
||||
callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Сменить название",
|
||||
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for server in cluster_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 Синхронизировать {server['server_name']}",
|
||||
callback_data=AdminClusterCallback(action="sync-server", data=server["server_name"]).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📍 Синхронизировать кластер",
|
||||
callback_data=AdminClusterCallback(action="sync-cluster", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .coupons_handler import router
|
||||
@@ -0,0 +1,489 @@
|
||||
from datetime import datetime
|
||||
import html
|
||||
import pytz
|
||||
from typing import Any
|
||||
|
||||
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,
|
||||
InlineQuery,
|
||||
InlineQueryResultArticle,
|
||||
InputTextMessageContent,
|
||||
Message,
|
||||
)
|
||||
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
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb, format_coupons_list
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
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(
|
||||
AdminPanelCallback.filter(F.action == "coupons"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons_create"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = "🎫 <b>Выберите тип купона:</b>"
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
|
||||
kb.button(text="⏳ Время", callback_data="coupon_type_days")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_balance")
|
||||
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||
)
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_days")
|
||||
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
|
||||
)
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_days_data)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
|
||||
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
coupon_amount = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if coupon_amount <= 0:
|
||||
raise ValueError("Сумма должна быть больше 0")
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
|
||||
async def handle_days_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
days = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if days <= 0:
|
||||
raise ValueError("Количество дней должно быть больше 0")
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, 0, usage_limit, session, days=days)
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"⏳ <b>{format_days(days)}</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons_list"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
data = AdminPanelCallback.unpack(callback_query.data)
|
||||
page = data.page if data.page is not None else 1
|
||||
await update_coupons_list(callback_query.message, session, page)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||
await callback_query.message.edit_text("Произошла ошибка при получении списка купонов.")
|
||||
|
||||
|
||||
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
|
||||
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
|
||||
coupon_code = callback_data.coupon_code
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
|
||||
)
|
||||
kb.button(
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
|
||||
async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
|
||||
coupon_code = callback_data.coupon_code
|
||||
confirm = callback_data.confirm
|
||||
|
||||
if confirm:
|
||||
try:
|
||||
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")
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"Произошла ошибка при удалении купона.",
|
||||
reply_markup=build_admin_back_kb("coupons")
|
||||
)
|
||||
return
|
||||
|
||||
await update_coupons_list(callback_query.message, session)
|
||||
|
||||
|
||||
async def update_coupons_list(message, session: Any, page: int = 1):
|
||||
per_page = 10
|
||||
result = await get_all_coupons(session, page, per_page)
|
||||
coupons = result["coupons"]
|
||||
|
||||
if not coupons:
|
||||
await message.edit_text(
|
||||
text="❌ На данный момент нет доступных купонов!",
|
||||
reply_markup=build_admin_back_kb("coupons"),
|
||||
)
|
||||
return
|
||||
|
||||
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
|
||||
text = format_coupons_list(coupons, USERNAME_BOT)
|
||||
await message.edit_text(text=text, reply_markup=kb)
|
||||
|
||||
|
||||
@router.inline_query(F.query.startswith("coupon_"))
|
||||
async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
if not INLINE_MODE:
|
||||
return
|
||||
|
||||
coupon_code = inline_query.query.split("coupon_")[1]
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
|
||||
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 inline_query.answer(
|
||||
results=[],
|
||||
switch_pm_text="Купон не найден",
|
||||
switch_pm_parameter="coupons",
|
||||
cache_time=1,
|
||||
)
|
||||
return
|
||||
|
||||
title = f"Купон {coupon['code']}"
|
||||
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"
|
||||
f"👇 Нажми, чтобы активировать!"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="Активировать купон", url=coupon_link)
|
||||
|
||||
result = InlineQueryResultArticle(
|
||||
id=coupon_code,
|
||||
title=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=[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()
|
||||
@@ -1,12 +1,18 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
|
||||
from handlers.buttons import BACK
|
||||
from handlers.utils import format_days
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
|
||||
coupon_code: str
|
||||
confirm: Optional[bool] = None
|
||||
|
||||
|
||||
def build_coupons_kb() -> InlineKeyboardMarkup:
|
||||
@@ -31,7 +37,7 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
|
||||
if current_page > 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
|
||||
)
|
||||
)
|
||||
@@ -48,3 +54,17 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
|
||||
builder.row(build_admin_back_btn("coupons"))
|
||||
builder.adjust(2)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
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>"
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"{value_text}\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
|
||||
)
|
||||
return coupon_list
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .management_handler import router
|
||||
@@ -0,0 +1,16 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
def build_management_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="💾 Создать резервную копию", callback_data=AdminPanelCallback(action="backups").pack())
|
||||
builder.button(text="🚫 Заблокировавшие бота", callback_data=AdminPanelCallback(action="bans").pack())
|
||||
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())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,75 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from asyncpg import Connection
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import AdminPanelCallback, build_management_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminManagementStates(StatesGroup):
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
async def handle_management(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
reply_markup=build_management_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
||||
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||
"""Запрашивает у администратора новый домен."""
|
||||
await state.set_state(AdminManagementStates.waiting_for_new_domain)
|
||||
await callback_query.message.edit_text(
|
||||
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminManagementStates.waiting_for_new_domain)
|
||||
async def process_new_domain(message: Message, state: FSMContext, session: Connection):
|
||||
"""Обновляет домен в таблице keys."""
|
||||
new_domain = message.text.strip()
|
||||
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
|
||||
|
||||
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
await message.answer(
|
||||
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
new_domain_url = f"https://{new_domain}"
|
||||
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
|
||||
|
||||
query = """
|
||||
UPDATE keys
|
||||
SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT)
|
||||
WHERE key NOT LIKE $1 || '%'
|
||||
"""
|
||||
try:
|
||||
await session.execute(query, new_domain_url)
|
||||
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||
await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin"))
|
||||
return
|
||||
|
||||
try:
|
||||
sample = await session.fetchrow("SELECT key FROM keys LIMIT 1")
|
||||
logger.info(f"[DomainChange] Пример обновленной записи: {sample}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||
|
||||
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
|
||||
await state.clear()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .panel_handler import router
|
||||
@@ -1,16 +1,20 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK, MAIN_MENU
|
||||
|
||||
|
||||
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
|
||||
action: str
|
||||
page: int
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if "page" not in kwargs or kwargs["page"] is None:
|
||||
kwargs["page"] = 1
|
||||
super().__init__(*args, **kwargs)
|
||||
def __init__(self, /, **data: Any) -> None:
|
||||
if "page" not in data or data["page"] is None:
|
||||
data["page"] = 1
|
||||
super().__init__(**data)
|
||||
|
||||
|
||||
def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
@@ -18,7 +22,7 @@ def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
builder.button(text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack())
|
||||
builder.button(text="🔑 Поиск по названию ключа", callback_data=AdminPanelCallback(action="search_key").pack())
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🖥️ Серверы", callback_data=AdminPanelCallback(action="servers").pack()),
|
||||
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())
|
||||
@@ -26,22 +30,11 @@ def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
|
||||
InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
|
||||
)
|
||||
builder.button(text="Личный кабинет", callback_data="profile")
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1, 1, 2, 1, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_management_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="💾 Создать резервную копию", callback_data=AdminPanelCallback(action="backups").pack())
|
||||
builder.button(text="🚫 Заблокировавшие бота", callback_data=AdminPanelCallback(action="bans").pack())
|
||||
builder.button(text="🔄 Перезагрузить бота", callback_data=AdminPanelCallback(action="restart").pack())
|
||||
builder.button(text="🌐 Сменить домен", callback_data=AdminPanelCallback(action="change_domain").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_restart_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="✅ Да, перезагрузить", callback_data=AdminPanelCallback(action="restart_confirm").pack())
|
||||
@@ -51,7 +44,7 @@ def build_restart_kb() -> InlineKeyboardMarkup:
|
||||
|
||||
|
||||
def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
|
||||
return build_admin_singleton_kb("🔙 Назад", action)
|
||||
return build_admin_singleton_kb(BACK, action)
|
||||
|
||||
|
||||
def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
|
||||
@@ -61,7 +54,7 @@ def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
|
||||
|
||||
|
||||
def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
|
||||
return build_admin_btn("🔙 Назад", action)
|
||||
return build_admin_btn(BACK, action)
|
||||
|
||||
|
||||
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
|
||||
@@ -2,10 +2,15 @@ from aiogram import F, Router
|
||||
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 keyboards.admin.panel_kb import AdminPanelCallback, build_management_kb, build_panel_kb
|
||||
|
||||
from .keyboard import AdminPanelCallback, build_panel_kb
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -17,12 +22,18 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
|
||||
await state.clear()
|
||||
|
||||
if callback_query.message.text:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
print(f"Ошибка при удалении сообщения: {e}")
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
|
||||
|
||||
@@ -38,11 +49,3 @@ async def handle_admin_message(message: Message, state: FSMContext):
|
||||
|
||||
await state.clear()
|
||||
await message.answer(text=text, reply_markup=build_panel_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
async def handle_management(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
reply_markup=build_management_kb(),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .restart_handler import router
|
||||
@@ -4,7 +4,9 @@ from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb, build_restart_kb
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .sender_handler import router
|
||||
@@ -0,0 +1,44 @@
|
||||
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 AdminSenderCallback(CallbackData, prefix="admin_sender"):
|
||||
type: str
|
||||
data: str | None = None
|
||||
|
||||
|
||||
def build_sender_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(InlineKeyboardButton(text="👥 Все пользователи", callback_data=AdminSenderCallback(type="all").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="✅ С подпиской", callback_data=AdminSenderCallback(type="subscribed").pack()),
|
||||
InlineKeyboardButton(text="❌ Без подписки", callback_data=AdminSenderCallback(type="unsubscribed").pack()),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📍 Не использовавшие триал", callback_data=AdminSenderCallback(type="untrial").pack()
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Кластер", callback_data=AdminSenderCallback(type="cluster-select").pack())
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for cluster in clusters:
|
||||
name = cluster["cluster_name"]
|
||||
builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
|
||||
|
||||
builder.adjust(2)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
@@ -7,10 +7,12 @@ from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
|
||||
from keyboards.admin.sender_kb import AdminSenderCallback, build_clusters_kb, build_sender_kb
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -96,6 +98,8 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
|
||||
""",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
elif send_to == "untrial":
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections WHERE trial = 0")
|
||||
elif send_to == "cluster":
|
||||
cluster_name = state_data.get("cluster_name")
|
||||
tg_ids = await session.fetch(
|
||||
@@ -114,6 +118,10 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
|
||||
text = f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
|
||||
|
||||
await message.answer(text=text)
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record["tg_id"]
|
||||
try:
|
||||
@@ -130,11 +138,10 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
|
||||
|
||||
text = (
|
||||
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||
f"👥 <b>Всего пользователей:</b> {total_users}\n"
|
||||
f"👥 <b>Количество получателей:</b> {total_users}\n"
|
||||
f"✅ <b>Доставлено:</b> {success_count}\n"
|
||||
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("stats"), parse_mode="HTML")
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("sender"))
|
||||
await state.clear()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .servers_handler import router
|
||||
@@ -0,0 +1,21 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
class AdminServerCallback(CallbackData, prefix="admin_server"):
|
||||
action: str
|
||||
data: str
|
||||
|
||||
|
||||
def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
from ..clusters.keyboard import AdminClusterCallback
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
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=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,187 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
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 filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminServerCallback,
|
||||
build_manage_server_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
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 server:
|
||||
api_url = server["api_url"]
|
||||
subscription_url = server["subscription_url"]
|
||||
inbound_id = server["inbound_id"]
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(text="❌ Сервер не найден.")
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "delete"), IsAdminFilter())
|
||||
async def process_callback_delete_server(
|
||||
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext, session: Any
|
||||
):
|
||||
from ..clusters.clusters_handler import AdminClusterStates
|
||||
|
||||
server_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers.items():
|
||||
for server in server_list:
|
||||
if server["server_name"] == server_name:
|
||||
cluster_name = c_name
|
||||
break
|
||||
if cluster_name:
|
||||
break
|
||||
|
||||
if not cluster_name:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Не удалось найти кластер для сервера '{server_name}'.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
all_servers = await session.fetch(
|
||||
"""
|
||||
SELECT server_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.server_name) as key_count
|
||||
FROM servers
|
||||
WHERE server_name != $1
|
||||
""",
|
||||
server_name
|
||||
)
|
||||
|
||||
if all_servers:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for server in all_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{server['server_name']} ({server['key_count']})",
|
||||
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()
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"⚠️ На сервере '{server_name}' есть {keys_count} ключей. Выберите сервер для переноса ключей:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_transfer)
|
||||
return
|
||||
|
||||
remaining_servers = await session.fetchval(
|
||||
"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
|
||||
)
|
||||
|
||||
if other_clusters:
|
||||
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)
|
||||
|
||||
all_clusters = await session.fetch(
|
||||
"""
|
||||
SELECT cluster_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.cluster_name) as key_count
|
||||
FROM servers
|
||||
WHERE cluster_name != $1
|
||||
GROUP BY cluster_name
|
||||
""",
|
||||
cluster_name
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cluster in all_clusters:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{cluster['cluster_name']} ({cluster['key_count']})",
|
||||
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()
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
"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}' также удален, так как в нем не осталось серверов.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
"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"),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .stats_handler import router
|
||||
@@ -1,7 +1,7 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
def build_stats_kb() -> InlineKeyboardMarkup:
|
||||
@@ -14,6 +14,13 @@ def build_stats_kb() -> InlineKeyboardMarkup:
|
||||
builder.button(
|
||||
text="📥 Выгрузить оплаты в CSV", callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack()
|
||||
)
|
||||
builder.button(
|
||||
text="📥 Выгрузить подписки в CSV",
|
||||
callback_data=AdminPanelCallback(action="stats_export_keys_csv").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📥 Выгрузить горящих лидов", callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack()
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,201 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
from utils.csv_export import export_hot_leads_csv, export_keys_csv, export_payments_csv, export_users_csv
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_stats_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_stats(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
total_users = await session.fetchval("SELECT COUNT(*) FROM users")
|
||||
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
|
||||
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
|
||||
|
||||
total_payments_today = int(
|
||||
await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE")
|
||||
)
|
||||
total_payments_week = int(
|
||||
await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
)
|
||||
total_payments_month = int(
|
||||
await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
)
|
||||
total_payments_last_month = int(
|
||||
await session.fetchval(
|
||||
"""
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM payments
|
||||
WHERE created_at >= date_trunc('month', CURRENT_DATE - interval '1 month')
|
||||
AND created_at < date_trunc('month', CURRENT_DATE)
|
||||
"""
|
||||
)
|
||||
)
|
||||
total_payments_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
|
||||
|
||||
all_keys = await session.fetch("SELECT created_at, expiry_time FROM keys")
|
||||
|
||||
def count_subscriptions_by_duration(keys):
|
||||
periods = {"trial": 0, "1": 0, "3": 0, "6": 0, "12": 0}
|
||||
for key in keys:
|
||||
try:
|
||||
duration_days = (key["expiry_time"] - key["created_at"]) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if duration_days <= 29:
|
||||
periods["trial"] += 1
|
||||
elif duration_days <= 89:
|
||||
periods["1"] += 1
|
||||
elif duration_days <= 179:
|
||||
periods["3"] += 1
|
||||
elif duration_days <= 359:
|
||||
periods["6"] += 1
|
||||
else:
|
||||
periods["12"] += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing key duration: {e}")
|
||||
continue
|
||||
return periods
|
||||
|
||||
subs_all_time = count_subscriptions_by_duration(all_keys)
|
||||
|
||||
registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
|
||||
registrations_week = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
registrations_month = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
|
||||
users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
|
||||
|
||||
active_keys = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
expired_keys = total_keys - active_keys
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
hot_leads_count = await session.fetchval("""
|
||||
SELECT COUNT(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
|
||||
""")
|
||||
|
||||
stats_message = (
|
||||
"📊 <b>Статистика проекта</b>\n\n"
|
||||
"👤 <b>Пользователи:</b>\n"
|
||||
f"├ 🗓️ За день: <b>{registrations_today}</b>\n"
|
||||
f"├ 📆 За неделю: <b>{registrations_week}</b>\n"
|
||||
f"├ 🗓️ За месяц: <b>{registrations_month}</b>\n"
|
||||
f"└ 🌐 Всего: <b>{total_users}</b>\n\n"
|
||||
"💡 <b>Активность:</b>\n"
|
||||
f"└ 👥 Сегодня были активны: <b>{users_updated_today}</b>\n\n"
|
||||
"🤝 <b>Реферальная система:</b>\n"
|
||||
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
|
||||
"🔐 <b>Подписки:</b>\n"
|
||||
f"├ 📦 Всего сгенерировано: <b>{total_keys}</b>\n"
|
||||
f"├ ✅ Активных: <b>{active_keys}</b>\n"
|
||||
f"├ ❌ Просроченных: <b>{expired_keys}</b>\n"
|
||||
f"└ 📋 По срокам:\n"
|
||||
f" • 🎁 Триал: <b>{subs_all_time['trial']}</b>\n"
|
||||
f" • 🗓️ 1 мес: <b>{subs_all_time['1']}</b>\n"
|
||||
f" • 🗓️ 3 мес: <b>{subs_all_time['3']}</b>\n"
|
||||
f" • 🗓️ 6 мес: <b>{subs_all_time['6']}</b>\n"
|
||||
f" • 🗓️ 12 мес: <b>{subs_all_time['12']}</b>\n\n"
|
||||
"💰 <b>Финансы:</b>\n"
|
||||
f"├ 📅 За день: <b>{total_payments_today} ₽</b>\n"
|
||||
f"├ 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
|
||||
f"├ 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
|
||||
f"├ 📆 За прошлый месяц: <b>{total_payments_last_month} ₽</b>\n"
|
||||
f"└ 🏦 Всего: <b>{total_payments_all_time} ₽</b>\n\n"
|
||||
f"🔥 <b>Горящие лиды</b>: <b>{hot_leads_count}</b> (платили, но не продлили)\n\n"
|
||||
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" not in str(e):
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
await callback_query.answer("Произошла ошибка при получении статистики", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_users_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт пользователей в CSV")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_payments_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт платежей в CSV")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_hot_leads_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_hot_leads_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт горящих лидов")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте 'горящих лидов': {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_keys_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_keys_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт подписок в CSV")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте подписок в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .users_handler import router
|
||||
@@ -4,8 +4,11 @@ from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import RENEWAL_PRICES
|
||||
from keyboards.admin.panel_kb import build_admin_back_btn
|
||||
from config import RENEWAL_PRICES, TOTAL_GB
|
||||
from database import get_clusters
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
|
||||
|
||||
class AdminUserEditorCallback(CallbackData, prefix="admin_users"):
|
||||
@@ -27,6 +30,11 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
current_time = datetime.now(tz=timezone.utc)
|
||||
|
||||
builder.button(
|
||||
text="➕ Создать ключ",
|
||||
callback_data=AdminUserEditorCallback(action="users_create_key", tg_id=tg_id).pack(),
|
||||
)
|
||||
|
||||
for record in key_records:
|
||||
email = record["email"]
|
||||
expiry = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
|
||||
@@ -43,6 +51,10 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
|
||||
text="💸 Изменить баланс",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🤝 Выгрузить рефералов",
|
||||
callback_data=AdminUserEditorCallback(action="users_export_referrals", tg_id=tg_id).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="♻️ Восстановить триал",
|
||||
callback_data=AdminUserEditorCallback(action="users_trial_restore", tg_id=tg_id).pack(),
|
||||
@@ -59,7 +71,7 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
|
||||
def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="🔙 Назад", # todo: fix magic text was set
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -94,7 +106,7 @@ def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="🔙 Назад", # todo: fix magic text was set
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
@@ -123,7 +135,7 @@ def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔙 Назад", # todo: fix magic text was set
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
|
||||
)
|
||||
builder.adjust(2, 2, 2, 2, 2, 1)
|
||||
@@ -144,7 +156,7 @@ def build_user_delete_kb(tg_id: int):
|
||||
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="🔙 Назад", callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
|
||||
text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -170,6 +182,13 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||
text="📊 Трафик",
|
||||
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
)
|
||||
if TOTAL_GB > 0:
|
||||
builder.button(
|
||||
text="♻️ Сбросить трафик",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
)
|
||||
builder.row(build_editor_back_btn(key_details["tg_id"], True))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -189,7 +208,7 @@ def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
|
||||
|
||||
def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
|
||||
return build_editor_singleton_kb("🔙 Назад", tg_id, edit)
|
||||
return build_editor_singleton_kb(BACK, tg_id, edit)
|
||||
|
||||
|
||||
def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
|
||||
@@ -199,10 +218,22 @@ def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> Inli
|
||||
|
||||
|
||||
def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton:
|
||||
return build_editor_btn("🔙 Назад", tg_id, edit)
|
||||
return build_editor_btn(BACK, tg_id, edit)
|
||||
|
||||
|
||||
def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(
|
||||
text=text, callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=edit).pack()
|
||||
)
|
||||
|
||||
|
||||
async def build_cluster_selection_kb(session, tg_id: int, email: str, action: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
clusters = await get_clusters(session)
|
||||
|
||||
for cluster_id in clusters:
|
||||
builder.button(text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}")
|
||||
|
||||
builder.button(text=BACK, callback_data=f"edit_user_key|{tg_id}|{email}")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -1,32 +1,48 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
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, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import TOTAL_GB
|
||||
from config import PUBLIC_LINK, RENEWAL_PRICES, TOTAL_GB
|
||||
from database import (
|
||||
delete_key,
|
||||
delete_user_data,
|
||||
get_balance,
|
||||
get_client_id_by_email,
|
||||
get_servers,
|
||||
store_key,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
update_trial,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, get_user_traffic, renew_key_in_cluster, update_subscription
|
||||
from handlers.utils import sanitize_key_name
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
|
||||
from keyboards.admin.users_kb import (
|
||||
from handlers.keys.key_utils import (
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
get_user_traffic,
|
||||
renew_key_in_cluster,
|
||||
reset_traffic_in_cluster,
|
||||
update_subscription,
|
||||
)
|
||||
from handlers.utils import generate_random_email, sanitize_key_name
|
||||
from logger import logger
|
||||
from utils.csv_export import export_referrals_csv
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminUserEditorCallback,
|
||||
AdminUserKeyEditorCallback,
|
||||
build_cluster_selection_kb,
|
||||
build_editor_kb,
|
||||
build_key_delete_kb,
|
||||
build_key_edit_kb,
|
||||
@@ -38,7 +54,7 @@ from keyboards.admin.users_kb import (
|
||||
build_users_key_expiry_kb,
|
||||
build_users_key_show_kb,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
|
||||
|
||||
@@ -53,6 +69,8 @@ class UserEditorState(StatesGroup):
|
||||
waiting_for_balance = State()
|
||||
waiting_for_expiry_time = State()
|
||||
waiting_for_message_text = State()
|
||||
selecting_cluster = State()
|
||||
selecting_duration = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -468,16 +486,25 @@ async def handle_update_key(callback_query: CallbackQuery, callback_data: AdminU
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"📡 Выберите кластер, на котором пересоздать ключ <b>{email}</b>:",
|
||||
reply_markup=await build_cluster_selection_kb(session, tg_id, email, action="confirm_admin_key_reissue"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_admin_key_reissue|"), IsAdminFilter())
|
||||
async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Any):
|
||||
_, tg_id, email, cluster_id = callback_query.data.split("|")
|
||||
tg_id = int(tg_id)
|
||||
|
||||
try:
|
||||
await update_subscription(tg_id, email, session)
|
||||
await handle_key_edit(callback_query, callback_data, session, True)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении ключа {email} администратором: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Произошла ошибка при обновлении ключа: {e}", reply_markup=build_user_key_kb(tg_id, email)
|
||||
await update_subscription(tg_id, email, session, cluster_override=cluster_id)
|
||||
await handle_key_edit(
|
||||
callback_query, AdminUserEditorCallback(tg_id=tg_id, data=email, action="view_key"), session, True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при перевыпуске ключа {email}: {e}")
|
||||
await callback_query.message.answer(f"❗ Ошибка: {e}")
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key"), IsAdminFilter())
|
||||
@@ -514,7 +541,7 @@ async def handle_delete_key_confirm(
|
||||
for cluster_name, cluster_servers in clusters.items():
|
||||
for _ in cluster_servers:
|
||||
tasks.append(delete_key_from_cluster(cluster_name, email, client_id))
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
await delete_key_from_servers()
|
||||
await delete_key(client_id, session)
|
||||
@@ -546,7 +573,7 @@ async def handle_delete_user_confirm(
|
||||
servers = await get_servers()
|
||||
for cluster_id, _cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
@@ -587,14 +614,19 @@ async def process_user_search(
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
text = (
|
||||
f"<b>📊 Информация о пользователе</b>"
|
||||
f"\n\n🆔 ID: <b>{tg_id}</b>"
|
||||
f"\n📄 Логин: <b>@{username}</b>"
|
||||
f"\n📅 Дата регистрации: <b>{created_at}</b>"
|
||||
f"\n🏃 Дата активности: <b>{updated_at}</b>"
|
||||
f"\n💰 Баланс: <b>{balance}</b>"
|
||||
f"\n👥 Количество рефералов: <b>{referral_count}</b>"
|
||||
)
|
||||
@@ -665,7 +697,7 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
|
||||
for cluster_name in clusters
|
||||
]
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
await update_key_on_all_servers()
|
||||
await update_key_expiry(client_id, expiry_time, session)
|
||||
@@ -715,3 +747,186 @@ async def handle_user_traffic(
|
||||
result_text += f"\n🔢 <b>Общий трафик:</b> {total_traffic:.2f} ГБ"
|
||||
|
||||
await callback_query.message.edit_text(result_text, reply_markup=build_editor_kb(tg_id, True))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_trials"), IsAdminFilter())
|
||||
async def confirm_restore_trials(callback_query: types.CallbackQuery):
|
||||
"""
|
||||
Меню подтверждения перед восстановлением пробников.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="✅ Подтвердить", callback_data=AdminPanelCallback(action="confirm_restore_trials").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="⚠ Вы уверены, что хотите восстановить пробники для пользователей? \n\n"
|
||||
"Только для тех, у кого нет активной подписки!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "confirm_restore_trials"), IsAdminFilter())
|
||||
async def restore_trials(callback_query: types.CallbackQuery, session: Any):
|
||||
"""
|
||||
Восстанавливает пробники для пользователей, у которых нет активной подписки.
|
||||
"""
|
||||
query = """
|
||||
UPDATE connections
|
||||
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
|
||||
)
|
||||
"""
|
||||
await session.execute(query)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✅ Пробники успешно восстановлены для пользователей, у которых нет активных подписок.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_export_referrals"), IsAdminFilter())
|
||||
async def handle_users_export_referrals(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
|
||||
):
|
||||
"""
|
||||
Обработчик: получает tg_id реферера из callback_data,
|
||||
вызывает export_referrals_csv и отправляет файл или отвечает,
|
||||
что рефералов нет.
|
||||
"""
|
||||
referrer_tg_id = callback_data.tg_id
|
||||
|
||||
csv_file = await export_referrals_csv(referrer_tg_id, session)
|
||||
|
||||
if csv_file is None:
|
||||
await callback_query.message.answer("У пользователя нет рефералов.")
|
||||
return
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
document=csv_file, caption=f"Список рефералов для пользователя {referrer_tg_id}."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
|
||||
async def handle_create_key_select_cluster(
|
||||
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(UserEditorState.selecting_cluster)
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_names = list(servers.keys())
|
||||
|
||||
if not cluster_names:
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Нет доступных кластеров для создания ключа.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cluster in cluster_names:
|
||||
builder.button(
|
||||
text=f"🌐 {cluster}",
|
||||
callback_data=cluster
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
"🌐 <b>Выберите кластер для создания ключа:</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
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
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>Выберите срок действия ключа для кластера {cluster_name}:</b>", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(UserEditorState.selecting_duration, IsAdminFilter())
|
||||
async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
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)
|
||||
|
||||
public_link = f"{PUBLIC_LINK}{email}/{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,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.!",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ключа: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Не удалось создать ключ. Попробуйте позже.", reply_markup=build_editor_kb(data.get("tg_id", 0))
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_reset_traffic"), IsAdminFilter())
|
||||
async def handle_reset_traffic(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
record = await session.fetchrow(
|
||||
"SELECT server_id, client_id FROM keys WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
if not record:
|
||||
await callback_query.message.edit_text("❌ Ключ не найден в базе данных.", reply_markup=build_editor_kb(tg_id))
|
||||
return
|
||||
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
try:
|
||||
await reset_traffic_in_cluster(cluster_id, email)
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Трафик для ключа <b>{email}</b> успешно сброшен.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе трафика: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Произошла ошибка при сбросе трафика. Попробуйте позже.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Основные кнопки меню:
|
||||
|
||||
MAIN_MENU = "👤 Личный кабинет"
|
||||
BACK = "⬅️ Назад"
|
||||
SUPPORT = "💬 Поддержка"
|
||||
CHANNEL = "📢 Канал"
|
||||
ABOUT_VPN = "💬 О сервисе"
|
||||
APPLY = "✅ Подтвердить"
|
||||
CANCEL = "❌ Отмена"
|
||||
|
||||
# Профиль
|
||||
|
||||
ADD_SUB = "➕ Подписка"
|
||||
MY_SUBS = "📱 Мои подписки"
|
||||
BALANCE = "💰 Баланс"
|
||||
INVITE = "👥 Пригласить"
|
||||
GIFTS = "🎁 Подарить"
|
||||
TARRIFS = "💡 Тарифы"
|
||||
INSTRUCTIONS = "📘 Инструкции"
|
||||
INVITE = "👥 Пригласить друга"
|
||||
TOP_FIVE = "🏆 Топ-5"
|
||||
TRIAL_SUB = "🎁 Пробная подписка"
|
||||
|
||||
# Меню Оплат и баланса
|
||||
|
||||
BALANCE_HISTORY = "📊 История пополнения"
|
||||
PAYMENT = "💳 Пополнить баланс"
|
||||
COUPON = "🎟️ Активировать купон"
|
||||
COUPON_RESTART = "🎟️ Попробовать другой купон"
|
||||
YOOKASSA = "💳 ЮКасса: быстрая оплата"
|
||||
YOOMONEY = "💳 ЮМани: перевод по карте"
|
||||
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
|
||||
STARS = "⭐ Оплата Звездами"
|
||||
ROBOKASSA = "⭐ RoboKassa"
|
||||
|
||||
|
||||
# Подарки
|
||||
|
||||
GIFT = "🎁 Подарить подписку"
|
||||
MY_GIFTS = "🎁 Мои подарки"
|
||||
GIFTS_MENU = "В меню подарков"
|
||||
SHARE_GIFT = "🎁 Поделиться подарком"
|
||||
APPLY = "✅ Подтвердить"
|
||||
CANCEL = "❌ Отмена"
|
||||
|
||||
# Создание и подключение ключей
|
||||
|
||||
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
|
||||
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
|
||||
DOWNLOAD_MACOS_BUTTON = "🍏 Скачать macOS"
|
||||
DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
|
||||
IMPORT_IOS = "🍏 Подключить"
|
||||
IMPORT_ANDROID = "🤖 Подключить"
|
||||
PC_BUTTON = "💻 Компьютеры"
|
||||
TV_BUTTON = "📺 Андроид TV"
|
||||
CONNECT_PHONE = "📱 Подключить телефон"
|
||||
CONNECT_DEVICE = "📲 Подключить устройство"
|
||||
CONNECT_WINDOWS_BUTTON = "💻 Подключить"
|
||||
CONNECT_MACOS_BUTTON = "🍏 Подключить"
|
||||
ALIAS = "✏️"
|
||||
UNFREEZE = "🟢 Разморозить подписку"
|
||||
FREEZE = "🛑 Заморозить подписку"
|
||||
RENEW = "⏳ Продлить"
|
||||
RENEW_FULL = "⏳ Продлить подписку"
|
||||
DELETE = "❌ Удалить"
|
||||
CHANGE_LOCATION = "🌍 Сменить локацию"
|
||||
QR = "📷 Показать QR-код"
|
||||
IPHONE = "🍏 Айфон"
|
||||
ANDROID = "🤖 Андроид"
|
||||
PC = "💻 Компьютер"
|
||||
PC_PC = "💻 Windows"
|
||||
PC_MACOS = "🍏 macOS"
|
||||
TV = "📺 Телевизор"
|
||||
ROUTER = "📶 Роутер"
|
||||
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
|
||||
RENEW_KEY = "🔄 Продлить подписку"
|
||||
TV_CONTINUE = "▶ Продолжить"
|
||||
TV_INSTRUCTIONS = "📖 Полная инструкция"
|
||||
|
||||
|
||||
# Кнопки касс
|
||||
|
||||
PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
|
||||
PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
STARS_BOT = "🤖 Бот для покупки звезд"
|
||||
@@ -1,6 +0,0 @@
|
||||
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
|
||||
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
|
||||
IMPORT_IOS = "🍏 Подключить"
|
||||
IMPORT_ANDROID = "🤖 Подключить"
|
||||
PC_BUTTON = "💻 Компьютеры"
|
||||
TV_BUTTON = "📺 Андроид TV"
|
||||
@@ -1,9 +0,0 @@
|
||||
GIFT = "🎁 Подарить подписку"
|
||||
MY_GIFTS = "🎁 Мои подарки"
|
||||
PROFILE = "👤 Личный кабинет"
|
||||
BACK = "В меню подарков"
|
||||
GIFTS_ABOUT = "<b>Дарите подарки и следите, чтобы они дошли до адресата! 🎄</b>"
|
||||
SHARE_GIFT = "🎁 Поделиться подарком"
|
||||
APPLY = "✅ Подтвердить"
|
||||
CANCEL = "❌ Отмена"
|
||||
NO_GIFTS = "У вас нет подарков."
|
||||
@@ -1,3 +0,0 @@
|
||||
RENEW_KEY = "🔄 Продлить подписку"
|
||||
ADD_KEY = "➕ Добавить подписку"
|
||||
PROFILE = "👤 Личный кабинет"
|
||||
@@ -1,10 +0,0 @@
|
||||
ADD_SUB = "➕ Подписка"
|
||||
MY_SUBS = "📱 Мои подписки"
|
||||
BALANCE = "💰 Баланс"
|
||||
BALANCE_HISTORY = "📊 История пополнения"
|
||||
PAYMENT = "💳 Пополнить баланс"
|
||||
INVITE = "👥 Пригласить"
|
||||
GIFTS = "🎁 Подарить"
|
||||
TARRIFS = "💡 Тарифы"
|
||||
INSTRUCTIONS = "📘 Инструкции"
|
||||
MAIN_MENU = "⬅️ Главное меню"
|
||||
@@ -1,12 +0,0 @@
|
||||
PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
BACK = "⬅️ Назад"
|
||||
CUSTOM_SUM = "💰 Ввести свою сумму"
|
||||
PROFILE = "👤 Личный кабинет"
|
||||
KEY_CREATION_PAYMENT_MESSAGE = (
|
||||
"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
KEY_RENEWAL_PAYMENT_MESSAGE = (
|
||||
"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
DEFAULT_PAYMENT_MESSAGE = "Вы выбрали пополнение на {amount} рублей. Перейдите по ссылке для оплаты:"
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import random
|
||||
import secrets
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -8,10 +9,12 @@ 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 logger import logger
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -35,7 +38,7 @@ async def generate_captcha(message: Message, state: FSMContext):
|
||||
builder.adjust(2, 2)
|
||||
|
||||
return {
|
||||
"text": f"🔒 Для подтверждения, что вы не робот, выберите кнопку с {correct_text}",
|
||||
"text": CAPTCHA_PROMPT_MSG.format(correct_text=correct_text),
|
||||
"markup": builder.as_markup(),
|
||||
}
|
||||
|
||||
|
||||
+15
-7
@@ -13,6 +13,13 @@ from database import (
|
||||
update_balance,
|
||||
update_coupon_usage_count,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU, COUPON_RESTART
|
||||
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
|
||||
|
||||
@@ -28,7 +35,7 @@ router = Router()
|
||||
@router.message(F.text == "/activate_coupon")
|
||||
async def handle_activate_coupon(callback_query_or_message: Message | CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
@@ -37,8 +44,7 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="<b>🎫 Введите код купона:</b>\n\n"
|
||||
"📝 Пожалуйста, введите действующий код купона, который вы хотите активировать. 🔑",
|
||||
text=COUPON_INPUT_PROMPT,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
@@ -51,9 +57,11 @@ async def process_coupon_code(message: Message, state: FSMContext, session: Any)
|
||||
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
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()
|
||||
|
||||
|
||||
@@ -61,12 +69,12 @@ async def activate_coupon(user_id: int, coupon_code: str, session: Any):
|
||||
coupon_record = await get_coupon_by_code(coupon_code, session)
|
||||
|
||||
if not coupon_record:
|
||||
return "<b>❌ Купон не найден</b> 🚫 или его использование ограничено. 🔒 Пожалуйста, проверьте код и попробуйте снова. 🔍"
|
||||
return COUPON_NOT_FOUND_MSG
|
||||
|
||||
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
|
||||
|
||||
if usage_exists:
|
||||
return "<b>❌ Вы уже активировали этот купон.</b> 🚫 Купоны могут быть активированы только один раз. 🔒"
|
||||
return COUPON_ALREADY_USED_MSG
|
||||
|
||||
coupon_amount = coupon_record["amount"]
|
||||
|
||||
@@ -74,4 +82,4 @@ async def activate_coupon(user_id: int, coupon_code: str, session: Any):
|
||||
await create_coupon_usage(coupon_record["id"], user_id, session)
|
||||
|
||||
await update_balance(user_id, coupon_amount, session)
|
||||
return f"<b>✅ Купон успешно активирован! 🎉</b>\n\nНа ваш баланс добавлено <b>{coupon_amount} рублей</b> 💰."
|
||||
return COUPON_ACTIVATED_SUCCESS_MSG.format(coupon_amount=coupon_amount)
|
||||
|
||||
+5
-4
@@ -5,6 +5,7 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Mes
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import RUB_TO_XTR
|
||||
from handlers.buttons import BACK, MAIN_MENU
|
||||
from logger import logger
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
@@ -31,7 +32,7 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
|
||||
callback_data="enter_custom_donate_amount",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = (
|
||||
"🌟 Поддержите наш проект! 💪\n\n"
|
||||
@@ -49,7 +50,7 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
|
||||
@router.callback_query(F.data == "enter_custom_donate_amount")
|
||||
async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
|
||||
text = "💸 Введите сумму доната в рублях:"
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -75,7 +76,7 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
|
||||
try:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
|
||||
|
||||
await message.answer_invoice(
|
||||
title=f"Донат проекту {amount} рублей",
|
||||
@@ -103,7 +104,7 @@ async def on_successful_donate(message: Message, state: FSMContext):
|
||||
try:
|
||||
amount = float(message.successful_payment.invoice_payload.split("_")[0])
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await message.answer(
|
||||
text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
|
||||
reply_markup=builder.as_markup(),
|
||||
|
||||
@@ -4,6 +4,7 @@ from aiogram import Router
|
||||
|
||||
from .instructions import router as instructions_router
|
||||
|
||||
|
||||
router = Router(name="instructions_main_router")
|
||||
|
||||
router.include_routers(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
@@ -9,17 +9,39 @@ from aiogram.types import (
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import CONNECT_MACOS, CONNECT_WINDOWS, SUPPORT_CHAT_URL
|
||||
from config import (
|
||||
CONNECT_MACOS,
|
||||
CONNECT_WINDOWS,
|
||||
DOWNLOAD_MACOS,
|
||||
DOWNLOAD_PC,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import get_key_details
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONNECT_MACOS_BUTTON,
|
||||
CONNECT_WINDOWS_BUTTON,
|
||||
DOWNLOAD_MACOS_BUTTON,
|
||||
DOWNLOAD_PC_BUTTON,
|
||||
MAIN_MENU,
|
||||
PC_MACOS,
|
||||
PC_PC,
|
||||
SUPPORT,
|
||||
TV_CONTINUE,
|
||||
TV_INSTRUCTIONS,
|
||||
)
|
||||
from handlers.texts import (
|
||||
CHOOSE_DEVICE_TEXT,
|
||||
CONNECT_TV_TEXT,
|
||||
INSTRUCTION_PC,
|
||||
INSTRUCTIONS,
|
||||
INSTRUCTION_MACOS,
|
||||
INSTRUCTION_PC,
|
||||
KEY_MESSAGE,
|
||||
SUBSCRIPTION_DETAILS_TEXT,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -30,8 +52,8 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
|
||||
image_path = os.path.join("img", "instructions.jpg")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
@@ -52,7 +74,7 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍",
|
||||
@@ -61,16 +83,32 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{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,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("windows_menu|"))
|
||||
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(key_name, session)
|
||||
key = record["key"]
|
||||
key_message_text = KEY_MESSAGE.format(key)
|
||||
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}"))
|
||||
builder.row(InlineKeyboardButton(text="💻 Подключить MacOS", url=f"{CONNECT_MACOS}{key}"))
|
||||
builder.row(InlineKeyboardButton(text="🆘 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}"))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -80,15 +118,36 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("macos_menu|"))
|
||||
async def process_macos_menu(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(key_name, session)
|
||||
key = record["key"]
|
||||
key_message_text = KEY_MESSAGE.format(key)
|
||||
instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}"))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=instruction_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_tv|"))
|
||||
async def process_connect_tv(callback_query: CallbackQuery):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="▶ Продолжить", callback_data=f"continue_tv|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -102,17 +161,19 @@ async def process_connect_tv(callback_query: CallbackQuery):
|
||||
@router.callback_query(F.data.startswith("continue_tv|"))
|
||||
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
record = await get_key_details(key_name, session)
|
||||
subscription_link = record["key"]
|
||||
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="📖 Полная инструкция", url="https://vpn4tv.com/quick-guide.html"))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"connect_tv|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=TV_INSTRUCTIONS, url="https://vpn4tv.com/quick-guide.html"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=message_text, reply_markup=builder.as_markup(), media_path=None
|
||||
target_message=callback_query.message,
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from aiogram import Router
|
||||
from .key_management import router as management_router
|
||||
from .keys import router as keys_router
|
||||
|
||||
|
||||
router = Router(name="keys_main_router")
|
||||
|
||||
router.include_routers(
|
||||
|
||||
+236
-105
@@ -1,25 +1,22 @@
|
||||
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
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, FSInputFile
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from bot import bot
|
||||
from client import delete_client
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
PUBLIC_LINK,
|
||||
RENEWAL_PRICES,
|
||||
@@ -39,21 +36,33 @@ from database import (
|
||||
store_key,
|
||||
update_balance,
|
||||
update_trial,
|
||||
check_server_name_by_cluster,
|
||||
)
|
||||
from handlers.buttons.add_subscribe import (
|
||||
DOWNLOAD_ANDROID_BUTTON,
|
||||
DOWNLOAD_IOS_BUTTON,
|
||||
IMPORT_ANDROID,
|
||||
IMPORT_IOS,
|
||||
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 DISCOUNTS, key_message_success
|
||||
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()
|
||||
|
||||
@@ -88,23 +97,19 @@ async def handle_key_creation(
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
|
||||
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
|
||||
updated = await update_trial(tg_id, 1, session)
|
||||
if updated:
|
||||
await edit_or_send_message(
|
||||
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
|
||||
text="⏳ Пожалуйста, подождите, создаем вам подключение...",
|
||||
reply_markup=None,
|
||||
)
|
||||
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
else:
|
||||
logger.error(f"Не удалось обновить статус триального периода для пользователя {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 plan_id in DISCOUNTS:
|
||||
if DISCOUNTS and plan_id in DISCOUNTS:
|
||||
discount_percentage = DISCOUNTS[plan_id]
|
||||
discount_text = f" ({discount_percentage}% скидка)"
|
||||
if index == len(RENEWAL_PRICES) - 1:
|
||||
@@ -115,7 +120,7 @@ async def handle_key_creation(
|
||||
callback_data=f"select_plan_{plan_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if isinstance(message_or_query, CallbackQuery):
|
||||
target_message = message_or_query.message
|
||||
@@ -124,7 +129,7 @@ async def handle_key_creation(
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="💳 Выберите тарифный план для создания нового ключа:",
|
||||
text=SELECT_TARIFF_PLAN_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
@@ -134,7 +139,7 @@ async def handle_key_creation(
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_plan_"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, session: Any):
|
||||
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)
|
||||
@@ -162,11 +167,11 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any):
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
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=f"💳 Недостаточно средств. Для продолжения необходимо пополнить баланс на {required_amount}₽.",
|
||||
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
@@ -176,14 +181,13 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="⏳ Подождите, создаем вам подключение...",
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
||||
await create_key(tg_id, expiry_time, None, session, callback_query, None, plan_id)
|
||||
await update_balance(tg_id, -plan_price, session)
|
||||
|
||||
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,
|
||||
@@ -204,37 +208,72 @@ async def create_key(
|
||||
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}. Получаем список серверов"
|
||||
)
|
||||
logger.info(f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов")
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name FROM servers WHERE cluster_name = $1",
|
||||
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1",
|
||||
least_loaded_cluster,
|
||||
)
|
||||
countries = [server["server_name"] for server in servers]
|
||||
logger.info(f"[Country Selection] Список серверов: {countries}")
|
||||
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 countries:
|
||||
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="⬅️ Назад", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
@@ -259,7 +298,7 @@ async def create_key(
|
||||
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
|
||||
)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
||||
await store_key(
|
||||
tg_id,
|
||||
@@ -270,7 +309,18 @@ async def create_key(
|
||||
least_loaded_cluster,
|
||||
session,
|
||||
)
|
||||
await update_trial(tg_id, 1, 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}")
|
||||
@@ -284,40 +334,45 @@ async def create_key(
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
|
||||
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=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
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=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
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()
|
||||
|
||||
@@ -339,15 +394,51 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
expiry_timestamp = record["expiry_time"]
|
||||
ts = int(expiry_timestamp / 1000)
|
||||
|
||||
servers = await session.fetch("SELECT server_name FROM servers")
|
||||
countries = [row["server_name"] for row in servers]
|
||||
logger.info(f"Доступные страны для смены локации: {countries}")
|
||||
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 countries:
|
||||
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="⬅️ Назад", callback_data=f"view_key|{old_key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -361,7 +452,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_country|"))
|
||||
async def handle_country_selection(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
"""
|
||||
Обрабатывает выбор страны.
|
||||
Формат callback data:
|
||||
@@ -388,7 +479,7 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any):
|
||||
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
|
||||
logger.info(f"Получено время истечения (timestamp): {ts}")
|
||||
|
||||
await finalize_key_creation(tg_id, expiry_time, selected_country, None, session, callback_query, old_key_name)
|
||||
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
|
||||
|
||||
|
||||
async def finalize_key_creation(
|
||||
@@ -409,7 +500,16 @@ async def finalize_key_creation(
|
||||
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()
|
||||
@@ -418,10 +518,10 @@ async def finalize_key_creation(
|
||||
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}"
|
||||
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(
|
||||
@@ -433,12 +533,8 @@ async def finalize_key_creation(
|
||||
raise ValueError(f"Сервер {selected_country} не найден.")
|
||||
|
||||
if old_key_name:
|
||||
old_key_details = await get_key_details(old_key_name, session)
|
||||
old_client_id = old_key_details.get("client_id") if old_key_details else None
|
||||
old_email = old_key_details.get("email") if old_key_details else None
|
||||
old_server_id = old_key_details.get("server_id") if old_key_details else None
|
||||
|
||||
if old_client_id and old_email and old_server_id:
|
||||
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,
|
||||
@@ -449,15 +545,16 @@ async def finalize_key_creation(
|
||||
old_server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
deletion_success = await delete_client(
|
||||
xui,
|
||||
old_server_info["inbound_id"],
|
||||
old_email,
|
||||
old_client_id,
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
if not deletion_success:
|
||||
raise ValueError(f"Не удалось удалить клиента с сервера {old_server_id}.")
|
||||
logger.warning(f"Не удалось удалить клиента с сервера {old_server_id}, но продолжаем процесс.")
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
await create_client_on_server(
|
||||
@@ -475,13 +572,9 @@ async def finalize_key_creation(
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET client_id = $1, email = $2, expiry_time = $3, key = $4, server_id = $5
|
||||
WHERE tg_id = $6 AND email = $7
|
||||
SET server_id = $1
|
||||
WHERE tg_id = $2 AND email = $3
|
||||
""",
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
selected_country,
|
||||
tg_id,
|
||||
old_key_name,
|
||||
@@ -501,6 +594,14 @@ async def finalize_key_creation(
|
||||
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}")
|
||||
@@ -508,31 +609,61 @@ async def finalize_key_creation(
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
|
||||
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=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
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=None
|
||||
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
|
||||
|
||||
+101
-24
@@ -1,13 +1,23 @@
|
||||
import asyncio
|
||||
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from client import ClientConfig, add_client, delete_client, extend_client_key, get_client_traffic, toggle_client
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB, USE_COUNTRY_SELECTION
|
||||
from database import get_servers, store_key
|
||||
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 logger import logger
|
||||
from panels.three_xui import (
|
||||
ClientConfig,
|
||||
add_client,
|
||||
delete_client,
|
||||
extend_client_key,
|
||||
get_client_traffic,
|
||||
toggle_client,
|
||||
)
|
||||
|
||||
|
||||
async def create_key_on_cluster(
|
||||
@@ -43,7 +53,8 @@ async def create_key_on_cluster(
|
||||
*(
|
||||
create_client_on_server(server, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan)
|
||||
for server in cluster
|
||||
)
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -68,6 +79,7 @@ async def create_client_on_server(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
@@ -122,12 +134,29 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
tg_id_query = "SELECT tg_id FROM keys WHERE client_id = $1 LIMIT 1"
|
||||
tg_id_record = await conn.fetchrow(tg_id_query, client_id)
|
||||
|
||||
if not tg_id_record:
|
||||
logger.error(f"Не найден пользователь с client_id={client_id} в таблице keys.")
|
||||
return False
|
||||
|
||||
tg_id = tg_id_record["tg_id"]
|
||||
|
||||
notification_prefixes = ["key_24h", "key_10h", "key_expired", "renew"]
|
||||
for notif in notification_prefixes:
|
||||
notification_id = f"{email}_{notif}"
|
||||
await delete_notification(tg_id, notification_id, session=conn)
|
||||
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
|
||||
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")
|
||||
@@ -145,10 +174,12 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
|
||||
sub_id = unique_email
|
||||
|
||||
tasks.append(
|
||||
extend_client_key(xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id)
|
||||
extend_client_key(
|
||||
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
|
||||
@@ -179,6 +210,7 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
@@ -197,7 +229,7 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
|
||||
@@ -229,6 +261,7 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
@@ -256,7 +289,7 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
logger.info(f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}")
|
||||
|
||||
@@ -265,7 +298,7 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
|
||||
raise e
|
||||
|
||||
|
||||
async def update_subscription(tg_id: int, email: str, session: Any) -> None:
|
||||
async def update_subscription(tg_id: int, email: str, session: Any, cluster_override: str = None) -> None:
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id
|
||||
@@ -281,27 +314,22 @@ async def update_subscription(tg_id: int, email: str, session: Any) -> None:
|
||||
|
||||
expiry_time = record["expiry_time"]
|
||||
client_id = record["client_id"]
|
||||
old_cluster_id = record["server_id"]
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
await delete_key_from_cluster(old_cluster_id, email, client_id)
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
DELETE FROM keys
|
||||
WHERE tg_id = $1 AND email = $2
|
||||
""",
|
||||
"DELETE FROM keys WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
least_loaded_cluster_id = await get_least_loaded_cluster()
|
||||
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,
|
||||
least_loaded_cluster_id,
|
||||
)
|
||||
update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
await store_key(
|
||||
@@ -310,7 +338,7 @@ async def update_subscription(tg_id: int, email: str, session: Any) -> None:
|
||||
email,
|
||||
expiry_time,
|
||||
public_link,
|
||||
server_id=least_loaded_cluster_id,
|
||||
server_id=new_cluster_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -355,7 +383,7 @@ async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, An
|
||||
Получает трафик с сервера для заданного client_id.
|
||||
Возвращает кортеж: (server, used_gb) или (server, ошибка).
|
||||
"""
|
||||
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
|
||||
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"]:
|
||||
@@ -378,7 +406,7 @@ async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, An
|
||||
for server, api_url in servers_map.items():
|
||||
tasks.append(fetch_traffic(api_url, client_id, server))
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for server, result in results:
|
||||
user_traffic_data[server] = result
|
||||
|
||||
@@ -422,6 +450,7 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
@@ -457,3 +486,51 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
|
||||
"""
|
||||
Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
|
||||
|
||||
Args:
|
||||
cluster_id (str): ID кластера или имя сервера
|
||||
email (str): Email клиента (будет преобразован в уникальный для SUPERNODE)
|
||||
"""
|
||||
try:
|
||||
servers = await get_servers()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
found_servers = []
|
||||
for _, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
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")
|
||||
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
continue
|
||||
|
||||
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
|
||||
|
||||
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
|
||||
|
||||
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info(f"✅ Трафик клиента {email} успешно сброшен на всех серверах кластера {cluster_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
|
||||
raise
|
||||
|
||||
+625
-141
@@ -1,12 +1,22 @@
|
||||
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
|
||||
|
||||
@@ -21,10 +31,13 @@ from config import (
|
||||
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,
|
||||
@@ -38,37 +51,76 @@ from database import (
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons.add_subscribe import (
|
||||
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.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):
|
||||
@@ -100,36 +152,83 @@ def build_keys_response(records):
|
||||
if records:
|
||||
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
key_name = record["email"]
|
||||
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")
|
||||
formatted_date_short = expiry_date_full.strftime("до %d.%m.%y")
|
||||
else:
|
||||
formatted_date_full = "без срока действия"
|
||||
formatted_date_short = "без срока действия"
|
||||
|
||||
button_text = f"🔑{key_name} ({formatted_date_short})"
|
||||
builder.row(InlineKeyboardButton(text=button_text, callback_data=f"view_key|{key_name}"))
|
||||
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_name}</b> ({formatted_date_full})\n"
|
||||
|
||||
response_message += "</blockquote>\n"
|
||||
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
|
||||
|
||||
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
|
||||
else:
|
||||
response_message = (
|
||||
"<b>🔑 У вас пока нет подписок.</b>\n\nВы можете создать новую подписку для подключения устройств."
|
||||
)
|
||||
response_message = NO_SUBSCRIPTIONS_MSG
|
||||
|
||||
builder.row(InlineKeyboardButton(text="➕ Добавить подписку", callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
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
|
||||
@@ -137,86 +236,132 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if record:
|
||||
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
|
||||
is_frozen = record["is_frozen"]
|
||||
|
||||
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> минут"
|
||||
if is_frozen:
|
||||
response_message = FROZEN_SUBSCRIPTION_MSG
|
||||
|
||||
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 = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
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"))
|
||||
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}")
|
||||
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:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key}"),
|
||||
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.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if ENABLE_DELETE_KEY_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⏳ Продлить", callback_data=f"renew_key|{key_name}"),
|
||||
InlineKeyboardButton(text="❌ Удалить", callback_data=f"delete_key|{key_name}"),
|
||||
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:
|
||||
builder.row(InlineKeyboardButton(text="⏳ Продлить подписку", callback_data=f"renew_key|{key_name}"))
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🌍 Сменить локацию", callback_data=f"change_location|{key_name}")
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", 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:
|
||||
@@ -227,6 +372,256 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
)
|
||||
|
||||
|
||||
@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]
|
||||
@@ -265,20 +660,107 @@ async def process_callback_connect_phone(callback_query: CallbackQuery):
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="📖 Ручная установка", callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{email}"))
|
||||
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
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except TelegramBadRequest as e:
|
||||
if "message can't be deleted" not in str(e):
|
||||
raise
|
||||
|
||||
await update_subscription(tg_id, email, session)
|
||||
await process_callback_view_key(callback_query, session)
|
||||
except Exception as e:
|
||||
@@ -294,27 +776,67 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
text=APPLY,
|
||||
callback_data=f"confirm_delete|{client_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="view_keys")],
|
||||
[types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
|
||||
]
|
||||
)
|
||||
|
||||
if callback_query.message.caption:
|
||||
await callback_query.message.edit_caption(
|
||||
caption="<b>Вы уверены, что хотите удалить ключ?</b>", reply_markup=confirmation_keyboard
|
||||
caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(
|
||||
text="<b>Вы уверены, что хотите удалить ключ?</b>", reply_markup=confirmation_keyboard
|
||||
)
|
||||
await callback_query.message.edit_text(text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке запроса на удаление ключа {client_id}: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete|"))
|
||||
async def process_callback_confirm_delete(callback_query: CallbackQuery, session: Any):
|
||||
email = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(email, session)
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
response_message = KEY_DELETED_MSG_SIMPLE
|
||||
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await delete_key(client_id, session)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
|
||||
)
|
||||
|
||||
servers = await get_servers(session)
|
||||
|
||||
async def delete_key_from_servers():
|
||||
try: # lol
|
||||
tasks = []
|
||||
for cluster_id, _cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
|
||||
|
||||
asyncio.create_task(delete_key_from_servers())
|
||||
|
||||
await delete_key(client_id, session)
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
|
||||
)
|
||||
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
|
||||
@@ -330,10 +852,13 @@ async def process_callback_renew_key(callback_query: CallbackQuery, session: Any
|
||||
for plan_id, plan_details in RENEWAL_PLANS.items():
|
||||
months = plan_details["months"]
|
||||
price = plan_details["price"]
|
||||
discount = DISCOUNTS.get(plan_id, 0)
|
||||
button_text = f"📅 {months} месяц{'а' if months > 1 else ''} ({price} руб.)" + (
|
||||
f" {discount}% скидка" if discount > 0 else ""
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -341,7 +866,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, session: Any
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
@@ -359,49 +884,8 @@ async def process_callback_renew_key(callback_query: CallbackQuery, session: Any
|
||||
else:
|
||||
await callback_query.message.answer("<b>Ключ не найден.</b>")
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete|"))
|
||||
async def process_callback_confirm_delete(callback_query: CallbackQuery, session: Any):
|
||||
email = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(email, session)
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
response_message = "Ключ успешно удален."
|
||||
back_button = types.InlineKeyboardButton(text="Назад", callback_data="view_keys")
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await delete_key(client_id, session)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
|
||||
)
|
||||
|
||||
servers = await get_servers(session)
|
||||
|
||||
async def delete_key_from_servers():
|
||||
try:
|
||||
tasks = []
|
||||
for cluster_id, _cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
|
||||
|
||||
asyncio.create_task(delete_key_from_servers())
|
||||
|
||||
await delete_key(client_id, session)
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
back_button = types.InlineKeyboardButton(text="Назад", callback_data="view_keys")
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.error(f"Ошибка в process_callback_renew_key: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
@@ -460,12 +944,12 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
else:
|
||||
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
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=f"💳 Недостаточно средств. Пополните баланс на {required_amount}₽.",
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
@@ -492,7 +976,7 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(months=plan)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if callback_query:
|
||||
try:
|
||||
|
||||
@@ -4,23 +4,24 @@ import random
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
PROJECT_NAME,
|
||||
SUB_MESSAGE,
|
||||
SUPERNODE,
|
||||
SUPPORT_CHAT_URL,
|
||||
TOTAL_GB,
|
||||
TRANSITION_DATE_STR,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USERNAME_BOT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import get_key_details, get_servers
|
||||
from handlers.utils import convert_to_bytes
|
||||
@@ -84,7 +85,7 @@ async def combine_unique_lines(urls: list[str], identifier: str, query_string: s
|
||||
logger.info(f"Составлены URL-адреса: {urls_with_query}")
|
||||
|
||||
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
|
||||
results = await asyncio.gather(*tasks)
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
all_lines = set()
|
||||
for lines in results:
|
||||
all_lines.update(filter(None, lines))
|
||||
@@ -275,7 +276,7 @@ def prepare_headers(
|
||||
"subscription-userinfo": subscription_userinfo,
|
||||
}
|
||||
elif "Hiddify" in user_agent:
|
||||
parts = subscription_info.split(' - ')[0].split(': ')
|
||||
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}"
|
||||
@@ -326,7 +327,7 @@ async def handle_subscription(request: web.Request, old_subscription: bool = Fal
|
||||
stored_tg_id = client_data.get("tg_id")
|
||||
server_id = client_data["server_id"]
|
||||
|
||||
if not old_subscription and str(tg_id) != str(stored_tg_id):
|
||||
if not old_subscription and int(tg_id) != int(stored_tg_id):
|
||||
logger.warning(f"Неверный tg_id для клиента с email {email}.")
|
||||
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from aiogram import Router
|
||||
from .general_notifications import router as general_notifications_router
|
||||
from .special_notifications import router as special_notifications_router
|
||||
|
||||
|
||||
router = Router(name="notifications_main_router")
|
||||
|
||||
router.include_routers(general_notifications_router, special_notifications_router)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import Bot, Router
|
||||
|
||||
from config import (
|
||||
@@ -10,6 +12,7 @@ from config import (
|
||||
NOTIFICATION_TIME,
|
||||
NOTIFY_DELETE_DELAY,
|
||||
NOTIFY_DELETE_KEY,
|
||||
NOTIFY_INACTIVE_TRAFFIC,
|
||||
NOTIFY_MAXPRICE,
|
||||
NOTIFY_RENEW,
|
||||
NOTIFY_RENEW_EXPIRED,
|
||||
@@ -26,65 +29,86 @@ from database import (
|
||||
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.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED
|
||||
from keyboards.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
|
||||
from handlers.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
|
||||
from handlers.texts import (
|
||||
KEY_DELETED_MSG,
|
||||
KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG,
|
||||
KEY_EXPIRED_DELAY_HOURS_MSG,
|
||||
KEY_EXPIRED_DELAY_MINUTES_MSG,
|
||||
KEY_EXPIRED_NO_DELAY_MSG,
|
||||
KEY_EXPIRY_10H,
|
||||
KEY_EXPIRY_24H,
|
||||
KEY_RENEWED,
|
||||
KEY_RENEWED_TEMP_MSG,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
from .notify_utils import send_notification
|
||||
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
notification_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def periodic_notifications(bot: Bot):
|
||||
"""
|
||||
Обработчик, который:
|
||||
1. Получает список всех ключей.
|
||||
2. Отправляет уведомления пользователям о неактивном пробном периоде (если триал включен).
|
||||
3. Отправляет уведомления об истекающих ключах (10h и 24h).
|
||||
4. Проверяет истекшие ключи.
|
||||
5. Проверяет пользователей с нулевым трафиком.
|
||||
Периодическая проверка и отправка уведомлений.
|
||||
Защищена от одновременного запуска с помощью asyncio.Lock.
|
||||
"""
|
||||
while True:
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
|
||||
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
logger.info("Начало обработки уведомлений.")
|
||||
if notification_lock.locked():
|
||||
logger.warning("⛔ Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
continue
|
||||
|
||||
async with notification_lock:
|
||||
conn = None
|
||||
try:
|
||||
keys = await get_all_keys(session=conn)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключей: {e}")
|
||||
keys = []
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
await notify_inactive_trial_users(bot, conn)
|
||||
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
|
||||
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
logger.info("🚀 Запуск обработки уведомлений")
|
||||
|
||||
try:
|
||||
keys = await get_all_keys(session=conn)
|
||||
keys = [k for k in keys if not k["is_frozen"]]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключей: {e}")
|
||||
keys = []
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
await notify_inactive_trial_users(bot, conn)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await handle_expired_keys(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
if NOTIFY_INACTIVE_TRAFFIC:
|
||||
await notify_users_no_traffic(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await handle_expired_keys(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
await notify_users_no_traffic(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
logger.info("✅ Завершена обработка уведомлений")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в periodic_notifications: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("Соединение с базой данных закрыто.")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в periodic_notifications: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("🔌 Соединение с базой данных закрыто.")
|
||||
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
|
||||
@@ -236,7 +260,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
if balance >= renewal_cost:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
bot, conn, key, notification_id, 1, "notify_expired.jpg", "Ваш ключ продлён!"
|
||||
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
|
||||
@@ -265,10 +289,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
bot,
|
||||
tg_id,
|
||||
"notify_expired.jpg",
|
||||
(
|
||||
f"Ваша подписка {email} была удалена, так как вы не продлили её действие.\n\n"
|
||||
"Перейдите в личный кабинет и получите новую!"
|
||||
),
|
||||
KEY_DELETED_MSG.format(email=email),
|
||||
keyboard,
|
||||
)
|
||||
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
|
||||
@@ -287,14 +308,17 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
|
||||
if hours > 0:
|
||||
if minutes > 0:
|
||||
delay_message = f"⚠️ Ваша подписка {email} истекла.\n\nЕсли вы не продлите её, она будет удалена через {hours} час{'а' if hours == 1 else 'ов'} и {minutes} минут."
|
||||
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)
|
||||
else:
|
||||
delay_message = f"⚠️ Ваша подписка {email} истекла.\n\nЕсли вы не продлите её, она будет удалена через {hours} час{'а' if hours == 1 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)
|
||||
else:
|
||||
delay_message = f"⚠️ Ваша подписка {email} истекла.\n\nЕсли вы не продлите её, она будет удалена через {NOTIFY_DELETE_DELAY} минут."
|
||||
|
||||
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(email=email, minutes=NOTIFY_DELETE_DELAY)
|
||||
else:
|
||||
delay_message = f"⚠ Ваша подписка {email} истекла!\n\nПродлите доступ, чтобы возобновить услуги."
|
||||
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
|
||||
|
||||
try:
|
||||
await send_notification(
|
||||
@@ -316,7 +340,13 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
|
||||
|
||||
async def process_auto_renew_or_notify(
|
||||
bot, conn, key: dict, notification_id: str, renewal_period_months: int, standard_photo: str, standard_caption: str
|
||||
bot,
|
||||
conn,
|
||||
key: dict,
|
||||
notification_id: str,
|
||||
renewal_period_months: int,
|
||||
standard_photo: str,
|
||||
standard_caption: str
|
||||
):
|
||||
"""
|
||||
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
|
||||
@@ -335,7 +365,6 @@ async def process_auto_renew_or_notify(
|
||||
return
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных для пользователя {tg_id}: {e}")
|
||||
return
|
||||
@@ -367,6 +396,7 @@ async def process_auto_renew_or_notify(
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
|
||||
await add_notification(tg_id, renew_notification_id, session=conn)
|
||||
await delete_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
logger.info(
|
||||
f"✅ Ключ {client_id} продлён на {renewal_period_months} мес. для пользователя {tg_id}. Списано {renewal_cost}."
|
||||
@@ -378,12 +408,10 @@ async def process_auto_renew_or_notify(
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
|
||||
except KeyError as e:
|
||||
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from handlers.buttons import MAIN_MENU, RENEW_KEY
|
||||
|
||||
|
||||
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
@@ -9,8 +11,8 @@ def build_notification_kb(email: str) -> InlineKeyboardMarkup:
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔄 Продлить VPN", callback_data=f"renew_key|{email}")
|
||||
builder.button(text="👤 Личный кабинет", callback_data="profile")
|
||||
builder.button(text=RENEW_KEY, callback_data=f"renew_key|{email}")
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -23,5 +25,5 @@ def build_notification_expired_kb() -> InlineKeyboardMarkup:
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="👤 Личный кабинет", callback_data="profile")
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
return builder.as_markup()
|
||||
@@ -1,52 +1,103 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
def rate_limited_send(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
while True:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except TelegramRetryAfter as e:
|
||||
retry_in = int(e.retry_after) + 1
|
||||
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек.")
|
||||
await asyncio.sleep(retry_in)
|
||||
except TelegramForbiddenError:
|
||||
tg_id = kwargs.get("tg_id") or args[1]
|
||||
logger.warning(f"Пользователь {tg_id} заблокировал бота.")
|
||||
return False
|
||||
except Exception as e:
|
||||
tg_id = kwargs.get("tg_id") or args[1]
|
||||
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
|
||||
return False
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def send_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
image_filename: str,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup,
|
||||
):
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Отправляет уведомление с изображением, если файл существует, иначе отправляет текстовое сообщение.
|
||||
Если возникает TelegramForbiddenError (например, бот заблокирован пользователем),
|
||||
функция логирует ошибку и прекращает попытки отправки уведомления.
|
||||
Отправляет уведомление пользователю.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота для отправки сообщений
|
||||
tg_id: Telegram ID пользователя
|
||||
image_filename: Имя файла изображения в директории img
|
||||
caption: Текст сообщения
|
||||
keyboard: Клавиатура для сообщения (опционально)
|
||||
|
||||
Returns:
|
||||
bool: True если сообщение успешно отправлено, False в случае ошибки
|
||||
"""
|
||||
photo_path = os.path.join("img", image_filename)
|
||||
|
||||
if os.path.isfile(photo_path):
|
||||
try:
|
||||
async with aiofiles.open(photo_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
|
||||
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
|
||||
try:
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"Ошибка отправки fallback-сообщения для пользователя {tg_id}: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Неизвестная ошибка при отправке fallback-сообщения для пользователя {tg_id}: {e}")
|
||||
return
|
||||
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
|
||||
else:
|
||||
logger.error(f"Файл с изображением не найден: {photo_path}")
|
||||
try:
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"Ошибка отправки сообщения для пользователя {tg_id}: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
|
||||
return
|
||||
logger.warning(f"Файл с изображением не найден: {photo_path}")
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
|
||||
|
||||
@rate_limited_send
|
||||
async def _send_photo_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
photo_path: str,
|
||||
image_filename: str,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправляет уведомление с изображением."""
|
||||
try:
|
||||
async with aiofiles.open(photo_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
|
||||
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Пользователь {tg_id} заблокировал бота")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
|
||||
|
||||
@rate_limited_send
|
||||
async def _send_text_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправляет текстовое уведомление."""
|
||||
try:
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Пользователь {tg_id} заблокировал бота")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
|
||||
return False
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
@@ -13,9 +15,12 @@ from database import (
|
||||
check_notification_time,
|
||||
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 logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
@@ -64,7 +69,7 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
trial_extended = await conn.fetchval(
|
||||
@@ -78,25 +83,13 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
)
|
||||
|
||||
if trial_extended:
|
||||
message = (
|
||||
f"<b>{display_name}</b>, у нас для тебя подарок! 🎁\n\n"
|
||||
"<blockquote>"
|
||||
f"Мы добавили тебе +{NOTIFY_EXTRA_DAYS} дополнительных дня к пробному периоду!\n"
|
||||
f"Теперь у тебя есть еще шанс протестировать наш VPN целых {NOTIFY_EXTRA_DAYS + TRIAL_TIME} дня!\n"
|
||||
"</blockquote>"
|
||||
"Нажми на кнопку ниже, чтобы активировать доступ с бонусом +2 дня! 👇"
|
||||
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 = (
|
||||
f"👋 <b>Привет, {display_name}!</b>\n\n"
|
||||
"<blockquote>"
|
||||
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
|
||||
"Не упусти возможность попробовать наш VPN прямо сейчас.\n"
|
||||
"</blockquote>"
|
||||
"Нажми на кнопку ниже, чтобы активировать пробный доступ! 👇"
|
||||
)
|
||||
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)
|
||||
@@ -167,7 +160,7 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
|
||||
continue
|
||||
|
||||
total_traffic = sum(
|
||||
value if isinstance(value, (int, float)) else 0 for value in traffic_data.get("traffic", {}).values()
|
||||
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
|
||||
)
|
||||
logger.info(f"Ключ для {email}: общий трафик: {total_traffic} ГБ")
|
||||
|
||||
@@ -176,15 +169,10 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
|
||||
builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
message = (
|
||||
f"⚠ <b>Ваша VPN-подписка {email} активна, но трафик не используется.</b>\n\n"
|
||||
"<blockquote>Если у вас возникли сложности с подключением, "
|
||||
"нажмите кнопку ниже, чтобы связаться с поддержкой.</blockquote>\n\n"
|
||||
"🛠 Мы поможем вам разобраться! 💡"
|
||||
)
|
||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
|
||||
+10
-18
@@ -10,9 +10,12 @@ 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 .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -23,56 +26,45 @@ async def handle_pay(callback_query: CallbackQuery):
|
||||
if YOOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💳 ЮКасса: быстрый перевод",
|
||||
text=YOOKASSA,
|
||||
callback_data="pay_yookassa",
|
||||
)
|
||||
)
|
||||
if YOOMONEY_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💳 ЮМани: оплата по карте",
|
||||
text=YOOMONEY,
|
||||
callback_data="pay_yoomoney",
|
||||
)
|
||||
)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 CryptoBot: криптовалюта",
|
||||
text=CRYPTOBOT,
|
||||
callback_data="pay_cryptobot",
|
||||
)
|
||||
)
|
||||
if STARS_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⭐ Оплата Звездами",
|
||||
text=STARS,
|
||||
callback_data="pay_stars",
|
||||
)
|
||||
)
|
||||
if ROBOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⭐ RoboKassa",
|
||||
text=ROBOKASSA,
|
||||
callback_data="pay_robokassa",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🎟️ Активировать купон", callback_data="activate_coupon"))
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
payment_text = (
|
||||
"💸 <b>Выберите удобный способ пополнения баланса:</b>\n"
|
||||
"<blockquote>"
|
||||
"• Быстро и безопасно\n"
|
||||
"• Поддержка разных платежных систем\n"
|
||||
"• Моментальное зачисление средств 🚀\n"
|
||||
"</blockquote>"
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=payment_text,
|
||||
text=PAYMENT_METHODS_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable → Regular
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Executable → Regular
BIN
Binary file not shown.
@@ -27,10 +27,12 @@ from database import (
|
||||
update_balance,
|
||||
)
|
||||
from handlers.payments.utils import send_payment_success_notification
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
from handlers.texts import PAYMENT_OPTIONS, ENTER_SUM, DEFAULT_PAYMENT_MESSAGE
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from handlers.buttons import BACK, PAY_2
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -89,7 +91,7 @@ async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, st
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
@@ -151,14 +153,14 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Оплатить", url=payment_url)],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")],
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="pay_robokassa")],
|
||||
]
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
|
||||
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
@@ -230,11 +232,11 @@ async def process_custom_amount_selection(callback_query: types.CallbackQuery, s
|
||||
logger.info(f"User {tg_id} chose to enter a custom amount.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="pay_robokassa"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Пожалуйста, введите сумму пополнения.",
|
||||
text=ENTER_SUM,
|
||||
reply_markup=builder.as_markup(),
|
||||
force_text=True,
|
||||
)
|
||||
@@ -285,7 +287,7 @@ async def handle_custom_amount_input(
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
|
||||
|
||||
if state_type == "waiting_for_payment":
|
||||
message_text = (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable → Regular
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Executable → Regular
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Executable → Regular
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Executable → Regular
BIN
Binary file not shown.
+173
-33
@@ -1,7 +1,12 @@
|
||||
import html
|
||||
from io import BytesIO
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import qrcode
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -16,34 +21,46 @@ from aiogram.types import (
|
||||
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 handlers.buttons.profile import (
|
||||
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
|
||||
)
|
||||
from handlers.texts import get_referral_link, invite_message_send, profile_message_send
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback
|
||||
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, INVITE_TEXT_NON_INLINE, TOP_REFERRALS_TEXT
|
||||
from logger import logger
|
||||
import html
|
||||
|
||||
from .admin.panel.keyboard import AdminPanelCallback
|
||||
from .texts import get_referral_link, invite_message_send, profile_message_send
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -55,14 +72,27 @@ async def process_callback_view_profile(
|
||||
admin: bool,
|
||||
):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
chat_id = callback_query_or_message.message.chat.id
|
||||
username = html.escape(callback_query_or_message.from_user.full_name)
|
||||
chat = callback_query_or_message.message.chat
|
||||
from_user = callback_query_or_message.from_user
|
||||
chat_id = chat.id
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
chat_id = callback_query_or_message.chat.id
|
||||
username = html.escape(callback_query_or_message.from_user.full_name)
|
||||
chat = callback_query_or_message.chat
|
||||
from_user = callback_query_or_message.from_user
|
||||
chat_id = chat.id
|
||||
target_message = callback_query_or_message
|
||||
|
||||
user = chat if chat.type == "private" else from_user
|
||||
|
||||
if getattr(user, "full_name", None):
|
||||
username = html.escape(user.full_name)
|
||||
elif getattr(user, "first_name", None):
|
||||
username = html.escape(user.first_name)
|
||||
elif getattr(user, "username", None):
|
||||
username = "@" + html.escape(user.username)
|
||||
else:
|
||||
username = "Пользователь"
|
||||
|
||||
image_path = os.path.join("img", "profile.jpg")
|
||||
logger.info(f"Переход в профиль. Используется изображение: {image_path}")
|
||||
|
||||
@@ -82,23 +112,32 @@ async def process_callback_view_profile(
|
||||
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if trial_status == 0 or key_count == 0:
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
else:
|
||||
if key_count > 0:
|
||||
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
|
||||
elif trial_status == 0:
|
||||
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=INVITE, callback_data="invite"),
|
||||
InlineKeyboardButton(text=GIFTS, callback_data="gifts"),
|
||||
)
|
||||
|
||||
row_buttons = []
|
||||
if REFERRAL_BUTTON:
|
||||
row_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
|
||||
if GIFT_BUTTON:
|
||||
row_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
|
||||
if row_buttons:
|
||||
builder.row(*row_buttons)
|
||||
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
|
||||
if admin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="start"))
|
||||
if SHOW_START_MENU_ONCE:
|
||||
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
@@ -124,9 +163,11 @@ async def balance_handler(callback_query: CallbackQuery, session: Any):
|
||||
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 = f"<b>Управление вашим балансом 💰</b>\n\nВаш баланс: {balance}"
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
@@ -145,7 +186,7 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
records = await get_last_payments(callback_query.from_user.id, session)
|
||||
|
||||
if records:
|
||||
history_text = "📊 <b>Последние 3 операции с балансом:</b>\n\n"
|
||||
history_text = BALANCE_HISTORY_HEADER
|
||||
for record in records:
|
||||
amount = record["amount"]
|
||||
payment_system = record["payment_system"]
|
||||
@@ -173,17 +214,15 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
@router.callback_query(F.data == "view_tariffs")
|
||||
async def view_tariffs_handler(callback_query: CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
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)
|
||||
]
|
||||
)
|
||||
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,
|
||||
@@ -212,11 +251,14 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery):
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
builder.button(text="👥 Пригласить друга", switch_inline_query="invite")
|
||||
builder.button(text=INVITE, switch_inline_query="invite")
|
||||
else:
|
||||
invite_text = f"\nПриглашаю тебя пользоваться действительно быстрым VPN вместе:\n\n{referral_link}"
|
||||
builder.button(text="👥 Пригласить друга", switch_inline_query=invite_text)
|
||||
builder.button(text="👤 Личный кабинет", callback_data="profile")
|
||||
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(
|
||||
@@ -252,3 +294,101 @@ async def inline_referral_handler(inline_query: InlineQuery):
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
+109
-157
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -19,6 +20,7 @@ from config import (
|
||||
CHANNEL_REQUIRED,
|
||||
CHANNEL_URL,
|
||||
DONATIONS_ENABLE,
|
||||
SHOW_START_MENU_ONCE,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import (
|
||||
@@ -29,14 +31,29 @@ from database import (
|
||||
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.captcha import generate_captcha
|
||||
from handlers.keys.key_management import create_key
|
||||
from handlers.texts import WELCOME_TEXT, get_about_vpn
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback
|
||||
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,
|
||||
WELCOME_TEXT,
|
||||
get_about_vpn,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
from .admin.panel.keyboard import AdminPanelCallback
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -73,12 +90,14 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=f"Для использования бота, пожалуйста, подпишитесь на наш канал: {CHANNEL_URL}",
|
||||
text=SUBSCRIPTION_REQUIRED_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.info(f"Пользователь {message.chat.id} подписан на канал (статус: {member.status}). Продолжаем работу.")
|
||||
logger.info(
|
||||
f"Пользователь {message.chat.id} подписан на канал (статус: {member.status}). Продолжаем работу."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписки пользователя {message.chat.id}: {e}")
|
||||
await state.update_data(start_text=text_to_process)
|
||||
@@ -86,7 +105,7 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=f"Пожалуйста, подпишитесь на наш канал: {CHANNEL_URL}",
|
||||
text=SUBSCRIPTION_REQUIRED_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
@@ -99,187 +118,115 @@ async def process_start_logic(
|
||||
text = text_to_process if text_to_process is not None else message.text
|
||||
if text:
|
||||
try:
|
||||
connection_exists = await check_connection_exists(message.chat.id)
|
||||
logger.info(f"Проверка существования подключения: {connection_exists}")
|
||||
|
||||
if not connection_exists:
|
||||
await add_connection(tg_id=message.chat.id, session=session)
|
||||
logger.info(f"Пользователь {message.chat.id} успешно добавлен в базу данных.")
|
||||
|
||||
if "coupons_" in text:
|
||||
logger.info(f"Обнаружена ссылка на купон: {text}")
|
||||
coupon_code = text.split("coupons_")[1].strip()
|
||||
logger.info(f"Пользователь {message.chat.id} ввёл купон: {coupon_code}")
|
||||
|
||||
coupon = await session.fetchrow(
|
||||
"SELECT id, code, amount, usage_limit, usage_count, is_used FROM coupons WHERE code = $1",
|
||||
coupon_code,
|
||||
)
|
||||
if coupon is None:
|
||||
logger.warning(f"Купон {coupon_code} не найден.")
|
||||
await message.answer("❌ Купон не найден!")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
usage_exists = await session.fetchval(
|
||||
"SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
|
||||
coupon["id"],
|
||||
message.chat.id,
|
||||
)
|
||||
if usage_exists:
|
||||
logger.info(f"Пользователь {message.chat.id} уже активировал купон {coupon_code}.")
|
||||
await message.answer("❌ Вы уже использовали этот купон!")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
if coupon["is_used"] or coupon["usage_count"] >= coupon["usage_limit"]:
|
||||
logger.info(f"Купон {coupon_code} уже использован или исчерпан.")
|
||||
await message.answer("❌ Этот купон уже использован!")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
await update_balance(message.chat.id, coupon["amount"])
|
||||
logger.info(f"Начислено {coupon['amount']} единиц для пользователя {message.chat.id}")
|
||||
|
||||
new_usage_count = coupon["usage_count"] + 1
|
||||
is_used = new_usage_count >= coupon["usage_limit"]
|
||||
|
||||
await session.execute(
|
||||
"UPDATE coupons SET usage_count = $1, is_used = $2 WHERE code = $3",
|
||||
new_usage_count,
|
||||
is_used,
|
||||
coupon_code,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
"INSERT INTO coupon_usages (coupon_id, user_id, used_at) VALUES ($1, $2, NOW())",
|
||||
coupon["id"],
|
||||
message.chat.id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Купон {coupon_code} успешно использован пользователем {message.chat.id}, начислено {coupon['amount']} RUB."
|
||||
)
|
||||
await message.answer(f"🎉 Ваш баланс пополнен на {coupon['amount']} RUB по купону!")
|
||||
return await show_start_menu(message, admin, session)
|
||||
user_id = message.chat.id
|
||||
await handle_coupon_activation(message, state, session, admin, text=text, user_id=user_id)
|
||||
return
|
||||
|
||||
if "gift_" in text:
|
||||
logger.info(f"Обнаружена ссылка на подарок: {text}")
|
||||
parts = text.split("gift_")[1].split("_")
|
||||
if len(parts) < 2:
|
||||
logger.error("Неверный формат ссылки на подарок: недостаточно частей после 'gift_'")
|
||||
await message.answer("❌ Неверный формат ссылки на подарок.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
gift_id = parts[0]
|
||||
recipient_tg_id = message.chat.id
|
||||
|
||||
gift_info = await session.fetchrow(
|
||||
"""
|
||||
SELECT sender_tg_id, selected_months, expiry_time, is_used, recipient_tg_id
|
||||
FROM gifts WHERE gift_id = $1
|
||||
""",
|
||||
gift_id,
|
||||
)
|
||||
|
||||
if gift_info is None:
|
||||
logger.warning(f"Подарок с ID {gift_id} уже был использован или не существует.")
|
||||
await message.answer("Этот подарок уже был использован или не существует.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
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"]:
|
||||
logger.warning(f"Подарок с ID {gift_id} уже был активирован ранее.")
|
||||
await message.answer("Этот подарок уже был использован.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
if gift_info["sender_tg_id"] == recipient_tg_id:
|
||||
logger.warning(f"Пользователь {recipient_tg_id} попытался активировать свой же подарок.")
|
||||
if gift_info["sender_tg_id"] == message.chat.id:
|
||||
await message.answer("❌ Вы не можете получить подарок от самого себя.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
if gift_info["recipient_tg_id"] is not None:
|
||||
logger.warning(
|
||||
f"Подарок {gift_id} уже привязан к другому пользователю ({gift_info['recipient_tg_id']})."
|
||||
)
|
||||
if gift_info["recipient_tg_id"]:
|
||||
await message.answer("❌ Этот подарок уже был активирован другим пользователем.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
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_referral(recipient_tg_id, gift_info["sender_tg_id"], session)
|
||||
logger.info(
|
||||
f"Пользователь {recipient_tg_id} теперь является рефералом отправителя {gift_info['sender_tg_id']}."
|
||||
)
|
||||
await add_connection(tg_id=message.chat.id, session=session)
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE connections SET trial = 1 WHERE tg_id = $1
|
||||
""",
|
||||
recipient_tg_id,
|
||||
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,
|
||||
)
|
||||
|
||||
selected_months = gift_info["selected_months"]
|
||||
expiry_time = gift_info["expiry_time"].replace(tzinfo=None)
|
||||
|
||||
logger.info(f"Подарок с ID {gift_id} успешно найден для пользователя {recipient_tg_id}.")
|
||||
|
||||
await create_key(recipient_tg_id, expiry_time, state, session, message)
|
||||
logger.info(f"Ключ создан для пользователя {recipient_tg_id} на срок {selected_months} месяцев.")
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1
|
||||
WHERE gift_id = $2
|
||||
""",
|
||||
recipient_tg_id,
|
||||
"UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1 WHERE gift_id = $2",
|
||||
message.chat.id,
|
||||
gift_id,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 Ваш подарок на {selected_months} "
|
||||
f"{'месяц' if selected_months == 1 else 'месяца' if selected_months in [2, 3, 4] else 'месяцев'} активирован!"
|
||||
f"🎉 Ваш подарок на {gift_info['selected_months']} "
|
||||
f"{'месяц' if gift_info['selected_months'] == 1 else 'месяца' if gift_info['selected_months'] in [2, 3, 4] else 'месяцев'} активирован!"
|
||||
)
|
||||
logger.info(f"Подарок на {selected_months} месяцев активирован для пользователя {recipient_tg_id}.")
|
||||
return
|
||||
|
||||
elif "referral_" in text:
|
||||
if "referral_" in text:
|
||||
try:
|
||||
referrer_tg_id = int(text.split("referral_")[1])
|
||||
if connection_exists:
|
||||
logger.info(f"Пользователь {message.chat.id} уже зарегистрирован и не может стать рефералом.")
|
||||
connection_exists_now = await check_connection_exists(message.chat.id)
|
||||
if connection_exists_now:
|
||||
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
if referrer_tg_id == message.chat.id:
|
||||
logger.warning(f"Пользователь {message.chat.id} попытался стать рефералом самого себя.")
|
||||
await message.answer("❌ Вы не можете быть рефералом самого себя.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
|
||||
if existing_referral:
|
||||
logger.info(f"Реферал с ID {message.chat.id} уже существует.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
return await process_callback_view_profile(message, state, admin)
|
||||
|
||||
await add_referral(message.chat.id, referrer_tg_id, session)
|
||||
logger.info(f"Реферал {message.chat.id} использовал ссылку от пользователя {referrer_tg_id}")
|
||||
await message.answer(f"Вы стали рефералом пользователя с ID {referrer_tg_id}")
|
||||
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
||||
try:
|
||||
await bot.send_message(referrer_tg_id, f"🎉 Ваш реферал {message.chat.id} успешно зарегистрировался!")
|
||||
logger.info(f"Уведомление отправлено пользователю {referrer_tg_id} о новом реферале {message.chat.id}")
|
||||
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
|
||||
|
||||
return await show_start_menu(message, admin, session)
|
||||
logger.info("Пользователь зашел без реферальной ссылки, подарка или купона.")
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка при обработке реферальной ссылки: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке текста {message.text} — {e}", exc_info=True)
|
||||
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
|
||||
else:
|
||||
logger.info(f"Пользователь {message.chat.id} зашел без реферальной ссылки, подарка или купона.")
|
||||
|
||||
await show_start_menu(message, admin, session)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка при обработке сообщения пользователя {message.chat.id}: {e}")
|
||||
await message.answer("❌ Произошла ошибка. Пожалуйста, попробуйте снова.")
|
||||
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 show_start_menu(message, admin, session)
|
||||
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")
|
||||
@@ -291,15 +238,15 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
|
||||
logger.info(f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}")
|
||||
|
||||
if member.status not in ["member", "administrator", "creator"]:
|
||||
await callback_query.answer("Вы еще не подписаны на канал!", show_alert=True)
|
||||
await callback_query.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
|
||||
await callback_query.message.edit_text(
|
||||
f"Для использования бота, пожалуйста, подпишитесь на наш канал: {CHANNEL_URL}",
|
||||
SUBSCRIPTION_REQUIRED_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query.answer("Подписка подтверждена!")
|
||||
await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG)
|
||||
data = await state.get_data()
|
||||
original_text = data.get("original_text")
|
||||
if not original_text:
|
||||
@@ -308,7 +255,7 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
|
||||
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True)
|
||||
await callback_query.answer("Ошибка проверки подписки, повторите попытку", show_alert=True)
|
||||
await callback_query.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
|
||||
|
||||
|
||||
async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
@@ -323,26 +270,27 @@ 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="🔗 Подключить VPN", callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text="🎁 Пробная подписка", callback_data="create_key"))
|
||||
else:
|
||||
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
|
||||
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
if not SHOW_START_MENU_ONCE:
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if CHANNEL_EXISTS:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL),
|
||||
InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL),
|
||||
InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL),
|
||||
InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
|
||||
if admin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🌐 О VPN", callback_data="about_vpn"))
|
||||
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
@@ -357,10 +305,14 @@ async def handle_about_vpn(callback_query: CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
builder.row(InlineKeyboardButton(text="📞 Техническая поддержка", url=SUPPORT_CHAT_URL))
|
||||
support_btn = InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)
|
||||
if CHANNEL_EXISTS:
|
||||
builder.row(InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="start"))
|
||||
channel_btn = InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL)
|
||||
builder.row(support_btn, channel_btn)
|
||||
else:
|
||||
builder.row(support_btn)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
|
||||
text = get_about_vpn("3.2.3-minor")
|
||||
|
||||
await edit_or_send_message(
|
||||
|
||||
+38
-12
@@ -7,6 +7,7 @@ import string
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
|
||||
|
||||
from bot import bot
|
||||
@@ -64,26 +65,30 @@ async def get_least_loaded_cluster() -> str:
|
||||
str: Идентификатор наименее загруженного кластера.
|
||||
"""
|
||||
servers = await get_servers()
|
||||
|
||||
cluster_loads: dict[str, int] = {cluster_id: 0 for cluster_id in servers.keys()}
|
||||
|
||||
server_to_cluster = {}
|
||||
cluster_loads = dict.fromkeys(servers.keys(), 0)
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
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:
|
||||
cluster_id = key["server_id"]
|
||||
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
|
||||
|
||||
logger.info(f"Cluster loads after database query: {cluster_loads}")
|
||||
|
||||
else:
|
||||
logger.warning(f"⚠️ Сервер {server_id} не найден в известных кластерах!")
|
||||
logger.info(f"Загруженность кластеров после запроса к БД: {cluster_loads}")
|
||||
if not cluster_loads:
|
||||
logger.warning("No clusters found in database or configuration.")
|
||||
logger.warning("⚠️ В базе данных или конфигурации нет кластеров!")
|
||||
return "cluster1"
|
||||
|
||||
least_loaded_cluster = min(cluster_loads, key=lambda k: (cluster_loads[k], k))
|
||||
|
||||
logger.info(f"Least loaded cluster selected: {least_loaded_cluster}")
|
||||
logger.info(f"✅ Выбран наименее загруженный кластер: {least_loaded_cluster}")
|
||||
|
||||
return least_loaded_cluster
|
||||
|
||||
@@ -147,6 +152,27 @@ def format_time_until_deletion(seconds: int) -> str:
|
||||
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_days(days: int) -> str:
|
||||
"""
|
||||
Форматирует количество дней с правильным склонением.
|
||||
|
||||
Args:
|
||||
days (int): Количество дней.
|
||||
|
||||
Returns:
|
||||
str: Строка с числом и склонённым словом "день/дня/дней".
|
||||
"""
|
||||
if days <= 0:
|
||||
return "0 дней"
|
||||
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
|
||||
|
||||
|
||||
async def edit_or_send_message(
|
||||
target_message: Message,
|
||||
text: str,
|
||||
@@ -199,7 +225,7 @@ async def edit_or_send_message(
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
await target_message.answer(
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from keyboards.admin.panel_kb import build_admin_back_btn
|
||||
|
||||
|
||||
class AdminSenderCallback(CallbackData, prefix="admin_sender"):
|
||||
type: str
|
||||
data: str | None = None
|
||||
|
||||
|
||||
def build_sender_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="👥 Все пользователи", callback_data=AdminSenderCallback(type="all").pack())
|
||||
builder.button(text="✅ Пользователи с подпиской", callback_data=AdminSenderCallback(type="subscribed").pack())
|
||||
builder.button(text="❌ Пользователи без подписки", callback_data=AdminSenderCallback(type="unsubscribed").pack())
|
||||
builder.button(text="📢 Пользователи кластера", callback_data=AdminSenderCallback(type="cluster-select").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cluster in clusters:
|
||||
name = cluster["cluster_name"]
|
||||
builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
|
||||
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -1,81 +0,0 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
class AdminServerEditorCallback(CallbackData, prefix="admin_servers"):
|
||||
action: str
|
||||
data: str
|
||||
|
||||
|
||||
def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for cluster_name in servers:
|
||||
builder.button(
|
||||
text=f"⚙️ {cluster_name}",
|
||||
callback_data=AdminServerEditorCallback(action="clusters_manage", data=cluster_name).pack(),
|
||||
)
|
||||
|
||||
builder.button(text="➕ Добавить кластер", callback_data=AdminPanelCallback(action="clusters_add").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_manage_cluster_kb(cluster_servers, cluster_name) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for server in cluster_servers:
|
||||
builder.button(
|
||||
text=f"🌍 {server['server_name']}",
|
||||
callback_data=AdminServerEditorCallback(action="servers_manage", data=server["server_name"]).pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="➕ Добавить сервер",
|
||||
callback_data=AdminServerEditorCallback(action="servers_add", data=cluster_name).pack(),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность",
|
||||
callback_data=AdminServerEditorCallback(action="servers_availability", data=cluster_name).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Синхронизация",
|
||||
callback_data=AdminServerEditorCallback(action="clusters_sync", data=cluster_name).pack(),
|
||||
),
|
||||
)
|
||||
builder.button(
|
||||
text="💾 Создать бэкап кластера",
|
||||
callback_data=AdminServerEditorCallback(action="clusters_backup", data=cluster_name).pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn("servers"))
|
||||
builder.adjust(1, 1, 1, 1, 1, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="🗑️ Удалить", callback_data=AdminServerEditorCallback(action="servers_delete", data=server_name).pack()
|
||||
)
|
||||
builder.button(
|
||||
text="🔙 Назад", callback_data=AdminServerEditorCallback(action="clusters_manage", data=cluster_name).pack()
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_delete_server_kb(server_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="✅ Да", callback_data=AdminServerEditorCallback(action="servers_delete_confirm", data=server_name).pack()
|
||||
)
|
||||
builder.button(
|
||||
text="🔙 Назад", callback_data=AdminServerEditorCallback(action="servers_manage", data=server_name).pack()
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
log_folder = "logs"
|
||||
|
||||
if not os.path.exists(log_folder):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Optional, Type, Union
|
||||
|
||||
from aiogram import Dispatcher
|
||||
from aiogram.dispatcher.middlewares.base import BaseMiddleware
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Dict, Set, cast
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||
@@ -14,8 +14,7 @@ class AdminMiddleware(BaseMiddleware):
|
||||
является ли пользователь администратором.
|
||||
"""
|
||||
|
||||
# Кэш ID администраторов для быстрого доступа
|
||||
_admin_ids: set[int] = set(ADMIN_ID) if isinstance(ADMIN_ID, (list, tuple)) else {ADMIN_ID}
|
||||
_admin_ids: set[int] = set(ADMIN_ID) if isinstance(ADMIN_ID, list | tuple) else {ADMIN_ID}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -51,7 +50,6 @@ class AdminMiddleware(BaseMiddleware):
|
||||
elif isinstance(event, CallbackQuery):
|
||||
return event.from_user and event.from_user.id in self._admin_ids
|
||||
|
||||
# Для других типов событий пытаемся получить from_user
|
||||
user_id = getattr(getattr(event, "from_user", None), "id", None)
|
||||
return user_id in self._admin_ids if user_id else False
|
||||
except Exception:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Dict, Optional, TypedDict, Union
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, InlineQuery, Message, TelegramObject, User
|
||||
@@ -45,12 +45,10 @@ class LoggingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
result: UserInfo = {"user_id": None, "username": None, "action": None}
|
||||
|
||||
# Обработка общих атрибутов для разных типов событий
|
||||
if hasattr(event, "from_user") and isinstance(event.from_user, User):
|
||||
result["user_id"] = event.from_user.id
|
||||
result["username"] = event.from_user.username
|
||||
|
||||
# Определение типа действия в зависимости от типа события
|
||||
if isinstance(event, Message):
|
||||
result["action"] = f"Сообщение: {event.text}"
|
||||
elif isinstance(event, CallbackQuery):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
@@ -17,11 +18,9 @@ class SessionMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
# Создаем пул соединений при первом вызове
|
||||
if self.pool is None:
|
||||
self.pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20)
|
||||
|
||||
# Получаем соединение из пула
|
||||
async with self.pool.acquire() as conn:
|
||||
data["session"] = conn
|
||||
return await handler(event, data)
|
||||
|
||||
+20
-98
@@ -1,109 +1,31 @@
|
||||
from collections.abc import Awaitable, Callable, MutableMapping
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.dispatcher.flags import get_flag
|
||||
from aiogram.types import TelegramObject, Update, User
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.types import CallbackQuery
|
||||
from cachetools import TTLCache
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для ограничения частоты запросов от пользователей.
|
||||
Позволяет настраивать разные временные интервалы для разных типов запросов.
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self.cache = TTLCache(maxsize=10_000, ttl=1.0)
|
||||
self.throttle_notice_cache = TTLCache(maxsize=10_000, ttl=1.0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_key: str | None = "default",
|
||||
default_ttl: float = 0.5,
|
||||
cache_size: int = 10_000,
|
||||
**ttl_map: float,
|
||||
) -> None:
|
||||
"""
|
||||
Инициализация middleware для ограничения частоты запросов.
|
||||
|
||||
Args:
|
||||
default_key: Ключ по умолчанию для ограничения
|
||||
default_ttl: Время ограничения по умолчанию в секундах
|
||||
cache_size: Максимальный размер кэша для каждого ключа
|
||||
**ttl_map: Словарь с ключами и временем ограничения
|
||||
"""
|
||||
# Добавляем ключ по умолчанию в карту TTL, если он указан
|
||||
if default_key:
|
||||
ttl_map[default_key] = default_ttl
|
||||
|
||||
self.default_key = default_key
|
||||
self.caches: dict[str, MutableMapping[int, None]] = {}
|
||||
self.cache_size = cache_size
|
||||
|
||||
# Инициализация кэшей для каждого ключа
|
||||
for name, ttl in ttl_map.items():
|
||||
self.caches[name] = TTLCache(maxsize=self.cache_size, ttl=ttl)
|
||||
|
||||
logger.debug(f"ThrottlingMiddleware initialized with {len(self.caches)} throttling keys")
|
||||
|
||||
def _should_skip_throttling(self, event: Update) -> bool:
|
||||
"""
|
||||
Проверяет, нужно ли пропустить ограничение для данного события.
|
||||
|
||||
Args:
|
||||
event: Событие Telegram
|
||||
|
||||
Returns:
|
||||
True, если ограничение следует пропустить
|
||||
"""
|
||||
# Пропускаем предварительные запросы на оплату
|
||||
if event.pre_checkout_query:
|
||||
return True
|
||||
|
||||
# Пропускаем уведомления об успешной оплате
|
||||
if event.message and event.message.successful_payment:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
# Проверяем, что событие является экземпляром Update
|
||||
if not isinstance(event, Update):
|
||||
logger.debug(f"Skipping throttling for non-Update event: {type(event).__name__}")
|
||||
async def __call__(self, handler, event, data):
|
||||
user_id = event.from_user.id if event.from_user else None
|
||||
if user_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем, нужно ли пропустить ограничение
|
||||
if self._should_skip_throttling(event):
|
||||
logger.debug("Skipping throttling for special event type")
|
||||
return await handler(event, data)
|
||||
current_count = self.cache.get(user_id, 0)
|
||||
|
||||
# Получаем пользователя из данных события
|
||||
user: User | None = data.get("event_from_user")
|
||||
|
||||
if user is None:
|
||||
logger.debug("No user found in event data, proceeding without throttle")
|
||||
return await handler(event, data)
|
||||
|
||||
# Получаем ключ ограничения из флагов или используем ключ по умолчанию
|
||||
key = get_flag(data, "throttling_key", default=self.default_key)
|
||||
|
||||
if not key:
|
||||
logger.debug(f"No throttling key provided for user {user.id}, proceeding without throttle")
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем, находится ли пользователь в кэше (т.е. ограничен)
|
||||
if user.id in self.caches[key]:
|
||||
logger.warning(f"User {user.id} is throttled with key: {key}")
|
||||
if current_count >= 3:
|
||||
if isinstance(event, CallbackQuery) and user_id not in self.throttle_notice_cache:
|
||||
self.throttle_notice_cache[user_id] = None
|
||||
bot: Bot = data["bot"]
|
||||
await bot.answer_callback_query(
|
||||
callback_query_id=event.id,
|
||||
text="Слишком много запросов! Пожалуйста, подождите...",
|
||||
show_alert=False,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
self.cache[user_id] = current_count + 1
|
||||
|
||||
# Добавляем пользователя в кэш
|
||||
self.caches[key][user.id] = None
|
||||
logger.debug(f"User {user.id} allowed to proceed with key: {key}")
|
||||
|
||||
# Продолжаем обработку события
|
||||
return await handler(event, data)
|
||||
|
||||
+1
-8
@@ -21,20 +21,14 @@ class UserMiddleware(BaseMiddleware):
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
try:
|
||||
# Получаем пользователя из данных события
|
||||
if user := data.get("event_from_user"):
|
||||
# Получаем сессию из контекста, если она есть
|
||||
session = data.get("session")
|
||||
# Обрабатываем пользователя и получаем его данные из БД
|
||||
db_user = await self._process_user(user, session)
|
||||
# Добавляем данные пользователя из БД в контекст
|
||||
if db_user:
|
||||
data["user"] = db_user
|
||||
except Exception as e:
|
||||
# Логируем ошибку, но не прерываем обработку события
|
||||
logger.error(f"Ошибка при обработке пользователя: {e}")
|
||||
|
||||
# Продолжаем обработку события в любом случае
|
||||
return await handler(event, data)
|
||||
|
||||
async def _process_user(self, user: User, session: Any = None) -> dict:
|
||||
@@ -49,7 +43,6 @@ class UserMiddleware(BaseMiddleware):
|
||||
dict: Словарь с информацией о пользователе из базы данных
|
||||
"""
|
||||
logger.debug(f"Обработка пользователя: {user.id}")
|
||||
# Получаем данные пользователя из БД после вставки/обновления
|
||||
user_data = await upsert_user(
|
||||
tg_id=user.id,
|
||||
username=user.username,
|
||||
@@ -57,7 +50,7 @@ class UserMiddleware(BaseMiddleware):
|
||||
last_name=user.last_name,
|
||||
language_code=user.language_code,
|
||||
is_bot=user.is_bot,
|
||||
session=session, # Передаем сессию, если она есть
|
||||
session=session,
|
||||
)
|
||||
|
||||
logger.debug(f"Получены данные пользователя из БД: {user.id}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import py3xui
|
||||
|
||||
from config import LIMIT_IP, SUPERNODE
|
||||
@@ -55,6 +56,10 @@ async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, An
|
||||
|
||||
return response if response else {"status": "failed"}
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
logger.error(f"Ошибка при добавлении клиента {config.email}: {e}")
|
||||
return {"status": "failed", "error": "Timeout"}
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
if "Duplicate email" in error_message:
|
||||
@@ -66,7 +71,14 @@ async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, An
|
||||
|
||||
|
||||
async def extend_client_key(
|
||||
xui: py3xui.AsyncApi, inbound_id: int, email: str, new_expiry_time: int, client_id: str, total_gb: int, sub_id: str
|
||||
xui: py3xui.AsyncApi,
|
||||
inbound_id: int,
|
||||
email: str,
|
||||
new_expiry_time: int,
|
||||
client_id: str,
|
||||
total_gb: int,
|
||||
sub_id: str,
|
||||
tg_id: int,
|
||||
) -> bool | None:
|
||||
"""
|
||||
Обновляет срок действия ключа клиента.
|
||||
@@ -88,11 +100,11 @@ async def extend_client_key(
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} не найден")
|
||||
logger.warning(f"Клиент с email {email} не найден.")
|
||||
return None
|
||||
|
||||
if not client.id:
|
||||
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID")
|
||||
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
|
||||
return None
|
||||
|
||||
logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}")
|
||||
@@ -105,12 +117,17 @@ async def extend_client_key(
|
||||
client.enable = True
|
||||
client.limit_ip = LIMIT_IP
|
||||
client.inbound_id = inbound_id
|
||||
client.tg_id = tg_id
|
||||
|
||||
await xui.client.update(client.id, client)
|
||||
await xui.client.reset_stats(inbound_id, email)
|
||||
logger.info(f"Ключ клиента {email} успешно продлён до {new_expiry_time}")
|
||||
return True
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
logger.error(f"Ошибка при обновлении клиента {email}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
|
||||
return False
|
||||
@@ -152,6 +169,10 @@ async def delete_client(
|
||||
logger.info(f"Клиент с ID {client_id} был удален успешно")
|
||||
return True
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
logger.error(f"Ошибка при удалении клиента {email}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
|
||||
return False
|
||||
@@ -179,6 +200,10 @@ async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str,
|
||||
logger.info(f"Трафик для клиента {client_id} успешно получен.")
|
||||
return {"status": "success", "client_id": client_id, "traffic": traffic_data}
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}")
|
||||
return {"status": "error", "error": "Timeout"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -201,26 +226,29 @@ async def toggle_client(xui: py3xui.AsyncApi, inbound_id: int, email: str, clien
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
# Получаем клиента по email
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
|
||||
return False
|
||||
|
||||
# Обновляем параметры клиента
|
||||
client.sub_id = email
|
||||
client.enable = enable
|
||||
client.id = client_id
|
||||
client.flow = "xtls-rprx-vision"
|
||||
client.limit_ip = LIMIT_IP
|
||||
client.inbound_id = inbound_id
|
||||
|
||||
# Обновляем клиента
|
||||
await xui.client.update(client.id, client)
|
||||
status = "включен" if enable else "отключен"
|
||||
logger.info(f"Клиент с email {email} и ID {client_id} успешно {status}.")
|
||||
return True
|
||||
|
||||
except httpx.ConnectTimeout as e:
|
||||
status = "включении" if enable else "отключении"
|
||||
logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
status = "включении" if enable else "отключении"
|
||||
logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}")
|
||||
+30
-1
@@ -44,13 +44,16 @@ dependencies = [
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py312"
|
||||
fix = true
|
||||
unsafe-fixes = true
|
||||
preview = true
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "ANN", "ASYNC", "S", "BLE", "FBT", "B", "A", "C4", "DTZ", "T10", "ISC", "ICN", "G", "PIE"]
|
||||
ignore = ["ANN101", "ANN102", "S101",'ANN201','ANN001','BLE001','W291','ANN401','DTZ003','DTZ005','F401','FBT002','FBT001','FBT003','A005','E501','UP017','DTZ004','W293','ANN202','DTZ007']
|
||||
ignore = ["ANN101", "ANN102", "S101", "ANN201", "ANN001", "BLE001", "W291", "ANN401", "DTZ003", "DTZ005", "F401", "FBT002", "FBT001", "FBT003", "A005", "E501", "UP017", "DTZ004", "W293", "ANN202", "DTZ007"]
|
||||
exclude = [
|
||||
".git",
|
||||
"venv",
|
||||
@@ -61,6 +64,32 @@ exclude = [
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
docstring-code-format = true
|
||||
docstring-code-line-length = 88
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
case-sensitive = true
|
||||
combine-as-imports = true
|
||||
force-wrap-aliases = true
|
||||
known-first-party = ["Solo_bot"]
|
||||
lines-after-imports = 2
|
||||
lines-between-types = 1
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
docstring-quotes = "double"
|
||||
inline-quotes = "double"
|
||||
multiline-quotes = "double"
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.pycodestyle]
|
||||
max-doc-length = 120
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 10
|
||||
|
||||
[tool.darker]
|
||||
src = ["."]
|
||||
|
||||
@@ -42,3 +42,7 @@ urllib3==2.2.3
|
||||
wrapt==1.16.0
|
||||
yarl==1.15.5
|
||||
yookassa==3.3.0
|
||||
httpx
|
||||
qrcode
|
||||
pillow
|
||||
rich
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user