ORM update and lots of improvements
This commit is contained in:
+2
-1
@@ -7,6 +7,7 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
alembic/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
@@ -28,6 +29,7 @@ env/
|
||||
# Sensitive configuration files
|
||||
config.py
|
||||
config.ini
|
||||
alembic.ini
|
||||
.env
|
||||
|
||||
# Backup files
|
||||
@@ -51,7 +53,6 @@ Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts
|
||||
models.py
|
||||
Dockerfile
|
||||
.csv
|
||||
/logs
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
@@ -1,297 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
language_code TEXT,
|
||||
is_bot BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
balance REAL NOT NULL DEFAULT 0.0,
|
||||
trial INTEGER NOT NULL DEFAULT 0,
|
||||
source_code TEXT REFERENCES tracking_sources (code)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'balance'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN balance REAL NOT NULL DEFAULT 0.0;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'trial'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN trial INTEGER NOT NULL DEFAULT 0;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'source_code'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN source_code TEXT REFERENCES tracking_sources (code);
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_bans (
|
||||
tg_id BIGINT PRIMARY KEY,
|
||||
banned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
reason TEXT,
|
||||
banned_by BIGINT,
|
||||
until TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tg_id BIGINT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
payment_system TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'success',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keys (
|
||||
tg_id BIGINT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
expiry_time BIGINT NOT NULL,
|
||||
key TEXT,
|
||||
server_id TEXT NOT NULL DEFAULT 'cluster1',
|
||||
notified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
remnawave_link TEXT,
|
||||
is_frozen BOOLEAN DEFAULT FALSE,
|
||||
alias TEXT,
|
||||
PRIMARY KEY (tg_id, client_id)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'keys'
|
||||
AND column_name = 'key'
|
||||
AND is_nullable = 'NO'
|
||||
) THEN
|
||||
ALTER TABLE keys
|
||||
ALTER COLUMN key DROP NOT NULL;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'remnawave_link'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN remnawave_link TEXT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'is_frozen'
|
||||
) 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$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'tariff_id'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id);
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals (
|
||||
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
referrer_tg_id BIGINT NOT NULL,
|
||||
reward_issued BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
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,
|
||||
user_id BIGINT NOT NULL,
|
||||
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (coupon_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
tg_id BIGINT NOT NULL,
|
||||
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notification_type TEXT NOT NULL,
|
||||
PRIMARY KEY (tg_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cluster_name TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
subscription_url TEXT,
|
||||
inbound_id TEXT NOT NULL,
|
||||
panel_type TEXT NOT NULL DEFAULT '3x-ui',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
max_keys INTEGER,
|
||||
tariff_group TEXT,
|
||||
UNIQUE (cluster_name, server_name)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'servers' AND column_name = 'tariff_group'
|
||||
) THEN
|
||||
ALTER TABLE servers ADD COLUMN tariff_group TEXT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'servers' AND column_name = 'panel_type'
|
||||
) THEN
|
||||
ALTER TABLE servers ADD COLUMN panel_type TEXT NOT NULL DEFAULT '3x-ui';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'servers' AND column_name = 'enabled'
|
||||
) THEN
|
||||
ALTER TABLE servers ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'servers' AND column_name = 'max_keys'
|
||||
) THEN
|
||||
ALTER TABLE servers ADD COLUMN max_keys INTEGER;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gifts (
|
||||
gift_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_tg_id BIGINT NOT NULL REFERENCES users (tg_id),
|
||||
selected_months INTEGER NOT NULL,
|
||||
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
gift_link TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recipient_tg_id BIGINT REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temporary_data (
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_users (
|
||||
tg_id BIGINT PRIMARY KEY,
|
||||
blocked_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracking_sources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by BIGINT,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tariffs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
group_code TEXT NOT NULL,
|
||||
duration_days INTEGER NOT NULL CHECK (duration_days > 0),
|
||||
price_rub INTEGER NOT NULL CHECK (price_rub >= 0),
|
||||
traffic_limit BIGINT,
|
||||
device_limit INTEGER,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'tariffs' AND column_name = 'device_limit'
|
||||
) THEN
|
||||
ALTER TABLE tariffs ADD COLUMN device_limit INTEGER;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'tariffs' AND column_name = 'group_code'
|
||||
) THEN
|
||||
ALTER TABLE tariffs ADD COLUMN group_code TEXT NOT NULL DEFAULT 'default';
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN balance TYPE REAL
|
||||
USING ROUND(balance::NUMERIC, 2)::REAL;
|
||||
END$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'connections'
|
||||
) THEN
|
||||
EXECUTE $upd$
|
||||
UPDATE users
|
||||
SET balance = c.balance,
|
||||
trial = c.trial
|
||||
FROM connections c
|
||||
WHERE users.tg_id = c.tg_id;
|
||||
$upd$;
|
||||
|
||||
EXECUTE 'DROP TABLE connections';
|
||||
END IF;
|
||||
END$$;
|
||||
@@ -1,11 +1,9 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
from bot import bot
|
||||
@@ -149,7 +147,9 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
|
||||
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
await bot.send_document(chat_id=admin_id, document=backup_input_file)
|
||||
await bot.send_document(
|
||||
chat_id=admin_id, document=backup_input_file
|
||||
)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
|
||||
|
||||
@@ -12,27 +12,19 @@ from aiogram.utils.markdown import hbold
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
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.3-b190508"
|
||||
|
||||
|
||||
register_middleware(dp)
|
||||
version = "4.3-b240504 (ORM update)"
|
||||
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
dp.callback_query.filter(IsPrivateFilter())
|
||||
|
||||
|
||||
@dp.errors(ExceptionTypeFilter(Exception))
|
||||
async def errors_handler(
|
||||
event: ErrorEvent,
|
||||
bot: Bot,
|
||||
) -> bool:
|
||||
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
|
||||
if isinstance(event.exception, TelegramForbiddenError):
|
||||
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
|
||||
return True
|
||||
@@ -41,30 +33,51 @@ async def errors_handler(
|
||||
error_message = str(event.exception)
|
||||
|
||||
if (
|
||||
"query is too old and response timeout expired or query ID is invalid" in error_message
|
||||
"query is too old and response timeout expired or query ID is invalid"
|
||||
in error_message
|
||||
or "message can't be deleted for everyone" in error_message
|
||||
or "message to delete not found" in error_message
|
||||
):
|
||||
logger.warning("Отправляем стартовое меню.")
|
||||
|
||||
try:
|
||||
from handlers.start import handle_start_callback_query, start_command
|
||||
|
||||
if event.update.message:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.message.chat.id,
|
||||
user_id=event.update.message.from_user.id,
|
||||
)
|
||||
await start_command(
|
||||
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
|
||||
event.update.message,
|
||||
state=fsm_context,
|
||||
session=None,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
elif event.update.callback_query:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.callback_query.message.chat.id,
|
||||
user_id=event.update.callback_query.from_user.id,
|
||||
)
|
||||
await handle_start_callback_query(
|
||||
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
|
||||
event.update.callback_query,
|
||||
state=fsm_context,
|
||||
session=None,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
|
||||
|
||||
return True
|
||||
|
||||
logger.exception(f"Update: {event.update}\nException: {event.exception}")
|
||||
|
||||
if not ADMIN_ID:
|
||||
return True
|
||||
|
||||
try:
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_document(
|
||||
@@ -75,19 +88,39 @@ async def errors_handler(
|
||||
),
|
||||
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
|
||||
)
|
||||
try:
|
||||
from handlers.start import handle_start_callback_query, start_command
|
||||
|
||||
if event.update.message:
|
||||
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
|
||||
elif event.update.callback_query:
|
||||
await handle_start_callback_query(
|
||||
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
|
||||
from handlers.start import handle_start_callback_query, start_command
|
||||
|
||||
if event.update.message:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.message.chat.id,
|
||||
user_id=event.update.message.from_user.id,
|
||||
)
|
||||
await start_command(
|
||||
event.update.message,
|
||||
state=fsm_context,
|
||||
session=None,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
elif event.update.callback_query:
|
||||
fsm_context = dp.fsm.get_context(
|
||||
bot=bot,
|
||||
chat_id=event.update.callback_query.message.chat.id,
|
||||
user_id=event.update.callback_query.from_user.id,
|
||||
)
|
||||
await handle_start_callback_query(
|
||||
event.update.callback_query,
|
||||
state=fsm_context,
|
||||
session=None,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
|
||||
except TelegramBadRequest as exception:
|
||||
logger.warning(f"Failed to send error details: {exception}")
|
||||
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
|
||||
except Exception as exception:
|
||||
logger.error(f"Unexpected error in error handler: {exception}")
|
||||
logger.error(f"Неожиданная ошибка в error handler: {exception}")
|
||||
|
||||
return True
|
||||
|
||||
+88
-28
@@ -4,7 +4,6 @@ import subprocess
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
@@ -12,7 +11,6 @@ from rich.text import Text
|
||||
|
||||
from config import BOT_SERVICE
|
||||
|
||||
|
||||
try:
|
||||
sys.stdin.reconfigure(encoding="utf-8")
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
@@ -32,9 +30,13 @@ IS_ROOT_DIR = PROJECT_DIR == "/root"
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
|
||||
console.print(
|
||||
"[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]"
|
||||
)
|
||||
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
|
||||
console.print("[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]")
|
||||
console.print(
|
||||
"[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]"
|
||||
)
|
||||
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
|
||||
sys.exit(1)
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
@@ -42,7 +44,9 @@ SERVICE_NAME = BOT_SERVICE
|
||||
|
||||
|
||||
def is_service_exists(service_name):
|
||||
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
["systemctl", "list-unit-files", service_name], capture_output=True, text=True
|
||||
)
|
||||
return service_name in result.stdout
|
||||
|
||||
|
||||
@@ -75,7 +79,9 @@ def fix_permissions():
|
||||
try:
|
||||
user = os.getenv("SUDO_USER") or os.getenv("USER")
|
||||
if user:
|
||||
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
|
||||
subprocess.run(
|
||||
["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True
|
||||
)
|
||||
|
||||
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
|
||||
|
||||
@@ -135,11 +141,15 @@ def install_dependencies():
|
||||
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
|
||||
try:
|
||||
if not os.path.exists("venv"):
|
||||
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
|
||||
console.print(
|
||||
"[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]"
|
||||
)
|
||||
subprocess.run("python3 -m venv venv", shell=True, check=True)
|
||||
|
||||
subprocess.run(
|
||||
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True
|
||||
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
|
||||
@@ -186,21 +196,29 @@ def update_from_beta():
|
||||
remote_version = get_remote_version(branch="dev")
|
||||
|
||||
if local_version and remote_version:
|
||||
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
|
||||
console.print(
|
||||
f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]"
|
||||
)
|
||||
if local_version == remote_version:
|
||||
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||
if not Confirm.ask(
|
||||
"[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"
|
||||
):
|
||||
return
|
||||
else:
|
||||
console.print("[red]⚠️ Не удалось определить версии.[/red]")
|
||||
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
if not Confirm.ask(
|
||||
"[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"
|
||||
):
|
||||
return
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_buttons = Confirm.ask(
|
||||
"[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False
|
||||
)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
@@ -231,7 +249,9 @@ def update_from_beta():
|
||||
|
||||
def update_from_release():
|
||||
update_cli_launcher()
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
|
||||
if not Confirm.ask(
|
||||
"[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"
|
||||
):
|
||||
return
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
|
||||
@@ -239,14 +259,18 @@ def update_from_release():
|
||||
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_buttons = Confirm.ask(
|
||||
"[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False
|
||||
)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
install_rsync_if_needed()
|
||||
|
||||
try:
|
||||
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
|
||||
response = requests.get(
|
||||
"https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10
|
||||
)
|
||||
releases = response.json()[:3]
|
||||
tag_choices = [r["tag_name"] for r in releases]
|
||||
|
||||
@@ -258,16 +282,25 @@ def update_from_release():
|
||||
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)]
|
||||
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
|
||||
)
|
||||
tag_name = tag_choices[int(selected) - 1]
|
||||
|
||||
if not Confirm.ask(f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"):
|
||||
if not Confirm.ask(
|
||||
f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"
|
||||
):
|
||||
return
|
||||
|
||||
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
|
||||
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"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
|
||||
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||
@@ -277,7 +310,9 @@ def update_from_release():
|
||||
if not update_buttons:
|
||||
exclude_options += " --exclude=handlers/buttons.py"
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
subprocess.run(
|
||||
f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True
|
||||
)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
fix_permissions()
|
||||
@@ -292,7 +327,9 @@ def update_from_release():
|
||||
def show_update_menu():
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
|
||||
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
|
||||
console.print(
|
||||
"[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
table = Table(title="Выберите способ обновления", title_style="bold green")
|
||||
@@ -312,7 +349,9 @@ def show_update_menu():
|
||||
|
||||
|
||||
def show_menu():
|
||||
table = Table(title="Solobot CLI v0.1.7", title_style="bold magenta", header_style="bold blue")
|
||||
table = Table(
|
||||
title="Solobot CLI v0.1.7", title_style="bold magenta", header_style="bold blue"
|
||||
)
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Операция", style="white")
|
||||
table.add_row("1", "Запустить бота (systemd)")
|
||||
@@ -335,7 +374,9 @@ def update_cli_launcher():
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8") as f:
|
||||
with open(
|
||||
os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(response.text)
|
||||
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
|
||||
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
|
||||
@@ -351,30 +392,49 @@ def main():
|
||||
try:
|
||||
while True:
|
||||
show_menu()
|
||||
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 10)])
|
||||
choice = Prompt.ask(
|
||||
"[bold blue]Введите номер действия[/bold blue]",
|
||||
choices=[str(i) for i in range(1, 10)],
|
||||
)
|
||||
if choice == "1":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "2":
|
||||
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
|
||||
if Confirm.ask(
|
||||
"[green]Вы действительно хотите запустить main.py вручную?[/green]"
|
||||
):
|
||||
subprocess.run(["venv/bin/python", "main.py"])
|
||||
elif choice == "3":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
|
||||
if Confirm.ask(
|
||||
"[yellow]Вы действительно хотите перезапустить бота?[/yellow]"
|
||||
):
|
||||
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "4":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
|
||||
if Confirm.ask(
|
||||
"[red]Вы уверены, что хотите остановить бота?[/red]"
|
||||
):
|
||||
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "5":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "journalctl", "-u", SERVICE_NAME, "-n", "80", "--no-pager"])
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"journalctl",
|
||||
"-u",
|
||||
SERVICE_NAME,
|
||||
"-n",
|
||||
"80",
|
||||
"--no-pager",
|
||||
]
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "6":
|
||||
|
||||
-1635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
from .bans import *
|
||||
from .coupons import *
|
||||
from .db import async_session_maker
|
||||
from .gifts import *
|
||||
from .hot_leads import *
|
||||
from .init_db import *
|
||||
from .keys import *
|
||||
from .notifications import *
|
||||
from .payments import *
|
||||
from .referrals import *
|
||||
from .servers import *
|
||||
from .statistics import *
|
||||
from .tariffs import *
|
||||
from .temporary_data import *
|
||||
from .tracking_sources import *
|
||||
from .users import *
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import BlockedUser
|
||||
|
||||
|
||||
async def create_blocked_user(session: AsyncSession, tg_id: int):
|
||||
stmt = (
|
||||
insert(BlockedUser)
|
||||
.values(tg_id=tg_id)
|
||||
.on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,121 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import case, delete, func, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Coupon, CouponUsage
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_coupon(
|
||||
session: AsyncSession, code: str, amount: int, usage_limit: int, days: int = None
|
||||
) -> bool:
|
||||
try:
|
||||
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
|
||||
if exists:
|
||||
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
|
||||
return False
|
||||
|
||||
await session.execute(
|
||||
insert(Coupon).values(
|
||||
code=code,
|
||||
amount=amount,
|
||||
usage_limit=usage_limit,
|
||||
usage_count=0,
|
||||
is_used=False,
|
||||
days=days,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"[Coupon] ✅ Купон {code} успешно создан.")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
logger.error(f"[Coupon] ❌ Ошибка при создании купона {code}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_coupon_by_code(session: AsyncSession, code: str) -> Coupon | None:
|
||||
stmt = select(Coupon).where(Coupon.code == code)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_coupons(
|
||||
session: AsyncSession, page: int = 1, per_page: int = 10
|
||||
) -> dict:
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
stmt = select(Coupon).order_by(Coupon.id.desc()).offset(offset).limit(per_page)
|
||||
result = await session.execute(stmt)
|
||||
coupons = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count()).select_from(Coupon)
|
||||
total = await session.scalar(count_stmt)
|
||||
pages = -(-total // per_page)
|
||||
|
||||
return {
|
||||
"coupons": [c.to_dict() for c in coupons],
|
||||
"total": total,
|
||||
"pages": pages,
|
||||
"current_page": page,
|
||||
}
|
||||
|
||||
|
||||
async def delete_coupon(session: AsyncSession, code: str) -> bool:
|
||||
result = await session.execute(select(Coupon).where(Coupon.code == code))
|
||||
coupon = result.scalar_one_or_none()
|
||||
|
||||
if not coupon:
|
||||
logger.info(f"❌ Купон {code} не найден")
|
||||
return False
|
||||
|
||||
await session.execute(delete(CouponUsage).where(CouponUsage.coupon_id == coupon.id))
|
||||
|
||||
await session.delete(coupon)
|
||||
await session.commit()
|
||||
logger.info(f"🗑 Купон {code} удалён вместе с его использованиями")
|
||||
return True
|
||||
|
||||
|
||||
async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int):
|
||||
try:
|
||||
stmt = insert(CouponUsage).values(
|
||||
coupon_id=coupon_id, user_id=user_id, used_at=datetime.utcnow()
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"✅ Купон {coupon_id} использован пользователем {user_id}")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении использования купона: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def check_coupon_usage(
|
||||
session: AsyncSession, coupon_id: int, user_id: int
|
||||
) -> bool:
|
||||
stmt = select(CouponUsage).where(
|
||||
CouponUsage.coupon_id == coupon_id, CouponUsage.user_id == user_id
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
|
||||
try:
|
||||
await session.execute(
|
||||
update(Coupon)
|
||||
.where(Coupon.id == coupon_id)
|
||||
.values(
|
||||
usage_count=Coupon.usage_count + 1,
|
||||
is_used=case(
|
||||
(Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False
|
||||
),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"🔁 Обновлён счётчик купона {coupon_id}")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
|
||||
await session.rollback()
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
from config import DATABASE_URL
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||
|
||||
async_session_maker = async_sessionmaker(
|
||||
bind=engine, expire_on_commit=False, class_=AsyncSession
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Gift
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def store_gift_link(
|
||||
session: AsyncSession,
|
||||
gift_id: str,
|
||||
sender_tg_id: int,
|
||||
selected_months: int,
|
||||
expiry_time: datetime,
|
||||
gift_link: str,
|
||||
tariff_id: int | None = None,
|
||||
):
|
||||
try:
|
||||
stmt = insert(Gift).values(
|
||||
gift_id=gift_id,
|
||||
sender_tg_id=sender_tg_id,
|
||||
recipient_tg_id=None,
|
||||
selected_months=selected_months,
|
||||
expiry_time=expiry_time,
|
||||
gift_link=gift_link,
|
||||
created_at=datetime.utcnow(),
|
||||
is_used=False,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id})")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment
|
||||
|
||||
|
||||
async def get_hot_leads(session: AsyncSession):
|
||||
"""
|
||||
Возвращает пользователей, у которых есть успешные оплаты, но нет активных ключей.
|
||||
"""
|
||||
subquery = (
|
||||
select(Key.tg_id)
|
||||
.where(Key.expiry_time > func.extract("epoch", func.now()) * 1000)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(Payment.tg_id)
|
||||
.distinct()
|
||||
.where(Payment.amount > 0)
|
||||
.where(~Payment.tg_id.in_(subquery))
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return [row.tg_id for row in result]
|
||||
@@ -0,0 +1,7 @@
|
||||
from database.db import engine
|
||||
from database.models import Base
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,187 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, func, select, text, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def store_key(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
client_id: str,
|
||||
email: str,
|
||||
expiry_time: int,
|
||||
key: str,
|
||||
server_id: str,
|
||||
remnawave_link: str = None,
|
||||
tariff_id: int = None,
|
||||
):
|
||||
try:
|
||||
exists = await session.execute(
|
||||
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||
)
|
||||
if exists.scalar_one_or_none():
|
||||
logger.info(
|
||||
f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}"
|
||||
)
|
||||
return
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
created_at=int(datetime.utcnow().timestamp() * 1000),
|
||||
expiry_time=expiry_time,
|
||||
key=key,
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
session.add(new_key)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}"
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def get_keys(session: AsyncSession, tg_id: int):
|
||||
result = await session.execute(select(Key).where(Key.tg_id == tg_id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_all_keys(session: AsyncSession):
|
||||
result = await session.execute(select(Key))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
|
||||
stmt = select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||
stmt = (
|
||||
select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
key, user = row
|
||||
expiry_date = datetime.utcfromtimestamp(key.expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>Ключ истек.</b>"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||
|
||||
return {
|
||||
"key": key.key,
|
||||
"remnawave_link": key.remnawave_link,
|
||||
"server_id": key.server_id,
|
||||
"created_at": key.created_at,
|
||||
"expiry_time": key.expiry_time,
|
||||
"client_id": key.client_id,
|
||||
"tg_id": user.tg_id,
|
||||
"email": key.email,
|
||||
"is_frozen": key.is_frozen,
|
||||
"balance": user.balance,
|
||||
"alias": key.alias,
|
||||
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
|
||||
"days_left_message": days_left_message,
|
||||
"link": key.key or key.remnawave_link,
|
||||
"cluster_name": key.server_id,
|
||||
"location_name": key.server_id,
|
||||
"tariff_id": key.tariff_id,
|
||||
}
|
||||
|
||||
|
||||
async def get_key_count(session: AsyncSession, tg_id: int) -> int:
|
||||
result = await session.execute(
|
||||
select(func.count()).select_from(Key).where(Key.tg_id == tg_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def delete_key(session: AsyncSession, identifier: int | str):
|
||||
stmt = delete(Key).where(
|
||||
Key.tg_id == identifier
|
||||
if str(identifier).isdigit()
|
||||
else Key.client_id == identifier
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"Ключ с идентификатором {identifier} удалён")
|
||||
|
||||
|
||||
async def update_key_expiry(
|
||||
session: AsyncSession, client_id: str, new_expiry_time: int
|
||||
):
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.client_id == client_id)
|
||||
.values(expiry_time=new_expiry_time, notified=False, notified_24h=False)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
|
||||
|
||||
|
||||
async def get_client_id_by_email(session: AsyncSession, email: str):
|
||||
result = await session.execute(select(Key.client_id).where(Key.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||
.values(notified=True)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def mark_key_as_frozen(
|
||||
session: AsyncSession, tg_id: int, client_id: str, time_left: int
|
||||
):
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = :expiry,
|
||||
is_frozen = TRUE
|
||||
WHERE tg_id = :tg_id
|
||||
AND client_id = :client_id
|
||||
"""
|
||||
),
|
||||
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
|
||||
|
||||
async def mark_key_as_unfrozen(
|
||||
session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int
|
||||
):
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = :expiry,
|
||||
is_frozen = FALSE
|
||||
WHERE tg_id = :tg_id
|
||||
AND client_id = :client_id
|
||||
"""
|
||||
),
|
||||
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class DictLikeMixin:
|
||||
def __getitem__(self, key):
|
||||
return getattr(self, key)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return getattr(self, key, default)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
column.name: getattr(self, column.name) for column in self.__table__.columns
|
||||
}
|
||||
|
||||
|
||||
class User(DictLikeMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
username = Column(String)
|
||||
first_name = Column(String)
|
||||
last_name = Column(String)
|
||||
language_code = Column(String)
|
||||
is_bot = Column(Boolean, default=False)
|
||||
balance = Column(Float, default=0.0)
|
||||
trial = Column(Integer, default=0)
|
||||
source_code = Column(String, ForeignKey("tracking_sources.code"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Key(DictLikeMixin, Base):
|
||||
__tablename__ = "keys"
|
||||
|
||||
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=False)
|
||||
client_id = Column(String, primary_key=True)
|
||||
email = Column(String, unique=True)
|
||||
created_at = Column(BigInteger)
|
||||
expiry_time = Column(BigInteger)
|
||||
key = Column(String)
|
||||
server_id = Column(String)
|
||||
remnawave_link = Column(String)
|
||||
tariff_id = Column(Integer, ForeignKey("tariffs.id"))
|
||||
is_frozen = Column(Boolean, default=False)
|
||||
alias = Column(String)
|
||||
notified = Column(Boolean, default=False)
|
||||
notified_24h = Column(Boolean, default=False)
|
||||
|
||||
|
||||
class Tariff(DictLikeMixin, Base):
|
||||
__tablename__ = "tariffs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String)
|
||||
group_code = Column(String)
|
||||
duration_days = Column(Integer)
|
||||
price_rub = Column(Integer)
|
||||
traffic_limit = Column(BigInteger, nullable=True)
|
||||
device_limit = Column(Integer, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Server(DictLikeMixin, Base):
|
||||
__tablename__ = "servers"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
cluster_name = Column(String)
|
||||
server_name = Column(String, unique=True)
|
||||
api_url = Column(String)
|
||||
subscription_url = Column(String)
|
||||
inbound_id = Column(String)
|
||||
panel_type = Column(String)
|
||||
max_keys = Column(Integer)
|
||||
tariff_group = Column(String)
|
||||
enabled = Column(Boolean, default=True)
|
||||
|
||||
|
||||
class Payment(DictLikeMixin, Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
|
||||
amount = Column(Float)
|
||||
payment_system = Column(String)
|
||||
status = Column(String)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Coupon(DictLikeMixin, Base):
|
||||
__tablename__ = "coupons"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
code = Column(String, unique=True)
|
||||
amount = Column(Integer)
|
||||
usage_limit = Column(Integer)
|
||||
usage_count = Column(Integer, default=0)
|
||||
is_used = Column(Boolean, default=False)
|
||||
days = Column(Integer, nullable=True)
|
||||
|
||||
|
||||
class CouponUsage(DictLikeMixin, Base):
|
||||
__tablename__ = "coupon_usages"
|
||||
|
||||
coupon_id = Column(Integer, ForeignKey("coupons.id"), primary_key=True)
|
||||
user_id = Column(BigInteger, primary_key=True)
|
||||
used_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Referral(DictLikeMixin, Base):
|
||||
__tablename__ = "referrals"
|
||||
|
||||
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||
reward_issued = Column(Boolean, default=False)
|
||||
|
||||
|
||||
class Notification(DictLikeMixin, Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||
notification_type = Column(String, primary_key=True)
|
||||
last_notification_time = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Gift(DictLikeMixin, Base):
|
||||
__tablename__ = "gifts"
|
||||
|
||||
gift_id = Column(String, primary_key=True)
|
||||
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
|
||||
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
|
||||
selected_months = Column(Integer)
|
||||
expiry_time = Column(DateTime)
|
||||
gift_link = Column(String)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
is_used = Column(Boolean, default=False)
|
||||
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
|
||||
|
||||
|
||||
class ManualBan(DictLikeMixin, Base):
|
||||
__tablename__ = "manual_bans"
|
||||
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
banned_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
reason = Column(Text)
|
||||
banned_by = Column(BigInteger)
|
||||
until = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class TemporaryData(DictLikeMixin, Base):
|
||||
__tablename__ = "temporary_data"
|
||||
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
state = Column(String)
|
||||
data = Column(JSON)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class BlockedUser(DictLikeMixin, Base):
|
||||
__tablename__ = "blocked_users"
|
||||
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
|
||||
|
||||
class TrackingSource(DictLikeMixin, Base):
|
||||
__tablename__ = "tracking_sources"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String)
|
||||
code = Column(String, unique=True)
|
||||
type = Column(String)
|
||||
created_by = Column(BigInteger)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Log(DictLikeMixin, Base):
|
||||
__tablename__ = "logs"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
event_type = Column(String)
|
||||
tg_id = Column(BigInteger)
|
||||
details = Column(JSON)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,136 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Notification, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def add_notification(session: AsyncSession, tg_id: int, notification_type: str):
|
||||
try:
|
||||
stmt = (
|
||||
insert(Notification)
|
||||
.values(
|
||||
tg_id=tg_id,
|
||||
notification_type=notification_type,
|
||||
last_notification_time=datetime.utcnow(),
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[Notification.tg_id, Notification.notification_type],
|
||||
set_={"last_notification_time": datetime.utcnow()},
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"✅ Добавлено уведомление {notification_type} для пользователя {tg_id}"
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при добавлении уведомления: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def delete_notification(
|
||||
session: AsyncSession, tg_id: int, notification_type: str
|
||||
):
|
||||
await session.execute(
|
||||
delete(Notification).where(
|
||||
Notification.tg_id == tg_id,
|
||||
Notification.notification_type == notification_type,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
|
||||
|
||||
|
||||
async def check_notification_time(
|
||||
session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12
|
||||
) -> bool:
|
||||
stmt = select(Notification.last_notification_time).where(
|
||||
Notification.tg_id == tg_id, Notification.notification_type == notification_type
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
last_time = result.scalar_one_or_none()
|
||||
if not last_time:
|
||||
return True
|
||||
return datetime.utcnow() - last_time > timedelta(hours=hours)
|
||||
|
||||
|
||||
async def get_last_notification_time(
|
||||
session: AsyncSession, tg_id: int, notification_type: str
|
||||
) -> int | None:
|
||||
stmt = select(Notification.last_notification_time).where(
|
||||
Notification.tg_id == tg_id, Notification.notification_type == notification_type
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
ts = result.scalar_one_or_none()
|
||||
if ts:
|
||||
return int(ts.timestamp() * 1000)
|
||||
return None
|
||||
|
||||
|
||||
async def check_notifications_bulk(
|
||||
session: AsyncSession,
|
||||
notification_type: str,
|
||||
hours: int,
|
||||
tg_ids: list[int] = None,
|
||||
emails: list[str] = None,
|
||||
) -> list[dict]:
|
||||
try:
|
||||
stmt = (
|
||||
select(
|
||||
User.tg_id,
|
||||
Key.email,
|
||||
User.username,
|
||||
User.first_name,
|
||||
User.last_name,
|
||||
func.max(Notification.last_notification_time).label(
|
||||
"last_notification_time"
|
||||
),
|
||||
)
|
||||
.outerjoin(Key, User.tg_id == Key.tg_id)
|
||||
.outerjoin(
|
||||
Notification,
|
||||
and_(
|
||||
User.tg_id == Notification.tg_id,
|
||||
Notification.notification_type == notification_type,
|
||||
),
|
||||
)
|
||||
.group_by(
|
||||
User.tg_id, Key.email, User.username, User.first_name, User.last_name
|
||||
)
|
||||
)
|
||||
|
||||
if tg_ids:
|
||||
stmt = stmt.where(User.tg_id.in_(tg_ids))
|
||||
if emails:
|
||||
stmt = stmt.where(Key.email.in_(emails))
|
||||
|
||||
result = await session.execute(stmt)
|
||||
now = datetime.utcnow()
|
||||
users = []
|
||||
|
||||
for row in result:
|
||||
last_time = row.last_notification_time
|
||||
can_notify = not last_time or (now - last_time > timedelta(hours=hours))
|
||||
if can_notify:
|
||||
users.append(
|
||||
{
|
||||
"tg_id": row.tg_id,
|
||||
"email": row.email,
|
||||
"username": row.username,
|
||||
"first_name": row.first_name,
|
||||
"last_name": row.last_name,
|
||||
"last_notification_time": (
|
||||
int(last_time.timestamp() * 1000) if last_time else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return users
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при массовой проверке уведомлений: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import insert, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Payment
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def add_payment(
|
||||
session: AsyncSession, tg_id: int, amount: float, payment_system: str
|
||||
):
|
||||
try:
|
||||
stmt = insert(Payment).values(
|
||||
tg_id=tg_id,
|
||||
amount=amount,
|
||||
payment_system=payment_system,
|
||||
status="success",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}"
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при добавлении платежа: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_last_payments(session: AsyncSession, tg_id: int, limit: int = 3):
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.tg_id == tg_id)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
payments = result.scalars().all()
|
||||
logger.info(
|
||||
f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}"
|
||||
)
|
||||
return [dict(p.__dict__) for p in payments]
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при получении платежей пользователя {tg_id}: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,276 @@
|
||||
from sqlalchemy import and_, desc, func, insert, select, text, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
|
||||
from database.models import Referral
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def add_referral(session: AsyncSession, referred_tg_id: int, referrer_tg_id: int):
|
||||
try:
|
||||
if referred_tg_id == referrer_tg_id:
|
||||
logger.warning(f"⚠️ Попытка самореферала: {referred_tg_id}")
|
||||
return
|
||||
|
||||
stmt = insert(Referral).values(
|
||||
referred_tg_id=referred_tg_id, referrer_tg_id=referrer_tg_id
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"✅ Добавлена реферальная связь: {referred_tg_id} → {referrer_tg_id}"
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при добавлении реферала: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_referral_by_referred_id(
|
||||
session: AsyncSession, referred_tg_id: int
|
||||
) -> dict | None:
|
||||
stmt = select(Referral).where(Referral.referred_tg_id == referred_tg_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
return dict(row.__dict__) if row else None
|
||||
|
||||
|
||||
async def get_total_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Referral)
|
||||
.where(Referral.referrer_tg_id == referrer_tg_id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_active_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Referral)
|
||||
.where(
|
||||
and_(
|
||||
Referral.referrer_tg_id == referrer_tg_id,
|
||||
Referral.reward_issued is True,
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int):
|
||||
await session.execute(
|
||||
update(Referral)
|
||||
.where(Referral.referred_tg_id == referred_tg_id)
|
||||
.values(reward_issued=True)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_total_referral_bonus(
|
||||
session: AsyncSession, referrer_tg_id: int, max_levels: int
|
||||
) -> float:
|
||||
if CHECK_REFERRAL_REWARD_ISSUED:
|
||||
bonus_cte = """
|
||||
WITH RECURSIVE
|
||||
referral_levels AS (
|
||||
SELECT
|
||||
referred_tg_id,
|
||||
referrer_tg_id,
|
||||
1 AS level
|
||||
FROM referrals
|
||||
WHERE referrer_tg_id = :tg_id AND reward_issued = TRUE
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
r.referred_tg_id,
|
||||
r.referrer_tg_id,
|
||||
rl.level + 1
|
||||
FROM referrals r
|
||||
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||
WHERE rl.level < :max_levels AND r.reward_issued = TRUE
|
||||
),
|
||||
earliest_payments AS (
|
||||
SELECT DISTINCT ON (tg_id) tg_id, amount, created_at
|
||||
FROM payments
|
||||
WHERE status = 'success'
|
||||
ORDER BY tg_id, created_at
|
||||
)
|
||||
"""
|
||||
bonus_query = (
|
||||
bonus_cte
|
||||
+ f"""
|
||||
SELECT
|
||||
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
|
||||
])
|
||||
}
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS total_bonus
|
||||
FROM referral_levels rl
|
||||
JOIN earliest_payments ep ON rl.referred_tg_id = ep.tg_id
|
||||
WHERE rl.level <= :max_levels
|
||||
"""
|
||||
)
|
||||
else:
|
||||
bonus_cte = """
|
||||
WITH RECURSIVE
|
||||
referral_levels AS (
|
||||
SELECT
|
||||
referred_tg_id,
|
||||
referrer_tg_id,
|
||||
1 AS level
|
||||
FROM referrals
|
||||
WHERE referrer_tg_id = :tg_id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
r.referred_tg_id,
|
||||
r.referrer_tg_id,
|
||||
rl.level + 1
|
||||
FROM referrals r
|
||||
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||
WHERE rl.level < :max_levels
|
||||
)
|
||||
"""
|
||||
bonus_query = (
|
||||
bonus_cte
|
||||
+ f"""
|
||||
SELECT
|
||||
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
|
||||
])
|
||||
}
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS total_bonus
|
||||
FROM referral_levels rl
|
||||
JOIN payments p ON rl.referred_tg_id = p.tg_id
|
||||
WHERE p.status = 'success' AND rl.level <= :max_levels
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels}
|
||||
)
|
||||
total_bonus = result.scalar() or 0.0
|
||||
logger.debug(f"Получена общая сумма бонусов от рефералов: {total_bonus}")
|
||||
return total_bonus
|
||||
|
||||
|
||||
async def get_referrals_by_level(
|
||||
session: AsyncSession, referrer_tg_id: int, max_levels: int
|
||||
) -> dict:
|
||||
query = """
|
||||
WITH RECURSIVE referral_levels AS (
|
||||
SELECT referred_tg_id, referrer_tg_id, 1 AS level
|
||||
FROM referrals
|
||||
WHERE referrer_tg_id = :referrer_tg_id
|
||||
UNION
|
||||
SELECT r.referred_tg_id, r.referrer_tg_id, rl.level + 1
|
||||
FROM referrals r
|
||||
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||
WHERE rl.level < :max_levels
|
||||
)
|
||||
SELECT level,
|
||||
COUNT(*) AS level_count,
|
||||
COUNT(CASE WHEN reward_issued THEN 1 END) AS active_level_count
|
||||
FROM referral_levels rl
|
||||
JOIN referrals r ON rl.referred_tg_id = r.referred_tg_id
|
||||
GROUP BY level
|
||||
ORDER BY level
|
||||
"""
|
||||
result = await session.execute(
|
||||
text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels}
|
||||
)
|
||||
return {
|
||||
row["level"]: {
|
||||
"total": row["level_count"],
|
||||
"active": row["active_level_count"],
|
||||
}
|
||||
for row in result.mappings()
|
||||
}
|
||||
|
||||
|
||||
async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
|
||||
try:
|
||||
logger.info(
|
||||
f"[ReferralStats] Получение статистики для пользователя {referrer_tg_id}"
|
||||
)
|
||||
|
||||
total_referrals = await get_total_referrals(session, referrer_tg_id)
|
||||
active_referrals = await get_active_referrals(session, referrer_tg_id)
|
||||
max_levels = len(REFERRAL_BONUS_PERCENTAGES)
|
||||
referrals_by_level = await get_referrals_by_level(
|
||||
session, referrer_tg_id, max_levels
|
||||
)
|
||||
total_referral_bonus = await get_total_referral_bonus(
|
||||
session, referrer_tg_id, max_levels
|
||||
)
|
||||
|
||||
return {
|
||||
"total_referrals": total_referrals,
|
||||
"active_referrals": active_referrals,
|
||||
"referrals_by_level": referrals_by_level,
|
||||
"total_referral_bonus": total_referral_bonus,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def get_user_referral_count(session: AsyncSession, tg_id: int) -> int:
|
||||
result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Referral)
|
||||
.where(Referral.referrer_tg_id == tg_id)
|
||||
)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def get_referral_position(session: AsyncSession, referral_count: int) -> int:
|
||||
subq = (
|
||||
select(Referral.referrer_tg_id)
|
||||
.group_by(Referral.referrer_tg_id)
|
||||
.having(func.count() > referral_count)
|
||||
.subquery()
|
||||
)
|
||||
query = select(func.count()).select_from(subq)
|
||||
result = await session.execute(query)
|
||||
count = result.scalar() or 0
|
||||
return count + 1
|
||||
|
||||
|
||||
async def get_top_referrals(session: AsyncSession, limit: int = 5):
|
||||
query = (
|
||||
select(Referral.referrer_tg_id, func.count().label("referral_count"))
|
||||
.group_by(Referral.referrer_tg_id)
|
||||
.order_by(desc("referral_count"))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return [
|
||||
{"referrer_tg_id": row.referrer_tg_id, "referral_count": row.referral_count}
|
||||
for row in result.all()
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
from sqlalchemy import delete, insert, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Server
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_server(
|
||||
session: AsyncSession,
|
||||
cluster_name: str,
|
||||
server_name: str,
|
||||
api_url: str,
|
||||
subscription_url: str,
|
||||
inbound_id: str,
|
||||
):
|
||||
try:
|
||||
stmt = insert(Server).values(
|
||||
cluster_name=cluster_name,
|
||||
server_name=server_name,
|
||||
api_url=api_url,
|
||||
subscription_url=subscription_url,
|
||||
inbound_id=inbound_id,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"✅ Сервер {server_name} добавлен в кластер {cluster_name}")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при добавлении сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def delete_server(session: AsyncSession, server_name: str):
|
||||
try:
|
||||
stmt = delete(Server).where(Server.server_name == server_name)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"🗑 Сервер {server_name} удалён")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при удалении сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_servers(session: AsyncSession, include_enabled: bool = False) -> dict:
|
||||
try:
|
||||
stmt = select(Server)
|
||||
result = await session.execute(stmt)
|
||||
servers = result.scalars().all()
|
||||
|
||||
grouped = {}
|
||||
for s in servers:
|
||||
if not include_enabled and not s.enabled:
|
||||
continue
|
||||
cluster = s.cluster_name
|
||||
grouped.setdefault(cluster, []).append(
|
||||
{
|
||||
"server_name": s.server_name,
|
||||
"api_url": s.api_url,
|
||||
"subscription_url": s.subscription_url,
|
||||
"inbound_id": s.inbound_id,
|
||||
"panel_type": s.panel_type,
|
||||
"enabled": s.enabled,
|
||||
"max_keys": s.max_keys,
|
||||
"tariff_group": s.tariff_group,
|
||||
"cluster_name": cluster,
|
||||
}
|
||||
)
|
||||
|
||||
return grouped
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении серверов: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
async def get_clusters(session: AsyncSession) -> list[str]:
|
||||
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
|
||||
result = await session.execute(stmt)
|
||||
return [r[0] for r in result.all()]
|
||||
|
||||
|
||||
async def check_unique_server_name(
|
||||
session: AsyncSession, server_name: str, cluster_name: str | None = None
|
||||
) -> bool:
|
||||
stmt = select(Server).where(Server.server_name == server_name)
|
||||
if cluster_name:
|
||||
stmt = stmt.where(Server.cluster_name == cluster_name)
|
||||
result = await session.execute(stmt.limit(1))
|
||||
return result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
async def check_server_name_by_cluster(
|
||||
session: AsyncSession, server_name: str
|
||||
) -> dict | None:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Server.cluster_name).where(Server.server_name == server_name)
|
||||
)
|
||||
row = result.first()
|
||||
return {"cluster_name": row[0]} if row else None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске кластера для сервера {server_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_cluster_name_by_server(
|
||||
session: AsyncSession, server_id_or_name: str
|
||||
) -> str | None:
|
||||
stmt = (
|
||||
select(Server.cluster_name)
|
||||
.where(
|
||||
(Server.id == server_id_or_name) | (Server.server_name == server_id_or_name)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
return row
|
||||
@@ -0,0 +1,98 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import and_, func, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment, Referral, Tariff, User
|
||||
|
||||
|
||||
async def count_total_users(session: AsyncSession) -> int:
|
||||
return await session.scalar(select(func.count()).select_from(User))
|
||||
|
||||
|
||||
async def count_users_updated_today(session: AsyncSession, today: date) -> int:
|
||||
return await session.scalar(
|
||||
select(func.count()).select_from(User).where(User.updated_at >= today)
|
||||
)
|
||||
|
||||
|
||||
async def count_users_registered_since(session: AsyncSession, since: date) -> int:
|
||||
return await session.scalar(
|
||||
select(func.count()).select_from(User).where(User.created_at >= since)
|
||||
)
|
||||
|
||||
|
||||
async def count_users_registered_between(
|
||||
session: AsyncSession, start: date, end: date
|
||||
) -> int:
|
||||
return await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.created_at >= start, User.created_at < end)
|
||||
)
|
||||
|
||||
|
||||
async def count_total_keys(session: AsyncSession) -> int:
|
||||
return await session.scalar(select(func.count()).select_from(Key))
|
||||
|
||||
|
||||
async def count_active_keys(session: AsyncSession) -> int:
|
||||
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
return await session.scalar(
|
||||
select(func.count()).select_from(Key).where(Key.expiry_time > current_time_ms)
|
||||
)
|
||||
|
||||
|
||||
async def count_trial_keys(session: AsyncSession) -> int:
|
||||
subquery_success_payments = (
|
||||
select(Payment.tg_id)
|
||||
.where(and_(Payment.tg_id == Key.tg_id, Payment.status == "success"))
|
||||
.exists()
|
||||
)
|
||||
|
||||
return await session.scalar(
|
||||
select(func.count()).select_from(Key).where(not_(subquery_success_payments))
|
||||
)
|
||||
|
||||
|
||||
async def get_tariff_distribution(session: AsyncSession) -> list[tuple[int, int]]:
|
||||
result = await session.execute(
|
||||
select(Key.tariff_id, func.count(Key.client_id)).group_by(Key.tariff_id)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_tariff_names(
|
||||
session: AsyncSession, tariff_ids: list[int]
|
||||
) -> dict[int, str]:
|
||||
if not tariff_ids:
|
||||
return {}
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff.id, Tariff.name).where(Tariff.id.in_(tariff_ids))
|
||||
)
|
||||
return dict(result.all())
|
||||
|
||||
|
||||
async def count_total_referrals(session: AsyncSession) -> int:
|
||||
return await session.scalar(select(func.count()).select_from(Referral))
|
||||
|
||||
|
||||
async def sum_payments_since(session: AsyncSession, since: date) -> float:
|
||||
return await session.scalar(
|
||||
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||
Payment.created_at >= since
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def sum_payments_between(session: AsyncSession, start: date, end: date) -> float:
|
||||
return await session.scalar(
|
||||
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||
Payment.created_at >= start, Payment.created_at < end
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def sum_total_payments(session: AsyncSession) -> float:
|
||||
return await session.scalar(select(func.coalesce(func.sum(Payment.amount), 0)))
|
||||
@@ -0,0 +1,104 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Server, Tariff
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def get_tariffs(
|
||||
session: AsyncSession, tariff_id: int = None, group_code: str = None
|
||||
):
|
||||
try:
|
||||
if tariff_id:
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
elif group_code:
|
||||
result = await session.execute(
|
||||
select(Tariff).where(Tariff.group_code == group_code)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(select(Tariff))
|
||||
|
||||
return [dict(r.__dict__) for r in result.scalars().all()]
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
|
||||
try:
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
return dict(tariff.__dict__) if tariff else None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении тарифа по ID {tariff_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str):
|
||||
try:
|
||||
server_row = await session.execute(
|
||||
select(Server.tariff_group)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.limit(1)
|
||||
)
|
||||
row = server_row.first()
|
||||
if not row or not row[0]:
|
||||
return []
|
||||
|
||||
group_code = row[0]
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.id)
|
||||
)
|
||||
return [dict(r.__dict__) for r in result.scalars().all()]
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(
|
||||
f"[TARIFF] Ошибка при получении тарифов для кластера {cluster_name}: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
async def create_tariff(session: AsyncSession, data: dict):
|
||||
try:
|
||||
data["created_at"] = datetime.utcnow()
|
||||
data["updated_at"] = datetime.utcnow()
|
||||
|
||||
stmt = insert(Tariff).values(**data).returning(Tariff)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.scalar_one()
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при создании тарифа: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
async def update_tariff(session: AsyncSession, tariff_id: int, updates: dict):
|
||||
if not updates:
|
||||
return False
|
||||
try:
|
||||
updates["updated_at"] = datetime.utcnow()
|
||||
await session.execute(
|
||||
update(Tariff).where(Tariff.id == tariff_id).values(**updates)
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при обновлении тарифа ID={tariff_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def delete_tariff(session: AsyncSession, tariff_id: int):
|
||||
try:
|
||||
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при удалении тарифа ID={tariff_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import TemporaryData
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_temporary_data(
|
||||
session: AsyncSession, tg_id: int, state: str, data: dict
|
||||
):
|
||||
try:
|
||||
stmt = (
|
||||
insert(TemporaryData)
|
||||
.values(tg_id=tg_id, state=state, data=data, updated_at=datetime.utcnow())
|
||||
.on_conflict_do_update(
|
||||
index_elements=[TemporaryData.tg_id],
|
||||
set_={"state": state, "data": data, "updated_at": datetime.utcnow()},
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"📝 Временные данные сохранены для {tg_id}")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении временных данных для {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def get_temporary_data(session: AsyncSession, tg_id: int) -> dict | None:
|
||||
stmt = select(TemporaryData).where(TemporaryData.tg_id == tg_id)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
return {"state": row.state, "data": row.data}
|
||||
return None
|
||||
|
||||
|
||||
async def clear_temporary_data(session: AsyncSession, tg_id: int):
|
||||
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
|
||||
await session.commit()
|
||||
logger.info(f"🗑 Временные данные очищены для {tg_id}")
|
||||
@@ -0,0 +1,126 @@
|
||||
from sqlalchemy import func, insert, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Payment, TrackingSource, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_tracking_source(
|
||||
session: AsyncSession, name: str, code: str, type_: str, created_by: int
|
||||
):
|
||||
try:
|
||||
stmt = insert(TrackingSource).values(
|
||||
name=name,
|
||||
code=code,
|
||||
type=type_,
|
||||
created_by=created_by,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"🆕 Источник трафика {code} создан")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при создании источника {code}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
|
||||
registrations_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
.where(User.source_code == TrackingSource.code)
|
||||
.correlate(TrackingSource)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
trials_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
.where((User.source_code == TrackingSource.code) & (User.trial == 1))
|
||||
.correlate(TrackingSource)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
payments_subq = (
|
||||
select(func.count(func.distinct(Payment.tg_id)))
|
||||
.join(User, Payment.tg_id == User.tg_id)
|
||||
.where(
|
||||
(User.source_code == TrackingSource.code) & (Payment.status == "success")
|
||||
)
|
||||
.correlate(TrackingSource)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
query = select(
|
||||
TrackingSource.code,
|
||||
TrackingSource.name,
|
||||
TrackingSource.created_at,
|
||||
registrations_subq.label("registrations"),
|
||||
trials_subq.label("trials"),
|
||||
payments_subq.label("payments"),
|
||||
).order_by(TrackingSource.created_at.desc())
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"code": r.code,
|
||||
"name": r.name,
|
||||
"created_at": r.created_at,
|
||||
"registrations": r.registrations or 0,
|
||||
"trials": r.trials or 0,
|
||||
"payments": r.payments or 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict | None:
|
||||
reg_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
.where(User.source_code == code)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
trial_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
.where((User.source_code == code) & (User.trial == 1))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
payments_subq = (
|
||||
select(func.count(func.distinct(Payment.tg_id)))
|
||||
.join(User, Payment.tg_id == User.tg_id)
|
||||
.where((User.source_code == code) & (Payment.status == "success"))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
amount_subq = (
|
||||
select(func.coalesce(func.sum(Payment.amount), 0))
|
||||
.join(User, Payment.tg_id == User.tg_id)
|
||||
.where((User.source_code == code) & (Payment.status == "success"))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
query = select(
|
||||
TrackingSource.name,
|
||||
TrackingSource.code,
|
||||
TrackingSource.created_at,
|
||||
reg_subq.label("registrations"),
|
||||
trial_subq.label("trials"),
|
||||
payments_subq.label("payments"),
|
||||
amount_subq.label("total_amount"),
|
||||
).where(TrackingSource.code == code)
|
||||
|
||||
result = await session.execute(query)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": row.name,
|
||||
"code": row.code,
|
||||
"created_at": row.created_at,
|
||||
"registrations": row.registrations or 0,
|
||||
"trials": row.trials or 0,
|
||||
"payments": row.payments or 0,
|
||||
"total_amount": float(row.total_amount or 0),
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, exists, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.keys import delete_key
|
||||
from database.models import (
|
||||
BlockedUser,
|
||||
CouponUsage,
|
||||
Gift,
|
||||
Notification,
|
||||
Payment,
|
||||
Referral,
|
||||
TemporaryData,
|
||||
User,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def add_user(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language_code: str = None,
|
||||
is_bot: bool = False,
|
||||
source_code: str = None,
|
||||
):
|
||||
try:
|
||||
stmt = (
|
||||
insert(User)
|
||||
.values(
|
||||
tg_id=tg_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
is_bot=is_bot,
|
||||
source_code=source_code,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[User.tg_id])
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})"
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
|
||||
try:
|
||||
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
|
||||
current = result.scalar_one_or_none() or 0
|
||||
new_balance = current + amount
|
||||
await session.execute(
|
||||
update(User).where(User.tg_id == tg_id).values(balance=new_balance)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[DB] Баланс пользователя {tg_id} обновлён: {current} → {new_balance}"
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
|
||||
stmt = select(exists().where(User.tg_id == tg_id))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_balance(session: AsyncSession, tg_id: int) -> float:
|
||||
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
|
||||
balance = result.scalar_one_or_none()
|
||||
return round(balance, 1) if balance is not None else 0.0
|
||||
|
||||
|
||||
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
|
||||
try:
|
||||
await session.execute(
|
||||
update(User).where(User.tg_id == tg_id).values(balance=balance)
|
||||
)
|
||||
await session.commit()
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def update_trial(session: AsyncSession, tg_id: int, status: int):
|
||||
try:
|
||||
await session.execute(
|
||||
update(User).where(User.tg_id == tg_id).values(trial=status)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
async def get_trial(session: AsyncSession, tg_id: int) -> int:
|
||||
result = await session.execute(select(User.trial).where(User.tg_id == tg_id))
|
||||
trial = result.scalar_one_or_none()
|
||||
return trial or 0
|
||||
|
||||
|
||||
async def upsert_user(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language_code: str = None,
|
||||
is_bot: bool = False,
|
||||
only_if_exists: bool = False,
|
||||
) -> dict | None:
|
||||
try:
|
||||
if only_if_exists:
|
||||
result = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
await session.execute(
|
||||
update(User)
|
||||
.where(User.tg_id == tg_id)
|
||||
.values(
|
||||
username=username or user.username,
|
||||
first_name=first_name or user.first_name,
|
||||
last_name=last_name or user.last_name,
|
||||
language_code=language_code or user.language_code,
|
||||
is_bot=is_bot,
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
insert(User)
|
||||
.values(
|
||||
tg_id=tg_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
is_bot=is_bot,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[User.tg_id],
|
||||
set_={
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"language_code": language_code,
|
||||
"is_bot": is_bot,
|
||||
"updated_at": datetime.utcnow(),
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
result = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
return dict(result.scalar_one().__dict__)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def delete_user_data(session: AsyncSession, tg_id: int):
|
||||
try:
|
||||
await session.execute(delete(Notification).where(Notification.tg_id == tg_id))
|
||||
await session.execute(delete(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
await session.execute(
|
||||
update(Gift)
|
||||
.where(Gift.recipient_tg_id == tg_id)
|
||||
.values(recipient_tg_id=None)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.tg_id == tg_id))
|
||||
await session.execute(
|
||||
delete(Referral).where(
|
||||
or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(CouponUsage).where(CouponUsage.user_id == tg_id))
|
||||
await delete_key(session, tg_id)
|
||||
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
|
||||
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
|
||||
await session.execute(delete(User).where(User.tg_id == tg_id))
|
||||
await session.commit()
|
||||
logger.info(f"[DB] Данные пользователя {tg_id} полностью удалены")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def mark_trial_extended(tg_id: int, session: AsyncSession):
|
||||
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
|
||||
await session.commit()
|
||||
@@ -15,7 +15,6 @@ from .profile import router as profile_router
|
||||
from .refferal import router as refferal_router
|
||||
from .start import router as start_router
|
||||
|
||||
|
||||
router = Router(name="handlers_main_router")
|
||||
|
||||
router.include_routers(
|
||||
|
||||
@@ -16,7 +16,6 @@ from .stats import router as stats_router
|
||||
from .tariffs import router as tariffs_router
|
||||
from .users import router as users_router
|
||||
|
||||
|
||||
router = Router(name="admins_main_router")
|
||||
|
||||
router.include_routers(
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import USERNAME_BOT
|
||||
from database import (
|
||||
create_tracking_source,
|
||||
get_all_tracking_sources,
|
||||
get_tracking_source_stats,
|
||||
)
|
||||
from database import create_tracking_source, get_tracking_source_stats
|
||||
from database.models import TrackingSource, User
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
@@ -23,7 +21,6 @@ from .keyboard import (
|
||||
build_cancel_input_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -34,7 +31,9 @@ class AdminAdsState(StatesGroup):
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
|
||||
async def handle_ads_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
|
||||
await callback_query.message.edit_text(
|
||||
text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
|
||||
@@ -51,12 +50,15 @@ async def handle_ads_name_input(message: Message, state: FSMContext):
|
||||
await state.update_data(name=name)
|
||||
await state.set_state(AdminAdsState.waiting_for_new_code)
|
||||
await message.answer(
|
||||
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.", reply_markup=build_cancel_input_kb()
|
||||
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.",
|
||||
reply_markup=build_cancel_input_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
|
||||
async def handle_ads_code_input(message: Message, state: FSMContext, session):
|
||||
async def handle_ads_code_input(
|
||||
message: Message, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
code = message.text.strip()
|
||||
data = await state.get_data()
|
||||
name = data["name"]
|
||||
@@ -64,9 +66,13 @@ async def handle_ads_code_input(message: Message, state: FSMContext, session):
|
||||
|
||||
try:
|
||||
await create_tracking_source(
|
||||
name=name, code=code_with_prefix, type_="utm", created_by=message.from_user.id, session=session
|
||||
name=name,
|
||||
code=code_with_prefix,
|
||||
type_="utm",
|
||||
created_by=message.from_user.id,
|
||||
session=session,
|
||||
)
|
||||
stats = await get_tracking_source_stats(code_with_prefix, session)
|
||||
stats = await get_tracking_source_stats(session, code_with_prefix)
|
||||
if not stats:
|
||||
await message.answer("❌ Источник не найден или не содержит данных.")
|
||||
return
|
||||
@@ -77,44 +83,59 @@ async def handle_ads_code_input(message: Message, state: FSMContext, session):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ссылки: {e}")
|
||||
logger.error(f"Ошибка при создании ссылки: {e}", exc_info=True)
|
||||
await message.answer("❌ Произошла ошибка при создании ссылки.")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
|
||||
async def handle_ads_list(callback_query: CallbackQuery, session):
|
||||
async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession):
|
||||
try:
|
||||
ads = await get_all_tracking_sources(session)
|
||||
result = await session.execute(
|
||||
select(TrackingSource).order_by(TrackingSource.created_at.desc())
|
||||
)
|
||||
ads = result.scalars().all()
|
||||
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
|
||||
await callback_query.message.edit_text(
|
||||
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка UTM: {e}")
|
||||
await callback_query.message.edit_text("Произошла ошибка при получении списка.")
|
||||
logger.error(f"Ошибка при получении списка UTM: {e}", exc_info=True)
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Произошла ошибка при получении списка."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
|
||||
async def handle_ads_view(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
|
||||
async def handle_ads_view(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminAdsCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
code = callback_data.code
|
||||
try:
|
||||
stats = await get_tracking_source_stats(code, session)
|
||||
stats = await get_tracking_source_stats(session, code)
|
||||
if not stats:
|
||||
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Источник не найден или не содержит данных."
|
||||
)
|
||||
return
|
||||
msg = format_ads_stats(stats, USERNAME_BOT)
|
||||
await callback_query.message.edit_text(
|
||||
text=msg, reply_markup=build_ads_stats_kb(code), parse_mode=ParseMode.HTML
|
||||
text=msg, reply_markup=build_ads_stats_kb(code)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при просмотре статистики: {e}")
|
||||
logger.error(f"Ошибка при просмотре статистики: {e}", exc_info=True)
|
||||
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
|
||||
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
|
||||
@router.callback_query(
|
||||
AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter()
|
||||
)
|
||||
async def handle_ads_delete_confirm(
|
||||
callback_query: CallbackQuery, callback_data: AdminAdsCallback
|
||||
):
|
||||
code = callback_data.code
|
||||
await callback_query.message.edit_text(
|
||||
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
|
||||
@@ -123,17 +144,37 @@ async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
|
||||
async def handle_ads_delete(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
|
||||
async def handle_ads_delete(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminAdsCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
code = callback_data.code
|
||||
try:
|
||||
await session.execute("UPDATE users SET source_code = NULL WHERE source_code = $1", code)
|
||||
await session.execute("DELETE FROM tracking_sources WHERE code = $1", code)
|
||||
await callback_query.message.edit_text(f"🗑️ Ссылка <code>{code}</code> удалена.", reply_markup=build_ads_kb())
|
||||
await session.execute(
|
||||
update(User).where(User.source_code == code).values(source_code=None)
|
||||
)
|
||||
await session.execute(delete(TrackingSource).where(TrackingSource.code == code))
|
||||
await session.commit()
|
||||
await callback_query.message.edit_text(
|
||||
f"🗑️ Ссылка <code>{code}</code> удалена.",
|
||||
reply_markup=build_ads_kb(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
|
||||
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter()
|
||||
)
|
||||
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback_query.message.edit_text(
|
||||
text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb()
|
||||
)
|
||||
|
||||
|
||||
def format_ads_stats(stats: dict, username_bot: str) -> str:
|
||||
return (
|
||||
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
|
||||
@@ -142,14 +183,9 @@ def format_ads_stats(stats: dict, username_bot: str) -> str:
|
||||
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💡 <b>Активность:</b>\n"
|
||||
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
|
||||
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n"
|
||||
f"\n💰 <b>Финансовая информация:</b>\n"
|
||||
f"└ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n\n"
|
||||
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n\n"
|
||||
f"💰 <b>Финансовая информация:</b>\n"
|
||||
f"├ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n"
|
||||
f"└ 💸 <b>Сумма:</b> <b>{round(stats.get('total_amount', 0), 2)} ₽</b>\n\n"
|
||||
f"<i>Просмотр статистики и управление рекламными ссылками</i>."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
|
||||
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
|
||||
|
||||
@@ -12,14 +12,20 @@ class AdminAdsCallback(CallbackData, prefix="admin_ads"):
|
||||
|
||||
def build_ads_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="➕ Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
|
||||
builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
|
||||
builder.button(
|
||||
text="➕ Новая ссылка", callback_data=AdminAdsCallback(action="create").pack()
|
||||
)
|
||||
builder.button(
|
||||
text="📊 Список", callback_data=AdminAdsCallback(action="list").pack()
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
|
||||
def build_ads_list_kb(
|
||||
ads: list, current_page: int, total_pages: int
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for ad in ads:
|
||||
@@ -33,14 +39,18 @@ def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineK
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
|
||||
callback_data=AdminAdsCallback(
|
||||
action="list", code=f"{current_page - 1}"
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
if current_page < total_pages:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
|
||||
callback_data=AdminAdsCallback(
|
||||
action="list", code=f"{current_page + 1}"
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
@@ -52,7 +62,10 @@ def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineK
|
||||
|
||||
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🗑️ Удалить", callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack())
|
||||
builder.button(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn("ads"))
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -66,12 +79,18 @@ def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
|
||||
code=code,
|
||||
).pack(),
|
||||
)
|
||||
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="view", code=code).pack())
|
||||
builder.button(
|
||||
text="❌ Отмена",
|
||||
callback_data=AdminAdsCallback(action="view", code=code).pack(),
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_cancel_input_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="cancel_input", code="none").pack())
|
||||
builder.button(
|
||||
text="❌ Отмена",
|
||||
callback_data=AdminAdsCallback(action="cancel_input", code="none").pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -6,7 +6,6 @@ from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -18,14 +17,19 @@ async def handle_backups(callback_query: CallbackQuery):
|
||||
kb = build_admin_back_kb("management")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
|
||||
text="💾 Инициализация резервного копирования базы данных...",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
exception = await backup_database()
|
||||
try:
|
||||
exception = await backup_database()
|
||||
|
||||
if exception:
|
||||
text = f"❌ Ошибка при создании резервной копии: {exception}"
|
||||
else:
|
||||
text = "✅ Резервная копия успешно создана и отправлена администраторам."
|
||||
if exception:
|
||||
text = f"❌ Ошибка при создании резервной копии:\n<code>{exception}</code>"
|
||||
else:
|
||||
text = "✅ Резервная копия успешно создана и отправлена администраторам."
|
||||
|
||||
except Exception as e:
|
||||
text = f"❌ Непредвиденная ошибка: <code>{e}</code>"
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb)
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery
|
||||
|
||||
from database import delete_user_data
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_bans_kb
|
||||
import csv
|
||||
import io
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery
|
||||
from sqlalchemy import delete, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import delete_user_data
|
||||
from database.models import ManualBan
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_bans_kb
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter())
|
||||
async def handle_bans(callback_query: CallbackQuery):
|
||||
text = (
|
||||
text_ = (
|
||||
"🚫 <b>Управление банами</b>\n\n"
|
||||
"📛 <b>Забанившие бота</b> — пользователи, которые заблокировали бота вручную.\n"
|
||||
"🔒 <b>Ручной бан</b> — пользователи, которых вы забанили через админку.\n\n"
|
||||
"⬇ Выберите нужный раздел:"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_bans_kb(),
|
||||
)
|
||||
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "bans_export"),
|
||||
IsAdminFilter(),
|
||||
AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter()
|
||||
)
|
||||
async def handle_bans_export(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_admin_back_kb("management")
|
||||
|
||||
try:
|
||||
banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
|
||||
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
|
||||
banned_users = result.all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id", "blocked_at"])
|
||||
writer.writerow(["tg_id"])
|
||||
|
||||
for user in banned_users:
|
||||
writer.writerow([user["tg_id"], user["blocked_at"]])
|
||||
writer.writerow([user.tg_id])
|
||||
|
||||
csv_output.seek(0)
|
||||
|
||||
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
|
||||
document = BufferedInputFile(
|
||||
file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv"
|
||||
)
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
document=document,
|
||||
caption="📥 Экспорт пользователей, заблокировавших бота в CSV",
|
||||
caption="📥 Экспорт пользователей, заблокировавших бота (CSV)",
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer(
|
||||
@@ -63,15 +61,16 @@ async def handle_bans_export(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "bans_delete_banned"),
|
||||
IsAdminFilter(),
|
||||
AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter()
|
||||
)
|
||||
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_bans_delete_banned(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
kb = build_admin_back_kb("bans")
|
||||
|
||||
try:
|
||||
blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
|
||||
blocked_ids = [record["tg_id"] for record in blocked_users]
|
||||
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
|
||||
blocked_users = result.all()
|
||||
blocked_ids = [user.tg_id for user in blocked_users]
|
||||
|
||||
if not blocked_ids:
|
||||
await callback_query.message.answer(
|
||||
@@ -83,7 +82,11 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
|
||||
for tg_id in blocked_ids:
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
await session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids)
|
||||
await session.execute(
|
||||
text("DELETE FROM blocked_users WHERE tg_id = ANY(:blocked_ids)"),
|
||||
{"blocked_ids": blocked_ids},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
|
||||
@@ -96,23 +99,30 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter())
|
||||
async def handle_manual_bans_export(callback_query: CallbackQuery, session: Any):
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter()
|
||||
)
|
||||
async def handle_manual_bans_export(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
build_admin_back_kb("bans")
|
||||
try:
|
||||
rows = await session.fetch("SELECT tg_id, banned_at, reason, until FROM manual_bans")
|
||||
|
||||
import csv
|
||||
import io
|
||||
result = await session.execute(
|
||||
text("SELECT tg_id, banned_at, reason, until FROM manual_bans")
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id", "banned_at", "reason", "until"])
|
||||
|
||||
for user in rows:
|
||||
writer.writerow([user["tg_id"], user["banned_at"], user["reason"], user["until"]])
|
||||
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
|
||||
|
||||
csv_output.seek(0)
|
||||
|
||||
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv")
|
||||
document = BufferedInputFile(
|
||||
file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv"
|
||||
)
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
document=document,
|
||||
@@ -125,10 +135,15 @@ async def handle_manual_bans_export(callback_query: CallbackQuery, session: Any)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter())
|
||||
async def handle_delete_manual_banned(callback_query: CallbackQuery, session):
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter()
|
||||
)
|
||||
async def handle_delete_manual_banned(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
try:
|
||||
await session.execute("DELETE FROM manual_bans")
|
||||
await session.execute(delete(ManualBan))
|
||||
await session.commit()
|
||||
await callback_query.message.edit_text(
|
||||
"🗑️ Вручную забаненные пользователи удалены.",
|
||||
reply_markup=build_bans_kb(),
|
||||
@@ -136,4 +151,6 @@ async def handle_delete_manual_banned(callback_query: CallbackQuery, session):
|
||||
logger.info("[BANS] Очищены записи из manual_bans")
|
||||
except Exception as e:
|
||||
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
|
||||
await callback_query.message.edit_text("❌ Ошибка при удалении вручную забаненных пользователей.")
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Ошибка при удалении вручную забаненных пользователей."
|
||||
)
|
||||
|
||||
@@ -7,11 +7,25 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
def build_bans_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text="📛 Забанившие бота", callback_data=AdminPanelCallback(action="bans_export").pack())
|
||||
builder.button(text="📛 Забаненные вручную", callback_data=AdminPanelCallback(action="manual_bans_export").pack())
|
||||
builder.button(text="🗑️ Удалить забанивших", callback_data=AdminPanelCallback(action="bans_delete_banned").pack())
|
||||
builder.button(text="🗑️ Очистить вручную забаненных", callback_data=AdminPanelCallback(action="bans_delete_manual").pack())
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack())
|
||||
builder.button(
|
||||
text="📛 Забанившие бота",
|
||||
callback_data=AdminPanelCallback(action="bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📛 Забаненные вручную",
|
||||
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Удалить забанивших",
|
||||
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Очистить вручную забаненных",
|
||||
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack()
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime
|
||||
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 sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backup import create_backup_and_send_to_admins
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
DATABASE_URL,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import check_unique_server_name, get_servers, update_key_expiry
|
||||
from database.models import Key, Server, Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import (
|
||||
create_client_on_server,
|
||||
@@ -43,7 +42,6 @@ from .keyboard import (
|
||||
build_tariff_group_selection_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -64,8 +62,8 @@ class AdminClusterStates(StatesGroup):
|
||||
AdminPanelCallback.filter(F.action == "clusters"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_servers(callback_query: CallbackQuery):
|
||||
servers = await get_servers()
|
||||
async def handle_servers(callback_query: CallbackQuery, session: AsyncSession):
|
||||
servers = await get_servers(session)
|
||||
|
||||
text = (
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
@@ -91,7 +89,9 @@ async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
|
||||
"<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 callback_query.message.edit_text(
|
||||
text=text, reply_markup=build_admin_back_kb("clusters")
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
|
||||
|
||||
@@ -100,7 +100,8 @@ async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
|
||||
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")
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -132,7 +133,8 @@ async def handle_cluster_name_input(message: Message, state: FSMContext):
|
||||
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")
|
||||
text="❌ Имя сервера не может быть пустым. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -148,7 +150,7 @@ async def handle_server_name_input(message: Message, state: FSMContext, session:
|
||||
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):
|
||||
if not await check_unique_server_name(session, server_name, cluster_name):
|
||||
await message.answer(
|
||||
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
@@ -228,9 +230,15 @@ async def handle_inbound_id_input(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])), IsAdminFilter())
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_panel_type_selection(
|
||||
callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
|
||||
|
||||
@@ -241,27 +249,24 @@ async def handle_panel_type_selection(
|
||||
subscription_url = user_data.get("subscription_url")
|
||||
inbound_id = user_data.get("inbound_id")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
tariff_group = await conn.fetchval(
|
||||
"SELECT tariff_group FROM servers WHERE cluster_name = $1 LIMIT 1",
|
||||
cluster_name,
|
||||
result = await session.execute(
|
||||
select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
tariff_group = row[0] if row else None
|
||||
|
||||
new_server = Server(
|
||||
cluster_name=cluster_name,
|
||||
server_name=server_name,
|
||||
api_url=api_url,
|
||||
subscription_url=subscription_url,
|
||||
inbound_id=inbound_id,
|
||||
panel_type=panel_type,
|
||||
tariff_group=tariff_group,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id, panel_type, tariff_group)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
""",
|
||||
cluster_name,
|
||||
server_name,
|
||||
api_url,
|
||||
subscription_url,
|
||||
inbound_id,
|
||||
panel_type,
|
||||
tariff_group,
|
||||
)
|
||||
|
||||
await conn.close()
|
||||
session.add(new_server)
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
|
||||
@@ -270,22 +275,39 @@ async def handle_panel_type_selection(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter()
|
||||
)
|
||||
async def handle_clusters_manage(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
row = await session.fetchrow(
|
||||
"SELECT tariff_group FROM servers WHERE cluster_name = $1 AND tariff_group IS NOT NULL LIMIT 1",
|
||||
cluster_name,
|
||||
result = await session.execute(
|
||||
select(Server.tariff_group)
|
||||
.where(Server.cluster_name == cluster_name, Server.tariff_group.isnot(None))
|
||||
.limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
tariff_group = row[0] if row else "—"
|
||||
|
||||
tariff_group = row["tariff_group"] if row else "—"
|
||||
result = await session.execute(
|
||||
select(Server.server_name).where(Server.cluster_name == cluster_name)
|
||||
)
|
||||
server_names = [row[0] for row in result.all()]
|
||||
result = await session.execute(
|
||||
select(func.count(func.distinct(Key.tg_id))).where(
|
||||
(Key.server_id == cluster_name) | (Key.server_id.in_(server_names))
|
||||
)
|
||||
)
|
||||
user_count = result.scalar() or 0
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Управление кластером <code>{cluster_name}</code></b>\n\n"
|
||||
f"📁 <b>Тарифная группа:</b> <code>{tariff_group}</code>\n"
|
||||
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
@@ -295,9 +317,9 @@ async def handle_clusters_manage(
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
|
||||
async def handle_cluster_servers(callback: CallbackQuery):
|
||||
async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession):
|
||||
cluster_name = callback.data.split("|", 1)[1]
|
||||
servers = await get_servers()
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -306,16 +328,22 @@ async def handle_cluster_servers(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter()
|
||||
)
|
||||
async def handle_cluster_availability(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
|
||||
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}' не содержит серверов.")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"Кластер '{cluster_name}' не содержит серверов."
|
||||
)
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
@@ -335,7 +363,12 @@ async def handle_cluster_availability(
|
||||
|
||||
try:
|
||||
if panel_type == "3x-ui":
|
||||
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=None,
|
||||
)
|
||||
await xui.login()
|
||||
inbound_id = int(server["inbound_id"])
|
||||
online_clients = await xui.client.online()
|
||||
@@ -374,19 +407,27 @@ async def handle_cluster_availability(
|
||||
|
||||
online_remna_users = matching_node.get("usersOnline", 0)
|
||||
total_online_users += online_remna_users
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
|
||||
result_text += (
|
||||
f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_text = str(e) or "Сервер недоступен"
|
||||
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
|
||||
|
||||
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
|
||||
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
|
||||
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())
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter()
|
||||
)
|
||||
async def handle_clusters_backup(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
@@ -394,6 +435,9 @@ async def handle_clusters_backup(
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
for server in cluster_servers:
|
||||
if server.get("panel_type") == "remnawave":
|
||||
continue
|
||||
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
@@ -404,7 +448,7 @@ async def handle_clusters_backup(
|
||||
|
||||
text = (
|
||||
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей (3x-ui).</i>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
@@ -414,7 +458,11 @@ async def handle_clusters_backup(
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
|
||||
async def handle_sync(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
|
||||
async def handle_sync(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
@@ -426,18 +474,33 @@ async def handle_sync(callback_query: types.CallbackQuery, callback_data: AdminC
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
|
||||
async def handle_sync_server(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter()
|
||||
)
|
||||
async def handle_sync_server(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
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)
|
||||
stmt = (
|
||||
select(
|
||||
Server.api_url,
|
||||
Server.inbound_id,
|
||||
Server.server_name,
|
||||
Server.panel_type,
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
)
|
||||
.join(Key, Server.cluster_name == Key.server_id)
|
||||
.where(Server.server_name == server_name)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
keys_to_sync = result.mappings().all()
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
@@ -446,15 +509,16 @@ async def handle_sync_server(callback_query: types.CallbackQuery, callback_data:
|
||||
)
|
||||
return
|
||||
|
||||
text = f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
text=f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
if key["panel_type"] == "remnawave":
|
||||
continue
|
||||
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": key["api_url"],
|
||||
@@ -469,7 +533,9 @@ async def handle_sync_server(callback_query: types.CallbackQuery, callback_data:
|
||||
)
|
||||
await asyncio.sleep(0.6)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}")
|
||||
logger.error(
|
||||
f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
|
||||
@@ -478,21 +544,33 @@ async def handle_sync_server(callback_query: types.CallbackQuery, callback_data:
|
||||
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")
|
||||
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):
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter()
|
||||
)
|
||||
async def handle_sync_cluster(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
try:
|
||||
query_keys = """
|
||||
SELECT tg_id, client_id, email, expiry_time, remnawave_link, tariff_id
|
||||
FROM keys
|
||||
WHERE server_id = $1
|
||||
"""
|
||||
keys_to_sync = await session.fetch(query_keys, cluster_name)
|
||||
result = await session.execute(
|
||||
select(
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.remnawave_link,
|
||||
Key.tariff_id,
|
||||
).where(Key.server_id == cluster_name)
|
||||
)
|
||||
keys_to_sync = result.mappings().all()
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
@@ -504,12 +582,17 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"])
|
||||
await delete_key_from_cluster(
|
||||
cluster_name, key["email"], key["client_id"], session
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
"DELETE FROM keys WHERE tg_id = $1 AND client_id = $2", key["tg_id"], key["client_id"]
|
||||
delete(Key).where(
|
||||
Key.tg_id == key["tg_id"], Key.client_id == key["client_id"]
|
||||
)
|
||||
)
|
||||
|
||||
await create_key_on_cluster(
|
||||
@@ -518,15 +601,17 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
plan=key.get("tariff_id"),
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
remnawave_link=key.get("remnawave_link"),
|
||||
remnawave_link=key["remnawave_link"],
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в {cluster_name}: {e}")
|
||||
logger.error(
|
||||
f"Ошибка при синхронизации ключа {key['client_id']} в {cluster_name}: {e}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
|
||||
@@ -542,7 +627,9 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
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)
|
||||
@@ -561,8 +648,14 @@ async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminS
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
|
||||
async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
|
||||
@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)
|
||||
@@ -574,7 +667,7 @@ async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClu
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
|
||||
async def handle_days_input(message: Message, state: FSMContext, session: Any):
|
||||
async def handle_days_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
if days <= 0:
|
||||
@@ -582,60 +675,87 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
add_ms = days * 86400 * 1000
|
||||
|
||||
row = await session.fetchrow("SELECT tariff_group FROM servers WHERE cluster_name = $1 LIMIT 1", cluster_name)
|
||||
if not row or not row["tariff_group"]:
|
||||
await message.answer("❌ Не удалось определить тарифную группу для этого кластера.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group_code = row["tariff_group"]
|
||||
|
||||
tariff = await session.fetchrow(
|
||||
"""
|
||||
SELECT * FROM tariffs
|
||||
WHERE group_code = $1 AND is_active = TRUE AND duration_days >= $2
|
||||
ORDER BY duration_days ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
group_code,
|
||||
days,
|
||||
result = await session.execute(
|
||||
select(Server.tariff_group)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.where(Server.tariff_group.isnot(None))
|
||||
.limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
if not row or not row[0]:
|
||||
result = await session.execute(
|
||||
select(Server.tariff_group)
|
||||
.where(Server.server_name == cluster_name)
|
||||
.where(Server.tariff_group.isnot(None))
|
||||
.limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
if not row or not row[0]:
|
||||
await message.answer(
|
||||
"❌ Не удалось определить тарифную группу для этого кластера или сервера."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group_code = row[0]
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.where(
|
||||
Tariff.group_code == group_code,
|
||||
Tariff.is_active.is_(True),
|
||||
Tariff.duration_days >= days,
|
||||
)
|
||||
.order_by(Tariff.duration_days.asc())
|
||||
.limit(1)
|
||||
)
|
||||
tariff = result.scalars().first()
|
||||
if not tariff:
|
||||
await message.answer("❌ Нет активных тарифов, подходящих по сроку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
total_gb = tariff["traffic_limit"] or 0
|
||||
total_gb = tariff.traffic_limit or 0
|
||||
|
||||
keys = await session.fetch(
|
||||
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
|
||||
cluster_name,
|
||||
server_stmt = select(Server.server_name).where(
|
||||
Server.cluster_name == cluster_name
|
||||
)
|
||||
server_rows = await session.execute(server_stmt)
|
||||
server_names = [row[0] for row in server_rows.all()]
|
||||
server_names.append(cluster_name)
|
||||
|
||||
result = await session.execute(
|
||||
select(Key).where(Key.server_id.in_(server_names))
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
|
||||
if not keys:
|
||||
await message.answer("❌ Нет подписок в этом кластере.")
|
||||
await message.answer("❌ Нет подписок в этом кластере или сервере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
for key in keys:
|
||||
new_expiry = key["expiry_time"] + add_ms
|
||||
new_expiry = key.expiry_time + add_ms
|
||||
await renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
email=key.email,
|
||||
client_id=key.client_id,
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=total_gb,
|
||||
session=session,
|
||||
)
|
||||
await update_key_expiry(key["client_id"], new_expiry, session)
|
||||
await update_key_expiry(session, key.client_id, new_expiry)
|
||||
|
||||
logger.info(f"[Cluster Extend] {key['email']} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
|
||||
logger.info(
|
||||
f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней.")
|
||||
except Exception as e:
|
||||
@@ -645,8 +765,14 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
|
||||
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):
|
||||
@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)
|
||||
|
||||
@@ -665,7 +791,9 @@ async def handle_rename_cluster(callback_query: CallbackQuery, callback_data: Ad
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
|
||||
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: Any):
|
||||
async def handle_new_cluster_name_input(
|
||||
message: Message, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||
@@ -684,11 +812,14 @@ async def handle_new_cluster_name_input(message: Message, state: FSMContext, ses
|
||||
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
|
||||
result = await session.execute(
|
||||
select(Server.cluster_name)
|
||||
.where(Server.cluster_name == new_cluster_name)
|
||||
.limit(1)
|
||||
)
|
||||
existing_cluster = result.scalar()
|
||||
|
||||
if existing_cluster:
|
||||
await message.answer(
|
||||
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
|
||||
@@ -696,38 +827,57 @@ async def handle_new_cluster_name_input(message: Message, state: FSMContext, ses
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_cluster_name)
|
||||
keys_count_result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.server_id == old_cluster_name)
|
||||
)
|
||||
keys_count = keys_count_result.scalar()
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2", new_cluster_name, old_cluster_name
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.cluster_name == old_cluster_name)
|
||||
.values(cluster_name=new_cluster_name)
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.server_id == old_cluster_name)
|
||||
.values(server_id=new_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 session.commit()
|
||||
|
||||
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 session.rollback()
|
||||
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):
|
||||
@router.callback_query(
|
||||
AdminServerCallback.filter(F.action == "rename"), IsAdminFilter()
|
||||
)
|
||||
async def handle_rename_server(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminServerCallback,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
old_server_name = callback_data.data
|
||||
|
||||
servers = await get_servers()
|
||||
servers = await get_servers(session=session)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers.items():
|
||||
for server in server_list:
|
||||
@@ -761,7 +911,9 @@ async def handle_rename_server(callback_query: CallbackQuery, callback_data: Adm
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
|
||||
async def handle_new_server_name_input(message: Message, state: FSMContext, session: Any):
|
||||
async def handle_new_server_name_input(
|
||||
message: Message, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
|
||||
@@ -781,13 +933,16 @@ async def handle_new_server_name_input(message: Message, state: FSMContext, sess
|
||||
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,
|
||||
result = await session.execute(
|
||||
select(Server)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == new_server_name,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
existing_server = result.scalar()
|
||||
if existing_server:
|
||||
await message.answer(
|
||||
text=f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. Введите другое имя.",
|
||||
@@ -795,55 +950,74 @@ async def handle_new_server_name_input(message: Message, state: FSMContext, sess
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_server_name)
|
||||
result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.server_id == old_server_name)
|
||||
)
|
||||
keys_count = result.scalar()
|
||||
|
||||
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,
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == old_server_name,
|
||||
)
|
||||
.values(server_name=new_server_name)
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.server_id == old_server_name)
|
||||
.values(server_id=new_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 session.commit()
|
||||
|
||||
await message.answer(
|
||||
text=final_text,
|
||||
text=f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
|
||||
await session.rollback()
|
||||
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)
|
||||
async def handle_server_transfer(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name)
|
||||
data = callback_query.data.split("|")
|
||||
new_server_name = data[1]
|
||||
old_server_name = data[2]
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.server_id == old_server_name)
|
||||
.values(server_id=new_server_name)
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
delete(Server).where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == old_server_name,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
|
||||
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
|
||||
@@ -854,59 +1028,81 @@ async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContex
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
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)
|
||||
async def handle_cluster_transfer(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
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
|
||||
)
|
||||
data = callback_query.data.split("|")
|
||||
new_cluster_name = data[1]
|
||||
old_cluster_name = data[2]
|
||||
old_server_name = data[3]
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.server_id == old_server_name)
|
||||
.values(server_id=new_cluster_name)
|
||||
)
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.server_id == old_cluster_name)
|
||||
.values(server_id=new_cluster_name)
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
delete(Server).where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == old_server_name,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно перенесены в кластер '{new_cluster_name}', сервер '{old_server_name}' и кластер '{old_cluster_name}' удалены!\n\n⚠️ Не забудьте сделать \"Синхронизацию\".",
|
||||
text=(
|
||||
f"✅ Ключи успешно перенесены в кластер '<b>{new_cluster_name}</b>', "
|
||||
f"сервер '<b>{old_server_name}</b>' и кластер '<b>{old_cluster_name}</b>' удалены!\n\n"
|
||||
f'⚠️ Не забудьте сделать "Синхронизацию".'
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
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()
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter())
|
||||
async def show_tariff_group_selection(callback: CallbackQuery, callback_data: AdminClusterCallback, session):
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter()
|
||||
)
|
||||
async def show_tariff_group_selection(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
rows = await session.fetch(
|
||||
"SELECT DISTINCT ON (group_code) id, group_code FROM tariffs WHERE group_code IS NOT NULL ORDER BY group_code"
|
||||
result = await session.execute(
|
||||
select(Tariff.id, Tariff.group_code)
|
||||
.where(Tariff.group_code.isnot(None))
|
||||
.distinct(Tariff.group_code)
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
groups = [(r["id"], r["group_code"]) for r in rows]
|
||||
|
||||
if not groups:
|
||||
@@ -919,13 +1115,21 @@ async def show_tariff_group_selection(callback: CallbackQuery, callback_data: Ad
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter())
|
||||
async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminClusterCallback, session):
|
||||
@router.callback_query(
|
||||
AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter()
|
||||
)
|
||||
async def apply_tariff_group(
|
||||
callback: CallbackQuery, callback_data: AdminClusterCallback, session
|
||||
):
|
||||
try:
|
||||
cluster_name, group_id = callback_data.data.split("|", 1)
|
||||
group_id = int(group_id)
|
||||
|
||||
row = await session.fetchrow("SELECT group_code FROM tariffs WHERE id = $1", group_id)
|
||||
result = await session.execute(
|
||||
select(Tariff.group_code).where(Tariff.id == group_id)
|
||||
)
|
||||
row = result.mappings().first()
|
||||
|
||||
if not row:
|
||||
await callback.message.edit_text("❌ Тарифная группа не найдена.")
|
||||
return
|
||||
@@ -933,10 +1137,11 @@ async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminCluste
|
||||
group_code = row["group_code"]
|
||||
|
||||
await session.execute(
|
||||
"UPDATE servers SET tariff_group = $1 WHERE cluster_name = $2",
|
||||
group_code,
|
||||
cluster_name,
|
||||
update(Server)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.values(tariff_group=group_code)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Для кластера <code>{cluster_name}</code> установлена тарифная группа: <b>{group_code}</b>",
|
||||
@@ -945,4 +1150,6 @@ async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminCluste
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при применении тарифной группы: {e}")
|
||||
await callback.message.edit_text("❌ Произошла ошибка при установке тарифной группы.")
|
||||
await callback.message.edit_text(
|
||||
"❌ Произошла ошибка при установке тарифной группы."
|
||||
)
|
||||
|
||||
@@ -16,16 +16,23 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
|
||||
|
||||
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=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())
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить кластер",
|
||||
callback_data=AdminClusterCallback(action="add").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn())
|
||||
@@ -33,14 +40,18 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
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(),
|
||||
callback_data=AdminServerCallback(
|
||||
action="manage", data=server["server_name"]
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -51,7 +62,15 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(
|
||||
action="manage", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -67,7 +86,9 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность",
|
||||
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
|
||||
callback_data=AdminClusterCallback(
|
||||
action="availability", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -79,47 +100,65 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💾 Создать бэкап",
|
||||
callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
|
||||
callback_data=AdminClusterCallback(
|
||||
action="backup", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Добавить время",
|
||||
callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
|
||||
callback_data=AdminClusterCallback(
|
||||
action="add_time", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Сменить название",
|
||||
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
|
||||
callback_data=AdminClusterCallback(
|
||||
action="rename", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💸 Тариф: [установить/изменить]",
|
||||
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
|
||||
text="💸 Тариф(Установить/изменить)",
|
||||
callback_data=AdminClusterCallback(
|
||||
action="set_tariff", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
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(),
|
||||
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(),
|
||||
callback_data=AdminClusterCallback(
|
||||
action="sync-cluster", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -130,25 +169,34 @@ def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKey
|
||||
|
||||
def build_panel_type_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
|
||||
builder.button(text="🌀 Remnawave", callback_data=AdminClusterCallback(action="panel_remnawave").pack())
|
||||
builder.button(
|
||||
text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack()
|
||||
)
|
||||
builder.button(
|
||||
text="🌀 Remnawave",
|
||||
callback_data=AdminClusterCallback(action="panel_remnawave").pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, str]]) -> InlineKeyboardMarkup:
|
||||
def build_tariff_group_selection_kb(
|
||||
cluster_name: str, groups: list[tuple[int, str]]
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for group_id, group_code in groups:
|
||||
builder.button(
|
||||
text=group_code,
|
||||
callback_data=AdminClusterCallback(
|
||||
action="apply_tariff_group",
|
||||
data=f"{cluster_name}|{group_id}"
|
||||
action="apply_tariff_group", data=f"{cluster_name}|{group_id}"
|
||||
).pack(),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack()
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminClusterCallback(
|
||||
action="manage", data=cluster_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.adjust(2, 1)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import html
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -19,19 +14,19 @@ from aiogram.types import (
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import INLINE_MODE, USERNAME_BOT
|
||||
from database import (
|
||||
create_coupon,
|
||||
delete_coupon,
|
||||
get_all_coupons,
|
||||
)
|
||||
from database import create_coupon, delete_coupon, get_all_coupons
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
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
|
||||
|
||||
from .keyboard import (
|
||||
AdminCouponDeleteCallback,
|
||||
build_coupons_kb,
|
||||
build_coupons_list_kb,
|
||||
format_coupons_list,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -47,7 +42,9 @@ class AdminCouponsState(StatesGroup):
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
|
||||
await callback_query.message.edit_text(
|
||||
text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -67,7 +64,9 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_balance")
|
||||
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
async def handle_balance_coupon_selection(
|
||||
callback_query: CallbackQuery, state: FSMContext
|
||||
):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
@@ -81,7 +80,9 @@ async def handle_balance_coupon_selection(callback_query: CallbackQuery, state:
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_days")
|
||||
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
async def handle_days_coupon_selection(
|
||||
callback_query: CallbackQuery, state: FSMContext
|
||||
):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
|
||||
@@ -95,7 +96,9 @@ async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSM
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
|
||||
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
async def handle_balance_coupon_input(
|
||||
message: Message, state: FSMContext, session: Any
|
||||
):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
@@ -123,7 +126,7 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
|
||||
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
text = (
|
||||
@@ -144,7 +147,9 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
|
||||
@@ -176,7 +181,7 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, 0, usage_limit, session, days=days)
|
||||
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
text = (
|
||||
@@ -197,7 +202,9 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -211,43 +218,66 @@ async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
|
||||
await update_coupons_list(callback_query.message, session, page)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||
await callback_query.message.edit_text("Произошла ошибка при получении списка купонов.")
|
||||
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):
|
||||
@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()
|
||||
text="✅ Да, удалить",
|
||||
callback_data=AdminCouponDeleteCallback(
|
||||
coupon_code=coupon_code, confirm=True
|
||||
).pack(),
|
||||
)
|
||||
kb.button(
|
||||
text="❌ Нет, отменить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
|
||||
text="❌ Нет, отменить",
|
||||
callback_data=AdminCouponDeleteCallback(
|
||||
coupon_code=coupon_code, confirm=False
|
||||
).pack(),
|
||||
)
|
||||
kb.adjust(1)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?", reply_markup=kb.as_markup()
|
||||
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?",
|
||||
reply_markup=kb.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
|
||||
async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
|
||||
@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)
|
||||
result = await delete_coupon(session, coupon_code)
|
||||
if not result:
|
||||
await callback_query.message.edit_text(
|
||||
f"❌ Купон с кодом {coupon_code} не найден.", reply_markup=build_admin_back_kb("coupons")
|
||||
f"❌ Купон с кодом {coupon_code} не найден.",
|
||||
reply_markup=build_admin_back_kb("coupons"),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"Произошла ошибка при удалении купона.", reply_markup=build_admin_back_kb("coupons")
|
||||
"Произошла ошибка при удалении купона.",
|
||||
reply_markup=build_admin_back_kb("coupons"),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -310,7 +340,9 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
id=coupon_code,
|
||||
title=title,
|
||||
description=description,
|
||||
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text, parse_mode=ParseMode.HTML
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,13 +17,27 @@ class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
|
||||
|
||||
def build_coupons_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="➕ Создать купон", callback_data=AdminPanelCallback(action="coupons_create").pack())
|
||||
builder.button(text="Купоны", callback_data=AdminPanelCallback(action="coupons_list").pack())
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Создать купон",
|
||||
callback_data=AdminPanelCallback(action="coupons_create").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="Купоны",
|
||||
callback_data=AdminPanelCallback(action="coupons_list").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
|
||||
def build_coupons_list_kb(
|
||||
coupons: list, current_page: int, total_pages: int
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for coupon in coupons:
|
||||
@@ -38,14 +52,18 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
|
||||
callback_data=AdminPanelCallback(
|
||||
action="coupons_list", page=current_page - 1
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
if current_page < total_pages:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=AdminPanelCallback(action="coupons_list", page=current_page + 1).pack(),
|
||||
callback_data=AdminPanelCallback(
|
||||
action="coupons_list", page=current_page + 1
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
|
||||
@@ -8,13 +8,35 @@ 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())
|
||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
|
||||
builder.button(text=maintenance_text, callback_data=AdminPanelCallback(action="toggle_maintenance").pack())
|
||||
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(),
|
||||
)
|
||||
maintenance_text = (
|
||||
"🛠️ Выключить тех. работы"
|
||||
if maintenance.maintenance_mode
|
||||
else "🛠️ Включить тех. работы"
|
||||
)
|
||||
builder.button(
|
||||
text=maintenance_text,
|
||||
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
|
||||
@@ -2,8 +2,10 @@ 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 sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
from middlewares import maintenance
|
||||
@@ -11,7 +13,6 @@ from middlewares import maintenance
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import AdminPanelCallback, build_management_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -19,7 +20,9 @@ class AdminManagementStates(StatesGroup):
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()
|
||||
)
|
||||
async def handle_management(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
@@ -27,7 +30,9 @@ async def handle_management(callback_query: CallbackQuery):
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
||||
@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)
|
||||
@@ -37,10 +42,14 @@ async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||
|
||||
|
||||
@router.message(AdminManagementStates.waiting_for_new_domain)
|
||||
async def process_new_domain(message: Message, state: FSMContext, session: Connection):
|
||||
async def process_new_domain(
|
||||
message: Message, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
"""Обновляет домен в таблице keys."""
|
||||
new_domain = message.text.strip()
|
||||
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
|
||||
logger.info(
|
||||
f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'"
|
||||
)
|
||||
|
||||
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
@@ -53,26 +62,34 @@ async def process_new_domain(message: Message, state: FSMContext, session: Conne
|
||||
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)
|
||||
stmt = (
|
||||
update(Key)
|
||||
.where(~Key.key.startswith(new_domain_url))
|
||||
.values(key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url))
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||
await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin"))
|
||||
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}")
|
||||
sample = await session.execute(select(Key.key).limit(1))
|
||||
example = sample.scalar()
|
||||
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||
|
||||
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
|
||||
await message.answer(
|
||||
f"✅ Домен успешно изменен на {new_domain}!",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
@@ -19,20 +19,44 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
|
||||
|
||||
def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
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="clusters").pack()),
|
||||
InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
|
||||
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="sender").pack()),
|
||||
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
|
||||
InlineKeyboardButton(
|
||||
text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Управление",
|
||||
callback_data=AdminPanelCallback(action="management").pack(),
|
||||
),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
|
||||
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
|
||||
InlineKeyboardButton(
|
||||
text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=AdminPanelCallback(action="stats").pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()
|
||||
),
|
||||
)
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1, 1, 2, 2, 1, 2, 1)
|
||||
@@ -41,7 +65,10 @@ def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
|
||||
def build_restart_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="✅ Да, перезагрузить", callback_data=AdminPanelCallback(action="restart_confirm").pack())
|
||||
builder.button(
|
||||
text="✅ Да, перезагрузить",
|
||||
callback_data=AdminPanelCallback(action="restart_confirm").pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -62,4 +89,6 @@ def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
|
||||
|
||||
|
||||
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
|
||||
return InlineKeyboardButton(
|
||||
text=text, callback_data=AdminPanelCallback(action=action).pack()
|
||||
)
|
||||
|
||||
@@ -10,22 +10,25 @@ from logger import logger
|
||||
|
||||
from .keyboard import AdminPanelCallback, build_panel_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
|
||||
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
|
||||
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
|
||||
|
||||
await state.clear()
|
||||
|
||||
if callback_query.message.text:
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
|
||||
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("🔄 Попытка редактировать сообщение без изменений — пропущено.")
|
||||
logger.warning(
|
||||
"🔄 Попытка редактировать сообщение без изменений — пропущено."
|
||||
)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
@@ -44,7 +47,7 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
|
||||
|
||||
@router.message(Command("admin"), IsAdminFilter())
|
||||
async def handle_admin_message(message: Message, state: FSMContext):
|
||||
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
|
||||
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
|
||||
|
||||
await state.clear()
|
||||
await message.answer(text=text, reply_markup=build_panel_kb())
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import psutil
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
@@ -7,36 +11,38 @@ from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "restart"),
|
||||
IsAdminFilter(),
|
||||
AdminPanelCallback.filter(F.action == "restart"), IsAdminFilter()
|
||||
)
|
||||
async def handle_restart(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="🤔 Вы уверены, что хотите перезагрузить бота?",
|
||||
reply_markup=build_restart_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "restart_confirm"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_restart_confirm(callback_query: CallbackQuery):
|
||||
async def handle_restart_confirm(
|
||||
callback_query: CallbackQuery, callback_data: AdminPanelCallback
|
||||
):
|
||||
kb = build_admin_back_kb()
|
||||
await callback_query.message.edit_text("🔄 Перезапускаем бота...", reply_markup=kb)
|
||||
|
||||
asyncio.create_task(restart_bot())
|
||||
|
||||
|
||||
async def restart_bot():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
|
||||
except subprocess.CalledProcessError:
|
||||
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
is_systemd = parent and "systemd" in parent.name().lower()
|
||||
|
||||
if is_systemd:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||
|
||||
except Exception as e:
|
||||
await callback_query.message.edit_text(text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", reply_markup=kb)
|
||||
print(f"[Restart] Ошибка при перезапуске: {e}")
|
||||
os._exit(1)
|
||||
|
||||
@@ -13,19 +13,39 @@ class AdminSenderCallback(CallbackData, prefix="admin_sender"):
|
||||
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()),
|
||||
InlineKeyboardButton(
|
||||
text="👥 Все пользователи",
|
||||
callback_data=AdminSenderCallback(type="all").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📍 Не использовавшие триал", callback_data=AdminSenderCallback(type="untrial").pack()
|
||||
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="hotleads").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Кластер", callback_data=AdminSenderCallback(type="cluster-select").pack())
|
||||
InlineKeyboardButton(
|
||||
text="🔥 Горячие лиды",
|
||||
callback_data=AdminSenderCallback(type="hotleads").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📢 Кластер",
|
||||
callback_data=AdminSenderCallback(type="cluster-select").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
@@ -37,7 +57,10 @@ def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
|
||||
|
||||
for cluster in clusters:
|
||||
name = cluster["cluster_name"]
|
||||
builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
|
||||
builder.button(
|
||||
text=f"🌐 {name}",
|
||||
callback_data=AdminSenderCallback(type="cluster", data=name).pack(),
|
||||
)
|
||||
|
||||
builder.adjust(2)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
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 sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment, Server, User
|
||||
from filters.admin import IsAdminFilter
|
||||
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()
|
||||
|
||||
|
||||
@@ -50,8 +51,10 @@ async def handle_sender_callback_text(
|
||||
AdminSenderCallback.filter(F.type == "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
|
||||
clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
|
||||
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Server.cluster_name).distinct())
|
||||
clusters = result.mappings().all()
|
||||
|
||||
await callback_query.message.answer(
|
||||
"✍️ Выберите кластер для рассылки сообщений:",
|
||||
reply_markup=build_clusters_kb(clusters),
|
||||
@@ -59,13 +62,14 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
|
||||
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
|
||||
async def handle_message_input(message: Message, state: FSMContext, session: Any):
|
||||
"""
|
||||
Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
|
||||
"""
|
||||
async def handle_message_input(message: Message, state: FSMContext, session):
|
||||
text_message = message.html_text if message.text else None
|
||||
photo = message.photo[-1].file_id if message.photo else None
|
||||
photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
|
||||
photo_url = (
|
||||
message.caption
|
||||
if message.photo and message.caption and message.caption.startswith("http")
|
||||
else None
|
||||
)
|
||||
|
||||
if not text_message and message.caption:
|
||||
text_message = message.caption
|
||||
@@ -76,85 +80,86 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
|
||||
|
||||
state_data = await state.get_data()
|
||||
send_to = state_data.get("type", "all")
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
|
||||
now = int(datetime.utcnow().timestamp() * 1000)
|
||||
query = None
|
||||
|
||||
if send_to == "subscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN keys k ON u.tg_id = k.tg_id
|
||||
WHERE k.expiry_time > $1
|
||||
""",
|
||||
now,
|
||||
)
|
||||
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
|
||||
|
||||
elif send_to == "unsubscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT u.tg_id
|
||||
FROM users u
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
GROUP BY u.tg_id
|
||||
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
|
||||
""",
|
||||
now,
|
||||
subquery = (
|
||||
select(User.tg_id)
|
||||
.outerjoin(Key, User.tg_id == Key.tg_id)
|
||||
.group_by(User.tg_id)
|
||||
.having(func.count(Key.tg_id) == 0)
|
||||
.union_all(
|
||||
select(User.tg_id)
|
||||
.join(Key, User.tg_id == Key.tg_id)
|
||||
.group_by(User.tg_id)
|
||||
.having(func.max(Key.expiry_time) <= now_ms)
|
||||
)
|
||||
)
|
||||
query = select(distinct(subquery.c.tg_id))
|
||||
|
||||
elif send_to == "untrial":
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users WHERE tg_id NOT IN (SELECT tg_id FROM keys)")
|
||||
subquery = select(Key.tg_id)
|
||||
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery))
|
||||
|
||||
elif send_to == "cluster":
|
||||
cluster_name = state_data.get("cluster_name")
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN keys k ON u.tg_id = k.tg_id
|
||||
JOIN servers s ON k.server_id = s.cluster_name
|
||||
WHERE s.cluster_name = $1
|
||||
""",
|
||||
cluster_name,
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Key, User.tg_id == Key.tg_id)
|
||||
.join(Server, Key.server_id == Server.cluster_name)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
)
|
||||
|
||||
elif send_to == "hotleads":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN payments p ON u.tg_id = p.tg_id
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
WHERE p.status = 'success'
|
||||
AND k.tg_id IS NULL
|
||||
"""
|
||||
subquery = select(Key.tg_id)
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Payment, User.tg_id == Payment.tg_id)
|
||||
.where(Payment.status == "success")
|
||||
.where(~User.tg_id.in_(subquery))
|
||||
)
|
||||
|
||||
else:
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users")
|
||||
query = select(distinct(User.tg_id))
|
||||
|
||||
result = await session.execute(query)
|
||||
tg_ids = [row[0] for row in result.all()]
|
||||
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
|
||||
text = f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
|
||||
await message.answer(
|
||||
f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
|
||||
)
|
||||
|
||||
await message.answer(text=text)
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record["tg_id"]
|
||||
for tg_id in tg_ids:
|
||||
try:
|
||||
if photo or photo_url:
|
||||
await message.bot.send_photo(
|
||||
chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
|
||||
chat_id=tg_id,
|
||||
photo=photo or photo_url,
|
||||
caption=text_message,
|
||||
)
|
||||
else:
|
||||
await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
|
||||
|
||||
await message.bot.send_message(
|
||||
chat_id=tg_id, text=text_message, parse_mode="HTML"
|
||||
)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
|
||||
|
||||
text = (
|
||||
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||
f"👥 <b>Количество получателей:</b> {total_users}\n"
|
||||
f"✅ <b>Доставлено:</b> {success_count}\n"
|
||||
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
|
||||
await message.answer(
|
||||
text=(
|
||||
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||
f"👥 <b>Количество получателей:</b> {total_users}\n"
|
||||
f"✅ <b>Доставлено:</b> {success_count}\n"
|
||||
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
|
||||
),
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("sender"))
|
||||
await state.clear()
|
||||
|
||||
@@ -10,27 +10,41 @@ class AdminServerCallback(CallbackData, prefix="admin_server"):
|
||||
data: str
|
||||
|
||||
|
||||
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
|
||||
from ..clusters.keyboard import AdminClusterCallback
|
||||
def build_manage_server_kb(
|
||||
server_name: str, cluster_name: str, enabled: bool
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
|
||||
toggle_action = "disable" if enabled else "enable"
|
||||
|
||||
builder.button(text=toggle_text, callback_data=AdminServerCallback(action=toggle_action, data=server_name).pack())
|
||||
|
||||
builder.button(
|
||||
text="📈 Задать лимит", callback_data=AdminServerCallback(action="set_limit", data=server_name).pack()
|
||||
text=toggle_text,
|
||||
callback_data=AdminServerCallback(
|
||||
action=toggle_action, data=server_name
|
||||
).pack(),
|
||||
)
|
||||
|
||||
builder.button(text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack())
|
||||
|
||||
builder.button(
|
||||
text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack()
|
||||
text="📈 Задать лимит",
|
||||
callback_data=AdminServerCallback(action="set_limit", data=server_name).pack(),
|
||||
)
|
||||
|
||||
builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
|
||||
builder.button(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=AdminServerCallback(action="delete", data=server_name).pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="✏️ Сменить название",
|
||||
callback_data=AdminServerCallback(action="rename", data=server_name).pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="🔙 Назад",
|
||||
callback_data=f"cluster_servers|{cluster_name}",
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_servers
|
||||
from database.models import Key, Server
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminServerCallback,
|
||||
build_manage_server_kb,
|
||||
)
|
||||
|
||||
from .keyboard import AdminServerCallback, build_manage_server_kb
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -24,13 +21,25 @@ class ServerLimitState(StatesGroup):
|
||||
waiting_for_limit = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_server_manage(callback_query: CallbackQuery, callback_data: AdminServerCallback):
|
||||
@router.callback_query(
|
||||
AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()
|
||||
)
|
||||
async def handle_server_manage(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminServerCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
server_name = callback_data.data
|
||||
servers = await get_servers(include_enabled=True)
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
(
|
||||
(c, s)
|
||||
for c, cs in servers.items()
|
||||
for s in cs
|
||||
if s["server_name"] == server_name
|
||||
),
|
||||
(None, None),
|
||||
)
|
||||
|
||||
if server:
|
||||
@@ -50,28 +59,32 @@ async def handle_server_manage(callback_query: CallbackQuery, callback_data: Adm
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
|
||||
reply_markup=build_manage_server_kb(
|
||||
server_name, cluster_name, enabled=server.get("enabled", True)
|
||||
),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(text="❌ Сервер не найден.")
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "delete"), IsAdminFilter())
|
||||
@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
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminServerCallback,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
from ..clusters.clusters_handler import AdminClusterStates
|
||||
|
||||
server_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
servers_dict = 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:
|
||||
for c_name, server_list in servers_dict.items():
|
||||
if any(s["server_name"] == server_name for s in server_list):
|
||||
cluster_name = c_name
|
||||
break
|
||||
|
||||
if not cluster_name:
|
||||
@@ -81,32 +94,41 @@ async def process_callback_delete_server(
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", server_name)
|
||||
stmt_keys_count = select(func.count()).where(Key.server_id == server_name)
|
||||
result = await session.execute(stmt_keys_count)
|
||||
keys_count = result.scalar_one()
|
||||
|
||||
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,
|
||||
subq = (
|
||||
select(func.count())
|
||||
.where(Key.server_id == Server.server_name)
|
||||
.correlate(Server)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(
|
||||
Server.server_name != server_name
|
||||
)
|
||||
result = await session.execute(stmt_all_servers)
|
||||
all_servers = result.all()
|
||||
|
||||
if all_servers:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for server in all_servers:
|
||||
for s_name, key_count 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}",
|
||||
text=f"{s_name} ({key_count})",
|
||||
callback_data=f"transfer_to_server|{s_name}|{server_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
text=BACK,
|
||||
callback_data=AdminServerCallback(
|
||||
action="manage", data=server_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -117,42 +139,62 @@ async def process_callback_delete_server(
|
||||
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
|
||||
stmt_remaining = select(func.count()).where(
|
||||
(Server.cluster_name == cluster_name) & (Server.server_name != server_name)
|
||||
)
|
||||
result = await session.execute(stmt_remaining)
|
||||
remaining_servers = result.scalar_one()
|
||||
|
||||
if remaining_servers == 0:
|
||||
other_clusters = await session.fetch(
|
||||
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1", cluster_name
|
||||
stmt_other_clusters = (
|
||||
select(Server.cluster_name)
|
||||
.distinct()
|
||||
.where(Server.cluster_name != cluster_name)
|
||||
)
|
||||
result = await session.execute(stmt_other_clusters)
|
||||
other_clusters = result.scalars().all()
|
||||
|
||||
if other_clusters:
|
||||
cluster_keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", cluster_name)
|
||||
stmt_cluster_keys = select(func.count()).where(
|
||||
Key.server_id == cluster_name
|
||||
)
|
||||
result = await session.execute(stmt_cluster_keys)
|
||||
cluster_keys_count = result.scalar_one()
|
||||
|
||||
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,
|
||||
await state.update_data(
|
||||
server_name=server_name, cluster_name=cluster_name
|
||||
)
|
||||
|
||||
subq_cluster = (
|
||||
select(func.count())
|
||||
.where(Key.server_id == Server.cluster_name)
|
||||
.correlate(Server)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
stmt_all_clusters = (
|
||||
select(Server.cluster_name, subq_cluster.label("key_count"))
|
||||
.where(Server.cluster_name != cluster_name)
|
||||
.group_by(Server.cluster_name)
|
||||
)
|
||||
result = await session.execute(stmt_all_clusters)
|
||||
all_clusters = result.all()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cluster in all_clusters:
|
||||
for cl_name, key_count 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}",
|
||||
text=f"{cl_name} ({key_count})",
|
||||
callback_data=f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
text=BACK,
|
||||
callback_data=AdminServerCallback(
|
||||
action="manage", data=server_name
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -163,36 +205,57 @@ async def process_callback_delete_server(
|
||||
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
|
||||
stmt_delete = delete(Server).where(
|
||||
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||
)
|
||||
await session.execute(stmt_delete)
|
||||
await session.commit()
|
||||
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
|
||||
stmt_delete = delete(Server).where(
|
||||
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||
)
|
||||
await session.execute(stmt_delete)
|
||||
await session.commit()
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер '{server_name}' удален.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter())
|
||||
async def toggle_server_enabled(callback_query: CallbackQuery, callback_data: AdminServerCallback, session: Any):
|
||||
@router.callback_query(
|
||||
AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter()
|
||||
)
|
||||
async def toggle_server_enabled(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminServerCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
server_name = callback_data.data
|
||||
action = callback_data.action
|
||||
|
||||
new_status = action == "enable"
|
||||
|
||||
await session.execute("UPDATE servers SET enabled = $1 WHERE server_name = $2", new_status, server_name)
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.server_name == server_name)
|
||||
.values(enabled=new_status)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
servers = await get_servers(include_enabled=True)
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
(
|
||||
(c, s)
|
||||
for c, cs in servers.items()
|
||||
for s in cs
|
||||
if s["server_name"] == server_name
|
||||
),
|
||||
(None, None),
|
||||
)
|
||||
|
||||
if not server:
|
||||
@@ -212,22 +275,30 @@ async def toggle_server_enabled(callback_query: CallbackQuery, callback_data: Ad
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=new_status),
|
||||
reply_markup=build_manage_server_kb(
|
||||
server_name, cluster_name, enabled=new_status
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter())
|
||||
async def ask_server_limit(callback: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
@router.callback_query(
|
||||
AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter()
|
||||
)
|
||||
async def ask_server_limit(
|
||||
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext
|
||||
):
|
||||
server_name = callback_data.data
|
||||
await state.set_state(ServerLimitState.waiting_for_limit)
|
||||
await state.update_data(server_name=server_name)
|
||||
await callback.message.edit_text(
|
||||
await callback_query.message.edit_text(
|
||||
f"Введите лимит ключей для сервера <b>{server_name}</b> (целое число, 0 — без лимита):",
|
||||
)
|
||||
|
||||
|
||||
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
|
||||
async def save_server_limit(message: types.Message, state: FSMContext, session: Any):
|
||||
async def save_server_limit(
|
||||
message: types.Message, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
try:
|
||||
limit = int(message.text.strip())
|
||||
if limit < 0:
|
||||
@@ -237,11 +308,23 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
|
||||
server_name = data["server_name"]
|
||||
|
||||
new_value = limit if limit > 0 else None
|
||||
await session.execute("UPDATE servers SET max_keys = $1 WHERE server_name = $2", new_value, server_name)
|
||||
|
||||
servers = await get_servers(include_enabled=True)
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.server_name == server_name)
|
||||
.values(max_keys=new_value)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
(
|
||||
(c, s)
|
||||
for c, cs in servers.items()
|
||||
for s in cs
|
||||
if s["server_name"] == server_name
|
||||
),
|
||||
(None, None),
|
||||
)
|
||||
|
||||
if not server:
|
||||
@@ -261,7 +344,10 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text, reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True))
|
||||
text,
|
||||
reply_markup=build_manage_server_kb(
|
||||
server_name, cluster_name, enabled=server.get("enabled", True)
|
||||
),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@@ -6,20 +6,24 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
def build_stats_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack())
|
||||
builder.button(
|
||||
text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack()
|
||||
)
|
||||
builder.button(
|
||||
text="📥 Выгрузить пользователей в CSV",
|
||||
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📥 Выгрузить оплаты в CSV", callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack()
|
||||
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()
|
||||
text="📥 Выгрузить горящих лидов",
|
||||
callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
|
||||
@@ -1,219 +1,226 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import asyncpg
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import ADMIN_ID
|
||||
from database import (
|
||||
count_active_keys,
|
||||
count_total_keys,
|
||||
count_total_referrals,
|
||||
count_total_users,
|
||||
count_trial_keys,
|
||||
count_users_registered_between,
|
||||
count_users_registered_since,
|
||||
count_users_updated_today,
|
||||
get_tariff_distribution,
|
||||
get_tariff_names,
|
||||
sum_payments_between,
|
||||
sum_payments_since,
|
||||
sum_total_payments,
|
||||
)
|
||||
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 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
|
||||
from bot import bot
|
||||
from config import DATABASE_URL, ADMIN_ID
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_stats(callback_query: CallbackQuery, session: Any):
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "stats"), IsAdminFilter())
|
||||
async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
|
||||
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")
|
||||
users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
|
||||
now = datetime.utcnow()
|
||||
today = now.date()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
total_payments_today = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"))
|
||||
total_payments_yesterday = int(await session.fetchval("""
|
||||
SELECT COALESCE(SUM(amount), 0) FROM payments
|
||||
WHERE created_at >= CURRENT_DATE - interval '1 day' AND 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"))
|
||||
|
||||
registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
|
||||
registrations_yesterday = await session.fetchval("""
|
||||
SELECT COUNT(*) FROM users
|
||||
WHERE created_at >= CURRENT_DATE - interval '1 day' AND 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)")
|
||||
registrations_last_month = await session.fetchval("""
|
||||
SELECT COUNT(*) FROM users
|
||||
WHERE created_at >= date_trunc('month', CURRENT_DATE - interval '1 month') AND created_at < date_trunc('month', 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
|
||||
|
||||
tariffs = await session.fetch("SELECT id, name, duration_days FROM tariffs WHERE is_active = TRUE")
|
||||
tariff_map = {t["id"]: t["name"] for t in tariffs}
|
||||
durations = [(t["id"], t["name"], t["duration_days"]) for t in tariffs]
|
||||
|
||||
tariff_counter: dict[str, int] = {}
|
||||
|
||||
keys_with_tariffs = await session.fetch("SELECT tariff_id FROM keys WHERE tariff_id IS NOT NULL")
|
||||
for row in keys_with_tariffs:
|
||||
name = tariff_map.get(row["tariff_id"], "Неизвестно")
|
||||
tariff_counter[name] = tariff_counter.get(name, 0) + 1
|
||||
|
||||
keys_without_tariffs = await session.fetch("SELECT created_at, expiry_time FROM keys WHERE tariff_id IS NULL")
|
||||
for row in keys_without_tariffs:
|
||||
duration_days = (row["expiry_time"] - row["created_at"]) / (1000 * 60 * 60 * 24)
|
||||
if durations:
|
||||
closest = min(durations, key=lambda t: abs(t[2] - duration_days))
|
||||
name = closest[1]
|
||||
else:
|
||||
name = "Неизвестно"
|
||||
tariff_counter[name] = tariff_counter.get(name, 0) + 1
|
||||
|
||||
tariff_order = {t["name"]: t["id"] for t in sorted(tariffs, key=lambda t: t["id"])}
|
||||
tariff_stats_text = "\n".join(
|
||||
f" • {name}: <b>{tariff_counter[name]}</b>"
|
||||
for name in sorted(tariff_counter.keys(), key=lambda name: tariff_order.get(name, float('inf')))
|
||||
total_users = await count_total_users(session)
|
||||
users_updated_today = await count_users_updated_today(session, today)
|
||||
registrations_today = await count_users_registered_since(session, today)
|
||||
registrations_yesterday = await count_users_registered_between(
|
||||
session, today - timedelta(days=1), today
|
||||
)
|
||||
registrations_week = await count_users_registered_since(
|
||||
session, today - timedelta(days=today.weekday())
|
||||
)
|
||||
registrations_month = await count_users_registered_since(
|
||||
session, today.replace(day=1)
|
||||
)
|
||||
last_month_start = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
|
||||
registrations_last_month = await count_users_registered_between(
|
||||
session, last_month_start, today.replace(day=1)
|
||||
)
|
||||
|
||||
if not tariff_stats_text:
|
||||
tariff_stats_text = " • Нет активных тарифов"
|
||||
total_keys = await count_total_keys(session)
|
||||
active_keys = await count_active_keys(session)
|
||||
expired_keys = total_keys - active_keys
|
||||
trial_keys_count = await count_trial_keys(session)
|
||||
|
||||
tariff_counts = await get_tariff_distribution(session)
|
||||
tariff_names = await get_tariff_names(
|
||||
session, [tid for tid, _ in tariff_counts]
|
||||
)
|
||||
|
||||
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
|
||||
""")
|
||||
tariff_stats_text = ""
|
||||
for tid, count in tariff_counts:
|
||||
name = tariff_names.get(tid, f"ID {tid}")
|
||||
tariff_stats_text += f"├ {name}: <b>{count}</b>\n"
|
||||
tariff_stats_text = (
|
||||
"└ По тарифам:\n" + tariff_stats_text
|
||||
if tariff_stats_text
|
||||
else "└ Нет данных по тарифам\n"
|
||||
)
|
||||
|
||||
trial_only_count = await session.fetchval("""
|
||||
SELECT COUNT(DISTINCT k.tg_id)
|
||||
FROM keys k
|
||||
LEFT JOIN tariffs t ON k.tariff_id = t.id
|
||||
LEFT JOIN payments p ON k.tg_id = p.tg_id
|
||||
WHERE p.id IS NULL
|
||||
""")
|
||||
total_referrals = await count_total_referrals(session)
|
||||
|
||||
total_payments_today = await sum_payments_since(session, today)
|
||||
total_payments_yesterday = await sum_payments_between(
|
||||
session, today - timedelta(days=1), today
|
||||
)
|
||||
total_payments_week = await sum_payments_since(
|
||||
session, today - timedelta(days=today.weekday())
|
||||
)
|
||||
total_payments_month = await sum_payments_since(session, today.replace(day=1))
|
||||
total_payments_last_month = await sum_payments_between(
|
||||
session, last_month_start, today.replace(day=1)
|
||||
)
|
||||
total_payments_all_time = await sum_total_payments(session)
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
stats_message = (
|
||||
"📊 <b>Статистика проекта</b>\n\n"
|
||||
"👤 <b>Пользователи:</b>\n"
|
||||
f"📊 <b>Статистика проекта</b>\n\n"
|
||||
f"👤 <b>Пользователи:</b>\n"
|
||||
f"<blockquote>"
|
||||
f"├ 🗓️ За день: <b>{registrations_today}</b>\n"
|
||||
f"├ 🗓️ Вчера: <b>{registrations_yesterday}</b>\n"
|
||||
f"├ 📆 За неделю: <b>{registrations_week}</b>\n"
|
||||
f"├ 🗓️ За месяц: <b>{registrations_month}</b>\n"
|
||||
f"├ 📅 За прошлый месяц: <b>{registrations_last_month}</b>\n"
|
||||
f"└ 🌐 Всего: <b>{total_users}</b>\n\n"
|
||||
"💡 <b>Активность:</b>\n"
|
||||
f"└ 🌐 Всего: <b>{total_users}</b>\n"
|
||||
f"</blockquote>\n"
|
||||
f"💡 <b>Активность:</b>\n"
|
||||
f"└ 👥 Сегодня были активны: <b>{users_updated_today}</b>\n\n"
|
||||
"🤝 <b>Реферальная система:</b>\n"
|
||||
f"🤝 <b>Реферальная система:</b>\n"
|
||||
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
|
||||
"🔐 <b>Подписки:</b>\n"
|
||||
f"🔐 <b>Подписки:</b>\n"
|
||||
f"<blockquote>"
|
||||
f"├ 📦 Всего сгенерировано: <b>{total_keys}</b>\n"
|
||||
f"├ ✅ Активных: <b>{active_keys}</b>\n"
|
||||
f"├ ❌ Просроченных: <b>{expired_keys}</b>\n"
|
||||
f"├ 🎁 Только триал: <b>{trial_only_count}</b>\n"
|
||||
f"└ 📋 По тарифам:\n{tariff_stats_text}\n\n"
|
||||
"💰 <b>Финансы:</b>\n"
|
||||
f"├ 🧪 Триальных: <b>{trial_keys_count}</b>\n"
|
||||
f"{tariff_stats_text}"
|
||||
f"</blockquote>\n"
|
||||
f"💰 <b>Финансы:</b>\n"
|
||||
f"<blockquote>"
|
||||
f"├ 📅 За день: <b>{total_payments_today} ₽</b>\n"
|
||||
f"├ 📆 Вчера: <b>{total_payments_yesterday} ₽</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"└ 🏦 Всего: <b>{total_payments_all_time} ₽</b>\n"
|
||||
f"</blockquote>\n"
|
||||
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
|
||||
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("\u041fроизошла ошибка при получении статистики", show_alert=True)
|
||||
await callback_query.answer(
|
||||
"Произошла ошибка при получении статистики", show_alert=True
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
|
||||
IsAdminFilter(),
|
||||
AdminPanelCallback.filter(F.action == "stats_export_users_csv"), IsAdminFilter()
|
||||
)
|
||||
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_export_users_csv(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_users_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт пользователей в CSV")
|
||||
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)
|
||||
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_payments_csv"),
|
||||
IsAdminFilter(),
|
||||
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), IsAdminFilter()
|
||||
)
|
||||
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_export_payments_csv(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_payments_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт платежей в CSV")
|
||||
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)
|
||||
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_hot_leads_csv"),
|
||||
IsAdminFilter(),
|
||||
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"), IsAdminFilter()
|
||||
)
|
||||
async def handle_export_hot_leads_csv(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_export_hot_leads_csv(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_hot_leads_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт горящих лидов")
|
||||
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)
|
||||
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(),
|
||||
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"), IsAdminFilter()
|
||||
)
|
||||
async def handle_export_keys_csv(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_export_keys_csv(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_keys_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт подписок в CSV")
|
||||
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)
|
||||
logger.error(f"Ошибка при экспорте подписок: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
async def send_daily_stats_report():
|
||||
async def send_daily_stats_report(session: AsyncSession):
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
today = datetime.utcnow().date()
|
||||
now_moscow = datetime.now(pytz.timezone("Europe/Moscow"))
|
||||
update_time = now_moscow.strftime("%d.%m.%y %H:%M")
|
||||
|
||||
registrations_today = await conn.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
|
||||
payments_today = int(await conn.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"))
|
||||
active_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE expiry_time > $1", int(datetime.utcnow().timestamp() * 1000))
|
||||
|
||||
update_time = datetime.now(pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y %H:%M")
|
||||
registrations_today = await count_users_registered_since(session, today)
|
||||
payments_today = await sum_payments_since(session, today)
|
||||
active_keys = await count_active_keys(session)
|
||||
|
||||
text = (
|
||||
"🗓️ <b>Сводка за день</b>\n\n"
|
||||
@@ -226,7 +233,5 @@ async def send_daily_stats_report():
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_message(admin_id, text)
|
||||
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Stats] Ошибка при отправке ежедневной статистики: {e}")
|
||||
logger.error(f"[Stats] Ошибка при отправке статистики: {e}")
|
||||
|
||||
@@ -11,23 +11,50 @@ class AdminTariffCallback(CallbackData, prefix="tariff"):
|
||||
|
||||
def build_tariff_menu_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🆕 Новый тариф", callback_data=AdminTariffCallback(action="create").pack()))
|
||||
builder.row(InlineKeyboardButton(text="📋 Мои тарифы", callback_data=AdminTariffCallback(action="list").pack()))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🆕 Новый тариф",
|
||||
callback_data=AdminTariffCallback(action="create").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📋 Мои тарифы",
|
||||
callback_data=AdminTariffCallback(action="list").pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_cancel_kb() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="❌ Отменить", callback_data="cancel_tariff_creation")]]
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отменить", callback_data="cancel_tariff_creation"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_tariff_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for group in groups:
|
||||
builder.button(text=group, callback_data=AdminTariffCallback(action=f"group|{group}").pack())
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminPanelCallback(action="tariffs").pack()))
|
||||
builder.button(
|
||||
text=group,
|
||||
callback_data=AdminTariffCallback(action=f"group|{group}").pack(),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminPanelCallback(action="tariffs").pack()
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -42,16 +69,24 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
|
||||
for t in tariffs:
|
||||
title = f"{t['name']} — {t['price_rub']}₽"
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=title, callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack())
|
||||
InlineKeyboardButton(
|
||||
text=title,
|
||||
callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить тариф", callback_data=AdminTariffCallback(action=f"create|{group_code}").pack()
|
||||
text="➕ Добавить тариф",
|
||||
callback_data=AdminTariffCallback(action=f"create|{group_code}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack()
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -61,13 +96,24 @@ def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать", callback_data=AdminTariffCallback(action=f"edit|{tariff_id}").pack()
|
||||
text="✏️ Редактировать",
|
||||
callback_data=AdminTariffCallback(
|
||||
action=f"edit|{tariff_id}"
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🗑 Удалить", callback_data=AdminTariffCallback(action=f"delete|{tariff_id}").pack()
|
||||
text="🗑 Удалить",
|
||||
callback_data=AdminTariffCallback(
|
||||
action=f"delete|{tariff_id}"
|
||||
).pack(),
|
||||
),
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack())],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminTariffCallback(action="list").pack(),
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
@@ -75,12 +121,39 @@ def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
|
||||
def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📝 Название", callback_data=f"edit_field|{tariff_id}|name")],
|
||||
[InlineKeyboardButton(text="📅 Длительность", callback_data=f"edit_field|{tariff_id}|duration_days")],
|
||||
[InlineKeyboardButton(text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub")],
|
||||
[InlineKeyboardButton(text="📦 Трафик (ГБ или 0)", callback_data=f"edit_field|{tariff_id}|traffic_limit")],
|
||||
[InlineKeyboardButton(text="📱 Лимит устройств", callback_data=f"edit_field|{tariff_id}|device_limit")],
|
||||
[InlineKeyboardButton(text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}")],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📝 Название", callback_data=f"edit_field|{tariff_id}|name"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📅 Длительность",
|
||||
callback_data=f"edit_field|{tariff_id}|duration_days",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📦 Трафик (ГБ или 0)",
|
||||
callback_data=f"edit_field|{tariff_id}|traffic_limit",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📱 Лимит устройств",
|
||||
callback_data=f"edit_field|{tariff_id}|device_limit",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}"
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view|{tariff_id}")],
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
)
|
||||
from sqlalchemy import delete, distinct, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import create_tariff
|
||||
from database.models import Key, Server, Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
@@ -17,7 +27,6 @@ from .keyboard import (
|
||||
build_tariff_menu_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -36,23 +45,39 @@ class TariffEditState(StatesGroup):
|
||||
editing_value = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter())
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()
|
||||
)
|
||||
async def handle_tariff_menu(callback_query: CallbackQuery):
|
||||
text = (
|
||||
"<b>💸 Управление тарифами</b>\n\n"
|
||||
"Здесь вы можете:\n"
|
||||
"• 🆕 Создать новый тариф (длительность, цена, лимит трафика)\n"
|
||||
"• 📋 Просмотреть и редактировать существующие тарифы"
|
||||
"Вы можете выполнить следующие действия:\n\n"
|
||||
"<b>🆕 Создать тариф</b>\n"
|
||||
"• Установите длительность (в днях)\n"
|
||||
"• Задайте цену (в рублях)\n"
|
||||
"• Задайте лимит устройств (hwid/ip_limit)\n"
|
||||
"• Укажите лимит трафика (в ГБ)\n\n"
|
||||
"<b>📋 Редактировать тарифы</b>\n"
|
||||
"• Просматривайте список текущих тарифов\n"
|
||||
"• Изменяйте параметры или удаляйте при необходимости"
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
text=text, reply_markup=build_tariff_menu_kb()
|
||||
)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_tariff_menu_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action == "create"), IsAdminFilter())
|
||||
@router.callback_query(
|
||||
AdminTariffCallback.filter(F.action == "create"), IsAdminFilter()
|
||||
)
|
||||
async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(TariffCreateState.group)
|
||||
await callback.message.edit_text(
|
||||
"📁 Введите <b>код группы</b>, в которую вы хотите добавить тариф.\n\n"
|
||||
"Например: <code>basic</code>, <code>vip</code>, <code>business</code>",
|
||||
"Например: <code>basic</code>, <code>vip</code>, <code>business</code>\n\n"
|
||||
"<b>Специальные группы:</b>\n"
|
||||
"• <code>discounts</code> — тарифы со скидкой\n"
|
||||
"• <code>discounts_max</code> — тарифы с максимальной скидкой\n"
|
||||
"• <code>gifts</code> — тарифы для подарков",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
|
||||
@@ -75,7 +100,8 @@ async def process_tariff_name(message: Message, state: FSMContext):
|
||||
await state.update_data(name=message.text.strip())
|
||||
await state.set_state(TariffCreateState.duration)
|
||||
await message.answer(
|
||||
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):", reply_markup=build_cancel_kb()
|
||||
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,7 +112,9 @@ async def process_tariff_duration(message: Message, state: FSMContext):
|
||||
if days <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное количество дней (целое число больше 0):")
|
||||
await message.answer(
|
||||
"❌ Введите корректное количество дней (целое число больше 0):"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(duration_days=days)
|
||||
@@ -111,7 +139,8 @@ async def process_tariff_price(message: Message, state: FSMContext):
|
||||
await state.update_data(price_rub=price)
|
||||
await state.set_state(TariffCreateState.traffic)
|
||||
await message.answer(
|
||||
"📦 Введите <b>лимит трафика в ГБ</b> (например: <i>100</i>, 0 — безлимит):", reply_markup=build_cancel_kb()
|
||||
"📦 Введите <b>лимит трафика в ГБ</b> (например: <i>100</i>, 0 — безлимит):",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
|
||||
|
||||
@@ -122,7 +151,9 @@ async def process_tariff_traffic(message: Message, state: FSMContext):
|
||||
if traffic < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректный лимит трафика (целое число 0 или больше):")
|
||||
await message.answer(
|
||||
"❌ Введите корректный лимит трафика (целое число 0 или больше):"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(traffic_limit=traffic * 1024**3 if traffic > 0 else None)
|
||||
@@ -134,13 +165,17 @@ async def process_tariff_traffic(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
@router.message(TariffCreateState.device_limit, IsAdminFilter())
|
||||
async def process_tariff_device_limit(message: Message, state: FSMContext, session):
|
||||
async def process_tariff_device_limit(
|
||||
message: Message, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
try:
|
||||
device_limit = int(message.text.strip())
|
||||
if device_limit < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректный лимит устройств (целое число 0 или больше):")
|
||||
await message.answer(
|
||||
"❌ Введите корректный лимит устройств (целое число 0 или больше):"
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -159,13 +194,15 @@ async def process_tariff_device_limit(message: Message, state: FSMContext, sessi
|
||||
|
||||
await state.set_state(TariffCreateState.confirm_more)
|
||||
await message.answer(
|
||||
f"✅ Тариф <b>{new_tariff['name']}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
|
||||
f"✅ Тариф <b>{new_tariff.name}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
|
||||
"➕ Хотите добавить ещё один тариф в эту группу?",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
|
||||
InlineKeyboardButton(text="❌ Нет", callback_data="done_tariff_group"),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет", callback_data="done_tariff_group"
|
||||
),
|
||||
]
|
||||
]
|
||||
),
|
||||
@@ -175,86 +212,148 @@ async def process_tariff_device_limit(message: Message, state: FSMContext, sessi
|
||||
@router.callback_query(F.data == "add_more_tariff", IsAdminFilter())
|
||||
async def handle_add_more_tariff(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(TariffCreateState.name)
|
||||
await callback.message.edit_text("📝 Введите <b>название следующего тарифа</b>:", reply_markup=build_cancel_kb())
|
||||
await callback.message.edit_text(
|
||||
"📝 Введите <b>название следующего тарифа</b>:", reply_markup=build_cancel_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "done_tariff_group", IsAdminFilter())
|
||||
async def handle_done_tariff_group(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback.message.edit_text("✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb())
|
||||
await callback.message.edit_text(
|
||||
"✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_tariff_creation", IsAdminFilter())
|
||||
async def cancel_tariff_creation(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback.message.edit_text("❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb())
|
||||
await callback.message.edit_text(
|
||||
"❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action == "list"), IsAdminFilter())
|
||||
async def show_tariff_groups(callback: CallbackQuery, session):
|
||||
rows = await session.fetch(
|
||||
"SELECT DISTINCT group_code FROM tariffs WHERE group_code IS NOT NULL ORDER BY group_code"
|
||||
async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(
|
||||
select(distinct(Tariff.group_code))
|
||||
.where(Tariff.group_code.isnot(None))
|
||||
.order_by(Tariff.group_code)
|
||||
)
|
||||
groups = [r["group_code"] for r in rows]
|
||||
groups = [row[0] for row in result.fetchall()]
|
||||
|
||||
if not groups:
|
||||
await callback.message.edit_text("❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb())
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb()
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text("<b>📋 Выберите тарифную группу:</b>", reply_markup=build_tariff_groups_kb(groups))
|
||||
special_groups = {
|
||||
"discounts": "🔻 Скидки",
|
||||
"discounts_max": "🔻 Макс. скидки",
|
||||
"gifts": "🎁 Подарки",
|
||||
}
|
||||
|
||||
text = "<b>📋 Выберите тарифную группу:</b>\n\n"
|
||||
text += "<b>Специальные группы:</b>\n"
|
||||
for code, label in special_groups.items():
|
||||
status = "✅ создана" if code in groups else "❌ не создана"
|
||||
text += f"{label} — <code>{code}</code> — <b>{status}</b>\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=build_tariff_groups_kb(groups))
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter())
|
||||
async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTariffCallback, session):
|
||||
def tariff_to_dict(tariff: Tariff) -> dict:
|
||||
return {
|
||||
"id": tariff.id,
|
||||
"name": tariff.name,
|
||||
"price_rub": tariff.price_rub,
|
||||
"group_code": tariff.group_code,
|
||||
}
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter()
|
||||
)
|
||||
async def show_tariffs_in_group(
|
||||
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
|
||||
):
|
||||
group_code = callback_data.action.split("|", 1)[1]
|
||||
rows = await session.fetch("SELECT * FROM tariffs WHERE group_code = $1 ORDER BY id", group_code)
|
||||
tariffs = [dict(r) for r in rows]
|
||||
|
||||
result = await session.execute(
|
||||
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
|
||||
return
|
||||
|
||||
tariff_dicts = [tariff_to_dict(t) for t in tariffs]
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"<b>📦 Тарифы группы: {group_code}</b>", reply_markup=build_tariff_list_kb(tariffs)
|
||||
f"<b>📦 Тарифы группы: {group_code}</b>",
|
||||
reply_markup=build_tariff_list_kb(tariff_dicts),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter())
|
||||
async def view_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, session):
|
||||
@router.callback_query(
|
||||
AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter()
|
||||
)
|
||||
async def view_tariff(
|
||||
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
|
||||
):
|
||||
tariff_id = int(callback_data.action.split("|", 1)[1])
|
||||
tariff = await session.fetchrow("SELECT * FROM tariffs WHERE id = $1", tariff_id)
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
t = dict(tariff)
|
||||
traffic_text = f"{t['traffic_limit'] // 1024**3} ГБ" if t["traffic_limit"] else "Безлимит"
|
||||
device_text = f"{t['device_limit']}" if t["device_limit"] is not None else "Безлимит"
|
||||
|
||||
text = (
|
||||
f"<b>📄 Тариф: {t['name']}</b>\n\n"
|
||||
f"📁 Группа: <code>{t['group_code']}</code>\n"
|
||||
f"📅 Длительность: <b>{t['duration_days']} дней</b>\n"
|
||||
f"💰 Стоимость: <b>{t['price_rub']}₽</b>\n"
|
||||
f"📦 Трафик: <b>{traffic_text}</b>\n"
|
||||
f"📱 Устройств: <b>{device_text}</b>\n"
|
||||
f"{'✅ Активен' if t['is_active'] else '⛔ Отключен'}"
|
||||
traffic_text = (
|
||||
f"{tariff.traffic_limit // 1024**3} ГБ" if tariff.traffic_limit else "Безлимит"
|
||||
)
|
||||
device_text = (
|
||||
f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=build_single_tariff_kb(tariff_id))
|
||||
text = (
|
||||
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
|
||||
f"📁 Группа: <code>{tariff.group_code}</code>\n"
|
||||
f"📅 Длительность: <b>{tariff.duration_days} дней</b>\n"
|
||||
f"💰 Стоимость: <b>{tariff.price_rub}₽</b>\n"
|
||||
f"📦 Трафик: <b>{traffic_text}</b>\n"
|
||||
f"📱 Устройств: <b>{device_text}</b>\n"
|
||||
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text, reply_markup=build_single_tariff_kb(tariff_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter())
|
||||
async def confirm_tariff_deletion(callback: CallbackQuery, callback_data: AdminTariffCallback):
|
||||
@router.callback_query(
|
||||
AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter()
|
||||
)
|
||||
async def confirm_tariff_deletion(
|
||||
callback: CallbackQuery, callback_data: AdminTariffCallback
|
||||
):
|
||||
tariff_id = int(callback_data.action.split("|", 1)[1])
|
||||
await callback.message.edit_text(
|
||||
"⚠️ Вы уверены, что хотите <b>удалить</b> этот тариф?",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view|{tariff_id}"),
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена", callback_data=f"view|{tariff_id}"
|
||||
),
|
||||
]
|
||||
]
|
||||
),
|
||||
@@ -262,32 +361,54 @@ async def confirm_tariff_deletion(callback: CallbackQuery, callback_data: AdminT
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
|
||||
async def delete_tariff(callback: CallbackQuery, session):
|
||||
async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
|
||||
tariff_id = int(callback.data.split("|", 1)[1])
|
||||
row = await session.fetchrow("SELECT group_code FROM tariffs WHERE id = $1", tariff_id)
|
||||
if not row:
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if not tariff:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
group_code = row["group_code"]
|
||||
group_code = tariff.group_code
|
||||
|
||||
await session.execute("UPDATE keys SET tariff_id = NULL WHERE tariff_id = $1", tariff_id)
|
||||
await session.execute("DELETE FROM tariffs WHERE id = $1", tariff_id)
|
||||
await session.execute(
|
||||
update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None)
|
||||
)
|
||||
|
||||
remaining = await session.fetchval("SELECT COUNT(*) FROM tariffs WHERE group_code = $1", group_code)
|
||||
if remaining == 0:
|
||||
await session.execute("UPDATE servers SET tariff_group = NULL WHERE tariff_group = $1", group_code)
|
||||
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
|
||||
|
||||
await callback.message.edit_text("🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb())
|
||||
result = await session.execute(
|
||||
select(Tariff).where(Tariff.group_code == group_code)
|
||||
)
|
||||
remaining_tariffs = result.scalars().all()
|
||||
|
||||
if not remaining_tariffs:
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.tariff_group == group_code)
|
||||
.values(tariff_group=None)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await callback.message.edit_text(
|
||||
"🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter())
|
||||
async def start_edit_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext):
|
||||
@router.callback_query(
|
||||
AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter()
|
||||
)
|
||||
async def start_edit_tariff(
|
||||
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
|
||||
):
|
||||
tariff_id = int(callback_data.action.split("|")[1])
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
await state.set_state(TariffEditState.choosing_field)
|
||||
await callback.message.edit_text(
|
||||
"<b>✏️ Что вы хотите изменить?</b>", reply_markup=build_edit_tariff_fields_kb(tariff_id)
|
||||
"<b>✏️ Что вы хотите изменить?</b>",
|
||||
reply_markup=build_edit_tariff_fields_kb(tariff_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -306,17 +427,26 @@ async def ask_new_value(callback: CallbackQuery, state: FSMContext):
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:", reply_markup=build_cancel_kb()
|
||||
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
||||
reply_markup=build_cancel_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(TariffEditState.editing_value, IsAdminFilter())
|
||||
async def apply_edit(message: Message, state: FSMContext, session):
|
||||
async def apply_edit(message: Message, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
tariff_id = data["tariff_id"]
|
||||
field = data["field"]
|
||||
value = message.text.strip()
|
||||
|
||||
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if not tariff:
|
||||
await message.answer("❌ Тариф не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]:
|
||||
try:
|
||||
num = int(value)
|
||||
@@ -332,7 +462,10 @@ async def apply_edit(message: Message, state: FSMContext, session):
|
||||
await message.answer("❌ Введите корректное число.")
|
||||
return
|
||||
|
||||
await session.execute(f"UPDATE tariffs SET {field} = $1, updated_at = NOW() WHERE id = $2", value, tariff_id)
|
||||
setattr(tariff, field, value)
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
await state.clear()
|
||||
await message.answer("✅ Тариф успешно обновлён.")
|
||||
|
||||
@@ -340,20 +473,28 @@ async def apply_edit(message: Message, state: FSMContext, session):
|
||||
@router.callback_query(F.data.startswith("toggle_active|"), IsAdminFilter())
|
||||
async def toggle_tariff_status(callback: CallbackQuery, session):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
row = await session.fetchrow("SELECT is_active FROM tariffs WHERE id = $1", tariff_id)
|
||||
row = await session.fetchrow(
|
||||
"SELECT is_active FROM tariffs WHERE id = $1", tariff_id
|
||||
)
|
||||
|
||||
if not row:
|
||||
await callback.message.edit_text("❌ Тариф не найден.")
|
||||
return
|
||||
|
||||
new_status = not row["is_active"]
|
||||
await session.execute("UPDATE tariffs SET is_active = $1, updated_at = NOW() WHERE id = $2", new_status, tariff_id)
|
||||
await session.execute(
|
||||
"UPDATE tariffs SET is_active = $1, updated_at = NOW() WHERE id = $2",
|
||||
new_status,
|
||||
tariff_id,
|
||||
)
|
||||
|
||||
status_text = "✅ Тариф активирован." if new_status else "⛔ Тариф отключён."
|
||||
await callback.message.edit_text(status_text)
|
||||
|
||||
|
||||
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter())
|
||||
@router.callback_query(
|
||||
AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter()
|
||||
)
|
||||
async def start_tariff_creation_existing_group(
|
||||
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
|
||||
):
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import DATABASE_URL, HWID_RESET_BUTTON
|
||||
from database import get_clusters, get_tariffs, get_tariffs_for_cluster
|
||||
from config import HWID_RESET_BUTTON
|
||||
from database import get_clusters
|
||||
from database.models import Key, Server, Tariff
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
@@ -28,45 +29,65 @@ class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
|
||||
edit: bool = False
|
||||
|
||||
|
||||
def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = False) -> InlineKeyboardMarkup:
|
||||
def build_user_edit_kb(
|
||||
tg_id: int, key_records: list, is_banned: bool = False
|
||||
) -> 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(),
|
||||
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)
|
||||
email = record.email
|
||||
expiry = datetime.fromtimestamp(record.expiry_time / 1000, tz=timezone.utc)
|
||||
days = (expiry - current_time).days
|
||||
builder.button(
|
||||
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=str(email)).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_key_edit", tg_id=tg_id, data=str(email)
|
||||
).pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="✉️ Сообщение", callback_data=AdminUserEditorCallback(action="users_send_message", tg_id=tg_id).pack()
|
||||
text="✉️ Сообщение",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_send_message", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="💸 Изменить баланс",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
|
||||
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(),
|
||||
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(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_trial_restore", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="❌ Удалить клиента", callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack()
|
||||
text="❌ Удалить клиента",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_delete_user", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="✅ Разблокировать" if is_banned else "🚫 Заблокировать",
|
||||
callback_data=AdminUserEditorCallback(action="users_unban" if is_banned else "users_ban", tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_unban" if is_banned else "users_ban", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
|
||||
builder.row(build_admin_back_btn())
|
||||
@@ -78,50 +99,62 @@ def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_balance_edit", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
async def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
async def build_users_balance_kb(
|
||||
session: AsyncSession, tg_id: int
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
tariffs = await get_tariffs(conn)
|
||||
for tariff in tariffs:
|
||||
months = tariff["duration_days"] // 30
|
||||
if months < 1:
|
||||
continue
|
||||
price = tariff["price_rub"]
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"+ {price}₽ ({months} мес.)",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=price).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"- {price}₽ ({months} мес.)",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=-price).pack(),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
result = await session.execute(select(Tariff))
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
for tariff in tariffs:
|
||||
months = tariff.duration_days // 30
|
||||
if months < 1:
|
||||
continue
|
||||
price = tariff.price_rub
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"+ {price}₽ ({months} мес.)",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_balance_add", tg_id=tg_id, data=price
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"- {price}₽ ({months} мес.)",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_balance_add", tg_id=tg_id, data=-price
|
||||
).pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💵 Добавить",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_balance_add", tg_id=tg_id
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="💵 Вычесть",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_balance_take", tg_id=tg_id
|
||||
).pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💵 Установить баланс",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_balance_set", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -134,71 +167,88 @@ def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_key_edit", tg_id=tg_id, data=email, edit=True
|
||||
).pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
async def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
async def build_users_key_expiry_kb(
|
||||
session: AsyncSession, tg_id: int, email: str
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow("SELECT server_id FROM keys WHERE email = $1", email)
|
||||
if not record:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Сервер не найден",
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
|
||||
)
|
||||
result = await session.execute(select(Key.server_id).where(Key.email == email))
|
||||
row = result.first()
|
||||
if not row or not row[0]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Сервер не найден",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_key_edit", tg_id=tg_id, data=email
|
||||
).pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
server_id = record["server_id"]
|
||||
server_id = row[0]
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(conn, server_id)
|
||||
for tariff in tariffs:
|
||||
months = tariff["duration_days"] // 30
|
||||
if months < 1:
|
||||
continue
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"+ {months} мес.",
|
||||
callback_data=AdminUserKeyEditorCallback(
|
||||
action="add", tg_id=tg_id, data=email, month=months
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"- {months} мес.",
|
||||
callback_data=AdminUserKeyEditorCallback(
|
||||
action="add", tg_id=tg_id, data=email, month=-months
|
||||
).pack(),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
result = await session.execute(
|
||||
select(Tariff)
|
||||
.join(Server, Tariff.group_code == Server.tariff_group)
|
||||
.where(Server.cluster_name == server_id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
for tariff in tariffs:
|
||||
months = tariff.duration_days // 30
|
||||
if months < 1:
|
||||
continue
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"+ {months} мес.",
|
||||
callback_data=AdminUserKeyEditorCallback(
|
||||
action="add", tg_id=tg_id, data=email, month=months
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"- {months} мес.",
|
||||
callback_data=AdminUserKeyEditorCallback(
|
||||
action="add", tg_id=tg_id, data=email, month=-months
|
||||
).pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Добавить дни",
|
||||
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack(),
|
||||
callback_data=AdminUserKeyEditorCallback(
|
||||
action="add", tg_id=tg_id, data=email
|
||||
).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Вычесть дни",
|
||||
callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack(),
|
||||
callback_data=AdminUserKeyEditorCallback(
|
||||
action="take", tg_id=tg_id, data=email
|
||||
).pack(),
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Установить дату истечения",
|
||||
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
|
||||
callback_data=AdminUserKeyEditorCallback(
|
||||
action="set", tg_id=tg_id, data=email
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_key_edit", tg_id=tg_id, data=email
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -209,7 +259,9 @@ def build_user_delete_kb(tg_id: int):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="❌ Да, удалить!",
|
||||
callback_data=AdminUserEditorCallback(action="users_delete_user_confirm", tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_delete_user_confirm", tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.row(build_editor_back_btn(tg_id, True))
|
||||
builder.adjust(1)
|
||||
@@ -219,7 +271,10 @@ def build_user_delete_kb(tg_id: int):
|
||||
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=BACK, 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()
|
||||
@@ -227,6 +282,7 @@ def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
|
||||
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="⏳ Время истечения",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
@@ -235,15 +291,27 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||
)
|
||||
builder.button(
|
||||
text="🔄 Перевыпустить",
|
||||
callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_update_key", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📦 Тариф",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_renew", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="❌ Удалить",
|
||||
callback_data=AdminUserEditorCallback(action="users_delete_key", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_delete_key", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📊 Трафик",
|
||||
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_traffic", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="♻️ Сбросить трафик",
|
||||
@@ -258,6 +326,7 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||
action="users_hwid_menu", 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()
|
||||
@@ -267,11 +336,15 @@ def build_hwid_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="♻️ Сбросить HWID",
|
||||
callback_data=AdminUserEditorCallback(action="users_hwid_reset", data=email, tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_hwid_reset", data=email, tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_key_edit", data=email, tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -282,7 +355,9 @@ def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=AdminUserEditorCallback(action="users_delete_key_confirm", data=email, tg_id=tg_id).pack(),
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_delete_key_confirm", data=email, tg_id=tg_id
|
||||
).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(build_editor_back_btn(tg_id))
|
||||
@@ -294,7 +369,9 @@ def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
|
||||
return build_editor_singleton_kb(BACK, tg_id, edit)
|
||||
|
||||
|
||||
def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
|
||||
def build_editor_singleton_kb(
|
||||
text: str, tg_id: int, edit: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(build_editor_btn(text, tg_id, edit))
|
||||
return builder.as_markup()
|
||||
@@ -306,16 +383,23 @@ def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButto
|
||||
|
||||
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()
|
||||
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:
|
||||
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=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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -12,12 +12,13 @@ CANCEL = "❌ Отмена"
|
||||
|
||||
ADD_SUB = "➕ Добавить новую подписку"
|
||||
MY_SUBS = "📱 Мои подписки"
|
||||
BALANCE = "💰 Баланс"
|
||||
BALANCE = "💵 Баланс"
|
||||
INVITE = "👥 Пригласить"
|
||||
GIFTS = "🎁 Подарить"
|
||||
INSTRUCTIONS = "📘 Инструкции"
|
||||
TOP_FIVE = "🏆 Топ-5"
|
||||
TRIAL_SUB = "🎁 Пробная подписка"
|
||||
MY_SUB = "🔐 Моя подписка"
|
||||
|
||||
# Меню Оплат и баланса
|
||||
|
||||
|
||||
+9
-5
@@ -1,6 +1,5 @@
|
||||
import random
|
||||
import secrets
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
@@ -13,13 +12,14 @@ from logger import logger
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def generate_captcha(message: Message, state: FSMContext):
|
||||
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
|
||||
wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
|
||||
wrong_emojis = random.sample(
|
||||
[e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3
|
||||
)
|
||||
|
||||
all_emojis = [correct_emoji] + wrong_emojis
|
||||
random.shuffle(all_emojis)
|
||||
@@ -29,7 +29,9 @@ async def generate_captcha(message: Message, state: FSMContext):
|
||||
if "user_data" not in state_data:
|
||||
from_user = message.from_user
|
||||
if not from_user:
|
||||
logger.warning("[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data")
|
||||
logger.warning(
|
||||
"[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data"
|
||||
)
|
||||
return None
|
||||
|
||||
await state.update_data(
|
||||
@@ -67,7 +69,9 @@ async def generate_captcha(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("captcha_"))
|
||||
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
|
||||
async def check_captcha(
|
||||
callback: CallbackQuery, state: FSMContext, session: Any, admin: bool
|
||||
):
|
||||
from handlers.start import process_start_logic
|
||||
|
||||
selected_emoji = callback.data.split("captcha_")[1]
|
||||
|
||||
+93
-55
@@ -1,15 +1,15 @@
|
||||
import html
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import ADMIN_ID
|
||||
from database import (
|
||||
@@ -45,7 +45,9 @@ router = Router()
|
||||
|
||||
@router.callback_query(F.data == "activate_coupon")
|
||||
@router.message(F.text == "/activate_coupon")
|
||||
async def handle_activate_coupon(callback_query_or_message: Message | CallbackQuery, state: FSMContext):
|
||||
async def handle_activate_coupon(
|
||||
callback_query_or_message: Message | CallbackQuery, state: FSMContext
|
||||
):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
@@ -72,21 +74,23 @@ async def process_coupon_code(message: Message, state: FSMContext, session: Any)
|
||||
async def activate_coupon(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
session: AsyncSession,
|
||||
coupon_code: str,
|
||||
admin: bool = False,
|
||||
user_data: dict | None = None,
|
||||
):
|
||||
logger.info(f"Активация купона: {coupon_code}")
|
||||
coupon_record = await get_coupon_by_code(coupon_code, session)
|
||||
coupon = await get_coupon_by_code(session, coupon_code)
|
||||
|
||||
if not coupon_record:
|
||||
if not coupon:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input")
|
||||
)
|
||||
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
if coupon_record["usage_count"] >= coupon_record["usage_limit"] or coupon_record["is_used"]:
|
||||
if coupon.usage_count >= coupon.usage_limit or coupon.is_used:
|
||||
await message.answer("❌ Лимит активаций купона исчерпан.")
|
||||
await state.clear()
|
||||
return
|
||||
@@ -94,33 +98,35 @@ async def activate_coupon(
|
||||
user = user_data or message.from_user or message.chat
|
||||
user_id = user["tg_id"] if isinstance(user, dict) else user.id
|
||||
|
||||
usage = await check_coupon_usage(coupon_record["id"], user_id, session)
|
||||
usage = await check_coupon_usage(session, coupon.id, user_id)
|
||||
if usage:
|
||||
await message.answer(COUPON_ALREADY_USED_MSG)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
user_exists = await check_user_exists(user_id)
|
||||
user_exists = await check_user_exists(session, user_id)
|
||||
if not user_exists:
|
||||
if isinstance(user, dict):
|
||||
await add_user(session=session, **user)
|
||||
else:
|
||||
await add_user(
|
||||
session=session,
|
||||
tg_id=user.id,
|
||||
username=getattr(user, "username", None),
|
||||
first_name=getattr(user, "first_name", None),
|
||||
last_name=getattr(user, "last_name", None),
|
||||
language_code=getattr(user, "language_code", None),
|
||||
is_bot=getattr(user, "is_bot", False),
|
||||
session=session,
|
||||
)
|
||||
|
||||
if coupon_record["amount"] > 0:
|
||||
if coupon.amount > 0:
|
||||
try:
|
||||
await update_balance(user_id, coupon_record["amount"], session, skip_referral=True)
|
||||
await update_coupon_usage_count(coupon_record["id"], session)
|
||||
await create_coupon_usage(coupon_record["id"], user_id, session)
|
||||
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon_record['amount']} рублей.")
|
||||
await update_balance(session, user_id, coupon.amount)
|
||||
await update_coupon_usage_count(session, coupon.id)
|
||||
await create_coupon_usage(session, coupon.id, user_id)
|
||||
await message.answer(
|
||||
f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей."
|
||||
)
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при активации купона на баланс: {e}")
|
||||
@@ -128,10 +134,10 @@ async def activate_coupon(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if coupon_record["days"] is not None and coupon_record["days"] > 0:
|
||||
if coupon.days:
|
||||
try:
|
||||
keys = await get_keys(user_id, session)
|
||||
active_keys = [k for k in keys if not k["is_frozen"]]
|
||||
keys = await get_keys(session, user_id)
|
||||
active_keys = [k for k in keys if not k.is_frozen]
|
||||
|
||||
if not active_keys:
|
||||
await message.answer("❌ У вас нет активных подписок для продления.")
|
||||
@@ -140,18 +146,20 @@ async def activate_coupon(
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
|
||||
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")
|
||||
key_display = html.escape((key.alias or key.email).strip())
|
||||
expiry_date = datetime.fromtimestamp(
|
||||
key.expiry_time / 1000, tz=moscow_tz
|
||||
).strftime("до %d.%m.%y, %H:%M")
|
||||
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
|
||||
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon_record['id']}")
|
||||
builder.button(
|
||||
text=key_display,
|
||||
callback_data=f"extend_key|{key.client_id}|{coupon.id}",
|
||||
)
|
||||
|
||||
response_message += "</blockquote>"
|
||||
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
|
||||
@@ -159,7 +167,7 @@ async def activate_coupon(
|
||||
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
await state.set_state(CouponActivationState.waiting_for_key_selection)
|
||||
await state.update_data(coupon_id=coupon_record["id"], user_id=user_id)
|
||||
await state.update_data(coupon_id=coupon.id, user_id=user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке купона на дни: {e}")
|
||||
await message.answer("❌ Ошибка при активации купона.")
|
||||
@@ -171,52 +179,71 @@ async def activate_coupon(
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("extend_key|"))
|
||||
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
|
||||
async def handle_key_extension(
|
||||
callback_query: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
admin: bool = False,
|
||||
):
|
||||
from database.models import Coupon, Key
|
||||
|
||||
parts = callback_query.data.split("|")
|
||||
client_id = parts[1]
|
||||
coupon_id = int(parts[2])
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
try:
|
||||
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
|
||||
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
|
||||
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
|
||||
result = await session.execute(select(Coupon).where(Coupon.id == coupon_id))
|
||||
coupon = result.scalar_one_or_none()
|
||||
if not coupon or coupon.usage_count >= coupon.usage_limit:
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Купон недействителен или лимит исчерпан."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
usage = await check_coupon_usage(coupon_id, callback_query.from_user.id, session)
|
||||
usage = await check_coupon_usage(session, coupon.id, tg_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
|
||||
result = await session.execute(
|
||||
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||
)
|
||||
if not key or key["is_frozen"]:
|
||||
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
|
||||
key = result.scalar_one_or_none()
|
||||
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)
|
||||
current_expiry = key.expiry_time
|
||||
new_expiry = max(now_ms, current_expiry) + (coupon.days * 86400 * 1000)
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=key["server_id"], email=key["email"], client_id=client_id, new_expiry_time=new_expiry, total_gb=0
|
||||
cluster_id=key.server_id,
|
||||
email=key.email,
|
||||
client_id=client_id,
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=0,
|
||||
)
|
||||
await update_key_expiry(client_id, new_expiry, session)
|
||||
await update_key_expiry(session, client_id, new_expiry)
|
||||
await update_coupon_usage_count(session, coupon.id)
|
||||
await create_coupon_usage(session, coupon.id, tg_id)
|
||||
|
||||
await update_coupon_usage_count(coupon["id"], session)
|
||||
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
|
||||
|
||||
alias = key.get("alias") or key["email"]
|
||||
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime(
|
||||
"%d.%m.%y, %H:%M"
|
||||
)
|
||||
alias = key.alias or key.email
|
||||
expiry_date = datetime.fromtimestamp(
|
||||
new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")
|
||||
).strftime("%d.%m.%y, %H:%M")
|
||||
await callback_query.message.answer(
|
||||
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
|
||||
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon.days)}⏳ до {expiry_date}📆."
|
||||
)
|
||||
await process_callback_view_profile(
|
||||
callback_query.message, state, admin, session
|
||||
)
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при продлении ключа: {e}")
|
||||
@@ -225,14 +252,25 @@ async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext,
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_coupon_activation")
|
||||
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
|
||||
async def cancel_coupon_activation(
|
||||
callback_query: CallbackQuery,
|
||||
state: FSMContext,
|
||||
admin: bool = False,
|
||||
session: AsyncSession = None,
|
||||
):
|
||||
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await process_callback_view_profile(callback_query.message, state, admin, session)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exit_coupon_input")
|
||||
async def handle_exit_coupon_input(callback_query: CallbackQuery, state: FSMContext):
|
||||
async def handle_exit_coupon_input(
|
||||
callback_query: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: AsyncSession = None,
|
||||
):
|
||||
await state.clear()
|
||||
is_admin = callback_query.from_user.id in ADMIN_ID
|
||||
await process_callback_view_profile(callback_query.message, state, admin=is_admin)
|
||||
await process_callback_view_profile(
|
||||
callback_query.message, state, admin=is_admin, session=session
|
||||
)
|
||||
|
||||
+15
-3
@@ -1,7 +1,13 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
LabeledPrice,
|
||||
Message,
|
||||
PreCheckoutQuery,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import RUB_TO_XTR
|
||||
@@ -25,7 +31,11 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести сумму доната",
|
||||
@@ -67,7 +77,9 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount // RUB_TO_XTR <= 0:
|
||||
await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:")
|
||||
await message.answer(
|
||||
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import SUPPORT_CHAT_URL
|
||||
from handlers.buttons import MAIN_MENU, SUPPORT
|
||||
from handlers.texts import FALLBACK_MESSAGE
|
||||
|
||||
fallback_router = Router()
|
||||
|
||||
|
||||
@fallback_router.message(F.text)
|
||||
async def handle_unhandled_messages(message: Message):
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
keyboard.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
keyboard.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await message.answer(
|
||||
FALLBACK_MESSAGE,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
@@ -4,7 +4,6 @@ from aiogram import Router
|
||||
|
||||
from .instructions import router as instructions_router
|
||||
|
||||
|
||||
router = Router(name="instructions_main_router")
|
||||
|
||||
router.include_routers(
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
Message,
|
||||
)
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
@@ -34,15 +29,14 @@ from handlers.buttons import (
|
||||
from handlers.texts import (
|
||||
CHOOSE_DEVICE_TEXT,
|
||||
CONNECT_TV_TEXT,
|
||||
INSTRUCTIONS,
|
||||
INSTRUCTION_MACOS,
|
||||
INSTRUCTION_PC,
|
||||
INSTRUCTIONS,
|
||||
KEY_MESSAGE,
|
||||
SUBSCRIPTION_DETAILS_TEXT,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -72,7 +66,7 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
|
||||
@router.callback_query(F.data.startswith("connect_pc|"))
|
||||
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(key_name, session)
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
@@ -85,8 +79,12 @@ 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=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(
|
||||
@@ -100,14 +98,16 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
|
||||
@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)
|
||||
record = await get_key_details(session, key_name)
|
||||
key = record["key"]
|
||||
key_message_text = KEY_MESSAGE.format(key)
|
||||
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
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=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}"))
|
||||
|
||||
@@ -122,14 +122,16 @@ async def process_windows_menu(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)
|
||||
record = await get_key_details(session, key_name)
|
||||
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=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}"))
|
||||
|
||||
@@ -146,7 +148,9 @@ async def process_connect_tv(callback_query: CallbackQuery):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}"))
|
||||
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"))
|
||||
|
||||
@@ -163,12 +167,16 @@ async def process_connect_tv(callback_query: CallbackQuery):
|
||||
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record = await get_key_details(key_name, session)
|
||||
record = await get_key_details(session, key_name)
|
||||
subscription_link = record["key"]
|
||||
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=TV_INSTRUCTIONS, url="https://vpn4tv.com/quick-guide.html"))
|
||||
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"))
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ from .key_renew import router as renew_router
|
||||
from .key_view import router as view_router
|
||||
from .keys import router as keys_router
|
||||
|
||||
|
||||
router = Router(name="keys_main_router")
|
||||
|
||||
router.include_routers(keys_router, view_router, renew_router, freeze_router, connect_router, key_mode_router)
|
||||
router.include_routers(
|
||||
keys_router,
|
||||
view_router,
|
||||
renew_router,
|
||||
freeze_router,
|
||||
connect_router,
|
||||
key_mode_router,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import os
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import qrcode
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, INSTRUCTIONS_BUTTON
|
||||
from config import (
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
)
|
||||
from database.models import Key
|
||||
from handlers.buttons import (
|
||||
ANDROID,
|
||||
BACK,
|
||||
@@ -33,7 +38,6 @@ from handlers.texts import (
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -43,12 +47,24 @@ async def handle_connect_device(callback_query: CallbackQuery):
|
||||
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=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}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -62,31 +78,25 @@ async def handle_connect_device(callback_query: CallbackQuery):
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_phone|"))
|
||||
async def process_callback_connect_phone(callback_query: CallbackQuery):
|
||||
async def process_callback_connect_phone(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
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:
|
||||
result = await session.execute(select(Key.key).where(Key.email == email))
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if not row:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
key_link = row
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
|
||||
|
||||
@@ -100,35 +110,37 @@ async def process_callback_connect_phone(callback_query: CallbackQuery):
|
||||
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=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
|
||||
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):
|
||||
async def process_callback_connect_ios(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
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:
|
||||
result = await session.execute(select(Key.key).where(Key.email == email))
|
||||
key_link = result.scalar_one_or_none()
|
||||
|
||||
if not key_link:
|
||||
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)
|
||||
|
||||
@@ -136,8 +148,12 @@ async def process_callback_connect_ios(callback_query: CallbackQuery):
|
||||
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=MANUAL_INSTRUCTIONS, callback_data="instructions")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -149,35 +165,40 @@ async def process_callback_connect_ios(callback_query: CallbackQuery):
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_android|"))
|
||||
async def process_callback_connect_android(callback_query: CallbackQuery):
|
||||
async def process_callback_connect_android(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
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:
|
||||
result = await session.execute(select(Key.key).where(Key.email == email))
|
||||
key_link = result.scalar_one_or_none()
|
||||
|
||||
if not key_link:
|
||||
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}"))
|
||||
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=MANUAL_INSTRUCTIONS, callback_data="instructions")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -189,17 +210,27 @@ async def process_callback_connect_android(callback_query: CallbackQuery):
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("show_qr|"))
|
||||
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
|
||||
async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSession):
|
||||
try:
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
|
||||
stmt = select(Key).where(Key.email == key_name)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalars().first()
|
||||
|
||||
if not record:
|
||||
await callback_query.message.answer("❌ Подписка не найдена.")
|
||||
return
|
||||
|
||||
qr_data = record.key or record.remnawave_link
|
||||
if not qr_data:
|
||||
await callback_query.message.answer(
|
||||
"❌ У этой подписки отсутствует ссылка для подключения."
|
||||
)
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(record["key"])
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
@@ -207,12 +238,14 @@ async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
qr_path = f"/tmp/qrcode_{record['email']}.png"
|
||||
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=BACK, callback_data=f"view_key|{record.email}")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
|
||||
+112
-102
@@ -1,23 +1,14 @@
|
||||
import time
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import (
|
||||
get_key_details,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
APPLY,
|
||||
BACK,
|
||||
CANCEL,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
renew_key_in_cluster,
|
||||
toggle_client_on_cluster,
|
||||
)
|
||||
from database import get_key_details, mark_key_as_frozen, mark_key_as_unfrozen
|
||||
from handlers.buttons import APPLY, BACK, CANCEL
|
||||
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
|
||||
from handlers.texts import (
|
||||
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
SUBSCRIPTION_FROZEN_MSG,
|
||||
@@ -25,13 +16,15 @@ from handlers.texts import (
|
||||
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, handle_error
|
||||
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
|
||||
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
|
||||
async def process_callback_unfreeze_subscription(
|
||||
callback_query: CallbackQuery, session: Any
|
||||
):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||
|
||||
@@ -55,7 +48,9 @@ async def process_callback_unfreeze_subscription(callback_query: CallbackQuery,
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
|
||||
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
async def process_callback_unfreeze_subscription_confirm(
|
||||
callback_query: CallbackQuery, session: Any
|
||||
):
|
||||
"""
|
||||
Размораживает (включает) подписку.
|
||||
"""
|
||||
@@ -63,7 +58,7 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
@@ -72,80 +67,98 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
|
||||
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,
|
||||
)
|
||||
tariff = await session.fetchrow(
|
||||
"""
|
||||
SELECT t.*
|
||||
FROM tariffs t
|
||||
JOIN servers s ON s.tariff_group = t.tariff_group
|
||||
WHERE s.server_name = $1
|
||||
ORDER BY t.duration_days DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
cluster_id,
|
||||
)
|
||||
|
||||
if not tariff or not tariff["traffic_limit"]:
|
||||
raise ValueError("Не удалось определить тариф для сервера")
|
||||
|
||||
base_bytes = int(tariff["traffic_limit"])
|
||||
added_days = max(leftover / (1000 * 86400), 0.01)
|
||||
total_gb = int((added_days / 30) * base_bytes)
|
||||
|
||||
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,
|
||||
hwid_device_limit=tariff["device_limit"]
|
||||
)
|
||||
text_ok = SUBSCRIPTION_UNFROZEN_MSG
|
||||
result = await toggle_client_on_cluster(
|
||||
cluster_id, email, client_id, enable=True, session=session
|
||||
)
|
||||
if result["status"] != "success":
|
||||
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_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||
)
|
||||
await edit_or_send_message(
|
||||
callback_query.message, text_error, builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
leftover = record["expiry_time"]
|
||||
logger.info(f"[Unfreeze Debug] expiry_time из БД: {leftover}")
|
||||
if leftover < 0:
|
||||
leftover = 0
|
||||
new_expiry_time = now_ms + leftover
|
||||
|
||||
await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time)
|
||||
await session.commit()
|
||||
|
||||
from database.servers import get_servers
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_id, [])
|
||||
|
||||
tariff = None
|
||||
for srv in cluster_servers:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.*
|
||||
FROM tariffs t
|
||||
JOIN servers s ON s.tariff_group = t.group_code
|
||||
WHERE s.server_name = :server_name
|
||||
ORDER BY t.duration_days DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"server_name": srv["server_name"]},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
if row:
|
||||
tariff = row
|
||||
break
|
||||
|
||||
await session.commit()
|
||||
|
||||
if not tariff:
|
||||
logger.info(
|
||||
"[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения."
|
||||
)
|
||||
base_bytes = 15 * 1024
|
||||
hwid_limit = 1
|
||||
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(),
|
||||
)
|
||||
base_bytes = int(tariff.get("traffic_limit") or 0)
|
||||
hwid_limit = int(tariff.get("device_limit") or 0)
|
||||
|
||||
added_days = max(leftover / (1000 * 86400), 0.01)
|
||||
total_gb = int((added_days / 30) * base_bytes)
|
||||
logger.info(
|
||||
f"[Unfreeze Debug] Запуск renew_key_in_cluster с expiry={new_expiry_time}, gb={total_gb}, hwid={hwid_limit}"
|
||||
)
|
||||
|
||||
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,
|
||||
session=session,
|
||||
hwid_device_limit=hwid_limit,
|
||||
)
|
||||
|
||||
text_ok = SUBSCRIPTION_UNFROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||
)
|
||||
await edit_or_send_message(callback_query.message, text_ok, 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):
|
||||
async def process_callback_freeze_subscription(
|
||||
callback_query: CallbackQuery, session: Any
|
||||
):
|
||||
"""
|
||||
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
|
||||
"""
|
||||
@@ -173,7 +186,9 @@ async def process_callback_freeze_subscription(callback_query: CallbackQuery, se
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
|
||||
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
async def process_callback_freeze_subscription_confirm(
|
||||
callback_query: CallbackQuery, session: Any
|
||||
):
|
||||
"""
|
||||
Замораживает (отключает) подписку.
|
||||
"""
|
||||
@@ -181,7 +196,7 @@ async def process_callback_freeze_subscription_confirm(callback_query: CallbackQ
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
@@ -190,7 +205,9 @@ async def process_callback_freeze_subscription_confirm(callback_query: CallbackQ
|
||||
client_id = record["client_id"]
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
|
||||
result = await toggle_client_on_cluster(
|
||||
cluster_id, email, client_id, enable=False, session=session
|
||||
)
|
||||
|
||||
if result["status"] == "success":
|
||||
now_ms = int(time.time() * 1000)
|
||||
@@ -198,33 +215,26 @@ async def process_callback_freeze_subscription_confirm(callback_query: CallbackQ
|
||||
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,
|
||||
)
|
||||
await mark_key_as_frozen(session, record["tg_id"], client_id, time_left)
|
||||
await session.commit()
|
||||
|
||||
text_ok = SUBSCRIPTION_FROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{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=text_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
else:
|
||||
text_error = (
|
||||
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
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}"))
|
||||
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,
|
||||
|
||||
@@ -7,12 +7,6 @@ from .key_country_mode import router as country_router
|
||||
from .key_create import router as create_router
|
||||
from .key_discount_mode import router as discount_router
|
||||
|
||||
|
||||
router = Router(name="key_mode_router")
|
||||
|
||||
router.include_routers(
|
||||
create_router,
|
||||
cluster_router,
|
||||
country_router,
|
||||
discount_router
|
||||
)
|
||||
router.include_routers(create_router, cluster_router, country_router, discount_router)
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
FSInputFile,
|
||||
InlineKeyboardButton,
|
||||
Message,
|
||||
WebAppInfo,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import CONNECT_PHONE_BUTTON, SUPPORT_CHAT_URL
|
||||
from database import (
|
||||
get_key_details,
|
||||
get_tariff_by_id,
|
||||
get_trial,
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
from handlers.texts import (
|
||||
key_message_success,
|
||||
from handlers.buttons import (
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
MAIN_MENU,
|
||||
MY_SUB,
|
||||
PC_BUTTON,
|
||||
SUPPORT,
|
||||
TV_BUTTON,
|
||||
)
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
from handlers.texts import key_message_success
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
generate_random_email,
|
||||
@@ -29,9 +40,7 @@ from handlers.utils import (
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
@@ -43,11 +52,19 @@ async def key_cluster_mode(
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
plan: int = None,
|
||||
):
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
target_message = None
|
||||
safe_to_edit = False
|
||||
|
||||
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
|
||||
target_message = message_or_query.message
|
||||
safe_to_edit = True
|
||||
elif isinstance(message_or_query, Message):
|
||||
target_message = message_or_query
|
||||
safe_to_edit = True
|
||||
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
existing_key = await get_key_details(session, key_name)
|
||||
if not existing_key:
|
||||
break
|
||||
|
||||
@@ -58,25 +75,27 @@ async def key_cluster_mode(
|
||||
try:
|
||||
device_limit = 0
|
||||
if plan:
|
||||
row = await session.fetchrow("SELECT device_limit FROM tariffs WHERE id = $1", plan)
|
||||
if row and row["device_limit"] is not None:
|
||||
device_limit = int(row["device_limit"])
|
||||
tariff = await get_tariff_by_id(session, plan)
|
||||
if tariff and tariff.get("device_limit") is not None:
|
||||
device_limit = int(tariff["device_limit"])
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||
await create_key_on_cluster(
|
||||
least_loaded_cluster,
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
plan,
|
||||
session,
|
||||
cluster_id=least_loaded_cluster,
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
expiry_timestamp=expiry_timestamp,
|
||||
plan=plan,
|
||||
session=session,
|
||||
hwid_limit=device_limit,
|
||||
)
|
||||
|
||||
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
||||
logger.info(
|
||||
f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}"
|
||||
)
|
||||
|
||||
key_record = await get_key_details(email, session)
|
||||
key_record = await get_key_details(session, email)
|
||||
if not key_record:
|
||||
raise ValueError(f"Ключ не найден после создания: {email}")
|
||||
|
||||
@@ -87,39 +106,45 @@ async def key_cluster_mode(
|
||||
data = await state.get_data() if state else {}
|
||||
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
await update_trial(session, tg_id, 1)
|
||||
|
||||
if data.get("tariff_id"):
|
||||
row = await session.fetchrow("SELECT price_rub FROM tariffs WHERE id = $1", data["tariff_id"])
|
||||
if row:
|
||||
await update_balance(tg_id, -row["price_rub"], session)
|
||||
|
||||
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
|
||||
tariff = await get_tariff_by_id(session, data["tariff_id"])
|
||||
if tariff:
|
||||
await update_balance(session, tg_id, -tariff["price_rub"])
|
||||
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
||||
if target_message:
|
||||
error_message = (
|
||||
"❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
||||
)
|
||||
|
||||
if safe_to_edit:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=error_message, reply_markup=None, media_path=None
|
||||
target_message=target_message,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if await is_full_remnawave_cluster(least_loaded_cluster, session):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
web_app=WebAppInfo(url=final_link),
|
||||
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
|
||||
)
|
||||
)
|
||||
elif CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=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}"),
|
||||
@@ -127,11 +152,10 @@ async def key_cluster_mode(
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
@@ -141,8 +165,7 @@ async def key_cluster_mode(
|
||||
key_message_text = key_message_success(final_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
default_media_path = "img/pic.jpg"
|
||||
|
||||
if target_message:
|
||||
if safe_to_edit:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=key_message_text,
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
DATABASE_URL,
|
||||
PUBLIC_LINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
@@ -32,12 +32,18 @@ from database import (
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
|
||||
from handlers.keys.key_utils import create_client_on_server
|
||||
from handlers.texts import (
|
||||
SELECT_COUNTRY_MSG,
|
||||
key_message_success,
|
||||
from database.models import Key, Server, Tariff
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
MAIN_MENU,
|
||||
PC_BUTTON,
|
||||
SUPPORT,
|
||||
TV_BUTTON,
|
||||
)
|
||||
from handlers.keys.key_utils import create_client_on_server
|
||||
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
generate_random_email,
|
||||
@@ -48,7 +54,6 @@ from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import delete_client, get_xui_instance
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
@@ -58,30 +63,63 @@ async def key_country_mode(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
session: AsyncSession,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
old_key_name: str = None,
|
||||
plan: int = None,
|
||||
):
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
target_message = None
|
||||
safe_to_edit = False
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url, panel_type FROM servers WHERE cluster_name = $1",
|
||||
least_loaded_cluster,
|
||||
if state and plan:
|
||||
await state.update_data(tariff_id=plan)
|
||||
|
||||
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
|
||||
target_message = message_or_query.message
|
||||
safe_to_edit = True
|
||||
elif isinstance(message_or_query, Message):
|
||||
target_message = message_or_query
|
||||
safe_to_edit = True
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||
if not least_loaded_cluster:
|
||||
logger.error("❌ Не удалось определить наименее загруженный кластер")
|
||||
text = "❌ Нет доступных кластеров для создания ключа."
|
||||
if safe_to_edit:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=text, reply_markup=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=text)
|
||||
return
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
Server.server_name,
|
||||
Server.api_url,
|
||||
Server.panel_type,
|
||||
Server.enabled,
|
||||
Server.max_keys,
|
||||
).where(Server.cluster_name == least_loaded_cluster)
|
||||
)
|
||||
servers = result.mappings().all()
|
||||
|
||||
if not servers:
|
||||
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
)
|
||||
logger.error(f"❌ Нет серверов в кластере {least_loaded_cluster}")
|
||||
text = "❌ Нет доступных серверов в выбранном кластере."
|
||||
if safe_to_edit:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=text, reply_markup=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=text)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
tasks = [asyncio.create_task(check_server_availability(server)) for server in servers]
|
||||
tasks = [
|
||||
asyncio.create_task(check_server_availability(server, session))
|
||||
for server in servers
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in zip(servers, results, strict=False):
|
||||
@@ -89,35 +127,45 @@ async def key_country_mode(
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
logger.warning(
|
||||
f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}"
|
||||
)
|
||||
text = "❌ Нет доступных серверов в выбранном кластере."
|
||||
if safe_to_edit:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=text, reply_markup=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=text)
|
||||
return
|
||||
|
||||
logger.info(f"[Country Selection] Доступные серверы для выбора: {available_servers}")
|
||||
logger.info(
|
||||
f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
ts = int(expiry_time.timestamp())
|
||||
|
||||
for country in available_servers:
|
||||
for server_name in available_servers:
|
||||
if old_key_name:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
callback_data = f"select_country|{server_name}|{ts}|{old_key_name}"
|
||||
else:
|
||||
callback_data = f"select_country|{country}|{ts}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
callback_data = f"select_country|{server_name}|{ts}"
|
||||
builder.row(InlineKeyboardButton(text=server_name, callback_data=callback_data))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
if safe_to_edit:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_location|"))
|
||||
@@ -129,7 +177,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
return
|
||||
|
||||
old_key_name = data[1]
|
||||
record = await get_key_details(old_key_name, session)
|
||||
record = await get_key_details(session, old_key_name)
|
||||
if not record:
|
||||
await callback_query.answer("❌ Ключ не найден", show_alert=True)
|
||||
return
|
||||
@@ -139,20 +187,36 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
|
||||
current_server = record["server_id"]
|
||||
|
||||
cluster_info = await check_server_name_by_cluster(current_server, session)
|
||||
cluster_info = await check_server_name_by_cluster(session, current_server)
|
||||
if not cluster_info:
|
||||
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
|
||||
await callback_query.answer(
|
||||
"❌ Кластер для текущего сервера не найден", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
cluster_name = cluster_info["cluster_name"]
|
||||
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url, panel_type, enabled, max_keys FROM servers WHERE cluster_name = $1 AND server_name != $2",
|
||||
cluster_name,
|
||||
current_server,
|
||||
servers = (
|
||||
(
|
||||
await session.execute(
|
||||
select(
|
||||
Server.server_name,
|
||||
Server.api_url,
|
||||
Server.panel_type,
|
||||
Server.enabled,
|
||||
Server.max_keys,
|
||||
)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.where(Server.server_name != current_server)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
if not servers:
|
||||
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
|
||||
await callback_query.answer(
|
||||
"❌ Доступных серверов в кластере не найдено", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
@@ -166,7 +230,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
"enabled": server.get("enabled", True),
|
||||
"max_keys": server.get("max_keys"),
|
||||
}
|
||||
task = asyncio.create_task(check_server_availability(server_info))
|
||||
task = asyncio.create_task(check_server_availability(server_info, session))
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -176,7 +240,9 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
|
||||
await callback_query.answer(
|
||||
"❌ Нет доступных серверов для смены локации", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Доступные страны для смены локации: {available_servers}")
|
||||
@@ -185,7 +251,9 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
for country in available_servers:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
@@ -194,12 +262,18 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
|
||||
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
|
||||
logger.error(
|
||||
f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}"
|
||||
)
|
||||
await callback_query.answer(
|
||||
"❌ Ошибка смены локации. Попробуйте снова.", show_alert=True
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_country|"))
|
||||
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
async def handle_country_selection(
|
||||
callback_query: CallbackQuery, session: Any, state: FSMContext
|
||||
):
|
||||
"""
|
||||
Обрабатывает выбор страны.
|
||||
Формат callback data:
|
||||
@@ -215,7 +289,9 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any,
|
||||
try:
|
||||
ts = int(data[2])
|
||||
except ValueError:
|
||||
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
|
||||
await callback_query.message.answer(
|
||||
"❌ Некорректное время истечения. Попробуйте снова."
|
||||
)
|
||||
return
|
||||
|
||||
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
||||
@@ -226,7 +302,15 @@ 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, state, 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(
|
||||
@@ -234,31 +318,29 @@ async def finalize_key_creation(
|
||||
expiry_time: datetime,
|
||||
selected_country: str,
|
||||
state: FSMContext | None,
|
||||
session: Any,
|
||||
session: AsyncSession,
|
||||
callback_query: CallbackQuery,
|
||||
old_key_name: str = None,
|
||||
tariff_id: int | None = None,
|
||||
):
|
||||
if not await check_user_exists(tg_id):
|
||||
if isinstance(callback_query, CallbackQuery):
|
||||
from_user = callback_query.from_user
|
||||
else:
|
||||
from_user = callback_query.from_user
|
||||
from_user = callback_query.from_user
|
||||
|
||||
if not await check_user_exists(session, tg_id):
|
||||
await add_user(
|
||||
session=session,
|
||||
tg_id=from_user.id,
|
||||
username=from_user.username,
|
||||
first_name=from_user.first_name,
|
||||
last_name=from_user.last_name,
|
||||
language_code=from_user.language_code,
|
||||
is_bot=from_user.is_bot,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
|
||||
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
if old_key_name:
|
||||
old_key_details = await get_key_details(old_key_name, session)
|
||||
old_key_details = await get_key_details(session, old_key_name)
|
||||
if not old_key_details:
|
||||
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
|
||||
return
|
||||
@@ -267,90 +349,88 @@ async def finalize_key_creation(
|
||||
client_id = old_key_details["client_id"]
|
||||
email = old_key_details["email"]
|
||||
expiry_timestamp = old_key_details["expiry_time"]
|
||||
|
||||
else:
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
existing_key = await get_key_details(session, key_name)
|
||||
if not existing_key:
|
||||
break
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
remna = None
|
||||
public_link = None
|
||||
remnawave_link = None
|
||||
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
try:
|
||||
server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
|
||||
selected_country,
|
||||
result = await session.execute(
|
||||
select(Server).where(Server.server_name == selected_country)
|
||||
)
|
||||
server_info = result.scalar_one_or_none()
|
||||
if not server_info:
|
||||
raise ValueError(f"Сервер {selected_country} не найден.")
|
||||
raise ValueError(f"Сервер {selected_country} не найден")
|
||||
|
||||
panel_type = server_info["panel_type"].lower()
|
||||
|
||||
public_link = None
|
||||
remnawave_link = None
|
||||
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
cluster_info = await check_server_name_by_cluster(selected_country, session)
|
||||
panel_type = server_info.panel_type.lower()
|
||||
cluster_info = await check_server_name_by_cluster(
|
||||
session, server_info.server_name
|
||||
)
|
||||
if not cluster_info:
|
||||
raise ValueError(f"Кластер для сервера {selected_country} не найден")
|
||||
raise ValueError(f"Кластер для сервера {server_info.server_name} не найден")
|
||||
|
||||
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
|
||||
is_full_remnawave = await is_full_remnawave_cluster(
|
||||
cluster_info["cluster_name"], session
|
||||
)
|
||||
|
||||
if old_key_name:
|
||||
old_server_id = old_key_details.get("server_id")
|
||||
old_server_id = old_key_details["server_id"]
|
||||
if old_server_id:
|
||||
old_server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
|
||||
old_server_id,
|
||||
result = await session.execute(
|
||||
select(Server).where(Server.server_name == old_server_id)
|
||||
)
|
||||
old_server_info = result.scalar_one_or_none()
|
||||
if old_server_info:
|
||||
old_panel_type = old_server_info["panel_type"].lower()
|
||||
try:
|
||||
if old_panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(old_server_info["api_url"])
|
||||
|
||||
if old_server_info.panel_type.lower() == "3x-ui":
|
||||
xui = await get_xui_instance(old_server_info.api_url)
|
||||
await delete_client(
|
||||
xui,
|
||||
old_server_info["inbound_id"],
|
||||
email,
|
||||
client_id,
|
||||
xui, old_server_info.inbound_id, email, client_id
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
"UPDATE keys SET key = NULL WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.email == email)
|
||||
.values(key=None)
|
||||
)
|
||||
logger.info(f"[Delete] Удалён клиент {email} с 3x-ui сервера {old_server_id}")
|
||||
elif old_panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(old_server_info["api_url"])
|
||||
elif old_server_info.panel_type.lower() == "remnawave":
|
||||
remna = RemnawaveAPI(old_server_info.api_url)
|
||||
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await remna.delete_user(client_id)
|
||||
await session.execute(
|
||||
"UPDATE keys SET remnawave_link = NULL WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.email == email)
|
||||
.values(remnawave_link=None)
|
||||
)
|
||||
logger.info(f"[Delete] Удалён клиент {client_id} с Remnawave сервера {old_server_id}")
|
||||
else:
|
||||
logger.warning(f"[Delete] Не удалось авторизоваться в Remnawave ({old_server_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Delete] Ошибка при удалении клиента с сервера {old_server_id}: {e}")
|
||||
logger.warning(f"[Delete] Ошибка при удалении клиента: {e}")
|
||||
|
||||
if panel_type == "remnawave" or is_full_remnawave:
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
remna = RemnawaveAPI(server_info.api_url)
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
|
||||
raise ValueError(
|
||||
f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})"
|
||||
)
|
||||
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
expire_at = (
|
||||
datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
)
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": [server_info["inbound_id"]],
|
||||
"activeUserInbounds": [server_info.inbound_id],
|
||||
"hwidDeviceLimit": 0,
|
||||
}
|
||||
result = await remna.create_user(user_data)
|
||||
@@ -359,20 +439,23 @@ async def finalize_key_creation(
|
||||
|
||||
client_id = result.get("uuid")
|
||||
remnawave_link = result.get("subscriptionUrl")
|
||||
logger.info(f"[Key Creation] Remnawave пользователь создан: {result}")
|
||||
|
||||
if old_key_name:
|
||||
await session.execute(
|
||||
"UPDATE keys SET client_id = $1 WHERE tg_id = $2 AND email = $3",
|
||||
client_id,
|
||||
tg_id,
|
||||
email,
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.email == email)
|
||||
.values(client_id=client_id)
|
||||
)
|
||||
|
||||
if panel_type == "3x-ui":
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
await create_client_on_server(
|
||||
server_info=server_info,
|
||||
server_info={
|
||||
"api_url": server_info.api_url,
|
||||
"inbound_id": server_info.inbound_id,
|
||||
"server_name": server_info.server_name,
|
||||
"panel_type": server_info.panel_type,
|
||||
},
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
@@ -381,64 +464,73 @@ async def finalize_key_creation(
|
||||
)
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
|
||||
logger.info(
|
||||
f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}"
|
||||
)
|
||||
|
||||
if old_key_name:
|
||||
await session.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE tg_id = $2 AND email = $3",
|
||||
selected_country,
|
||||
tg_id,
|
||||
old_key_name,
|
||||
)
|
||||
update_data = {"server_id": selected_country}
|
||||
if panel_type == "3x-ui":
|
||||
await session.execute(
|
||||
"UPDATE keys SET key = $1 WHERE tg_id = $2 AND email = $3",
|
||||
public_link,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
update_data["key"] = public_link
|
||||
elif panel_type == "remnawave":
|
||||
await session.execute(
|
||||
"UPDATE keys SET remnawave_link = $1 WHERE tg_id = $2 AND email = $3",
|
||||
remnawave_link,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
update_data["remnawave_link"] = remnawave_link
|
||||
|
||||
else:
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, remnawave_link, server_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""",
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
created_at,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
remnawave_link,
|
||||
selected_country,
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.email == email)
|
||||
.values(**update_data)
|
||||
)
|
||||
else:
|
||||
data = {}
|
||||
if state:
|
||||
data = await state.get_data()
|
||||
|
||||
tariff_id = data.get("tariff_id") or tariff_id
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
created_at=created_at,
|
||||
expiry_time=expiry_timestamp,
|
||||
key=public_link,
|
||||
remnawave_link=remnawave_link,
|
||||
server_id=selected_country,
|
||||
tariff_id=data.get("tariff_id"),
|
||||
)
|
||||
session.add(new_key)
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
await update_trial(session, tg_id, 1)
|
||||
if data.get("tariff_id"):
|
||||
row = await session.fetchrow("SELECT price_rub FROM tariffs WHERE id = $1", data["tariff_id"])
|
||||
result = await session.execute(
|
||||
select(Tariff.price_rub).where(Tariff.id == data["tariff_id"])
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
await update_balance(tg_id, -row["price_rub"], session)
|
||||
await update_balance(session, tg_id, -row)
|
||||
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
|
||||
logger.error(
|
||||
f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}"
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"❌ Произошла ошибка при создании подписки. Попробуйте снова."
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
|
||||
if is_full_remnawave and (public_link or remnawave_link):
|
||||
is_full_remnawave = await is_full_remnawave_cluster(
|
||||
cluster_info["cluster_name"], session
|
||||
)
|
||||
if (panel_type == "remnawave" or is_full_remnawave) and (
|
||||
public_link or remnawave_link
|
||||
):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
@@ -446,20 +538,31 @@ async def finalize_key_creation(
|
||||
)
|
||||
)
|
||||
elif CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, 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=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔐 Моя подписка", callback_data=f"view_key|{key_name}"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
remaining_time = expiry_time - datetime.now(moscow_tz)
|
||||
days = remaining_time.days
|
||||
|
||||
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
|
||||
key_message_text = key_message_success(link_to_show, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
@@ -474,7 +577,7 @@ async def finalize_key_creation(
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def check_server_availability(server_info: dict, session: Any = None) -> bool:
|
||||
async def check_server_availability(server_info: dict, session: AsyncSession) -> bool:
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
enabled = server_info.get("enabled", True)
|
||||
@@ -484,32 +587,33 @@ async def check_server_availability(server_info: dict, session: Any = None) -> b
|
||||
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
|
||||
return False
|
||||
|
||||
connection = None
|
||||
external_session = session is not None
|
||||
|
||||
try:
|
||||
if not external_session:
|
||||
connection = await asyncpg.connect(DATABASE_URL)
|
||||
session = connection
|
||||
|
||||
if max_keys is not None:
|
||||
count_query = "SELECT COUNT(*) FROM keys WHERE server_id = $1"
|
||||
key_count = await session.fetchval(count_query, server_name)
|
||||
result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.server_id == server_name)
|
||||
)
|
||||
key_count = result.scalar()
|
||||
|
||||
if key_count >= max_keys:
|
||||
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}.")
|
||||
logger.info(
|
||||
f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}."
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}")
|
||||
except SQLAlchemyError as e:
|
||||
logger.warning(
|
||||
f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}"
|
||||
)
|
||||
return False
|
||||
finally:
|
||||
if connection:
|
||||
await connection.close()
|
||||
|
||||
try:
|
||||
if panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
|
||||
await asyncio.wait_for(
|
||||
remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0
|
||||
)
|
||||
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
|
||||
return True
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -15,12 +14,18 @@ from config import (
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import add_user, check_user_exists, create_temporary_data, get_balance, get_tariffs_for_cluster, get_trial
|
||||
from handlers.buttons import (
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_tariff_by_id,
|
||||
get_tariffs_for_cluster,
|
||||
get_trial,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU, PAYMENT
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.stars_pay import process_custom_amount_input_stars
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.texts import (
|
||||
CREATING_CONNECTION_MSG,
|
||||
@@ -33,7 +38,6 @@ from logger import logger
|
||||
from .key_cluster_mode import key_cluster_mode
|
||||
from .key_country_mode import key_country_mode
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
@@ -44,7 +48,9 @@ class Form(FSMContext):
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_key")
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
async def confirm_create_new_key(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
await handle_key_creation(tg_id, state, session, callback_query)
|
||||
|
||||
@@ -55,17 +61,22 @@ async def handle_key_creation(
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery,
|
||||
):
|
||||
"""Создание ключа с учётом выбора тарифного плана."""
|
||||
current_time = datetime.now(moscow_tz)
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status in [0, -1]:
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
|
||||
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
|
||||
logger.info(
|
||||
f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}."
|
||||
)
|
||||
await edit_or_send_message(
|
||||
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
|
||||
target_message=(
|
||||
message_or_query
|
||||
if isinstance(message_or_query, Message)
|
||||
else message_or_query.message
|
||||
),
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=None,
|
||||
)
|
||||
@@ -73,12 +84,16 @@ async def handle_key_creation(
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
|
||||
cluster_name = await get_least_loaded_cluster()
|
||||
cluster_name = await get_least_loaded_cluster(session)
|
||||
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||
|
||||
if not tariffs:
|
||||
await edit_or_send_message(
|
||||
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
|
||||
target_message=(
|
||||
message_or_query
|
||||
if isinstance(message_or_query, Message)
|
||||
else message_or_query.message
|
||||
),
|
||||
text="❌ Нет доступных тарифов для выбранного кластера.",
|
||||
reply_markup=None,
|
||||
)
|
||||
@@ -94,7 +109,11 @@ async def handle_key_creation(
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
target_message = (
|
||||
message_or_query.message
|
||||
if isinstance(message_or_query, CallbackQuery)
|
||||
else message_or_query
|
||||
)
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_TARIFF_PLAN_MSG,
|
||||
@@ -106,22 +125,25 @@ async def handle_key_creation(
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_tariff_plan|"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
tg_id = callback_query.message.chat.id
|
||||
async def select_tariff_plan(
|
||||
callback_query: CallbackQuery, session: Any, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.from_user.id
|
||||
tariff_id = int(callback_query.data.split("|")[1])
|
||||
|
||||
row = await session.fetchrow("SELECT * FROM tariffs WHERE id = $1", tariff_id)
|
||||
if not row:
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff:
|
||||
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
|
||||
return
|
||||
|
||||
tariff = dict(row)
|
||||
duration_days = tariff["duration_days"]
|
||||
price_rub = tariff["price_rub"]
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
balance = round(await get_balance(session, tg_id))
|
||||
price_rub = round(tariff["price_rub"])
|
||||
|
||||
if balance < price_rub:
|
||||
required_amount = price_rub - balance
|
||||
required_amount = max(price_rub - balance, 0)
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
@@ -137,6 +159,8 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||
await process_custom_amount_input_stars(callback_query, session)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
@@ -149,7 +173,9 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key")
|
||||
)
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
@@ -170,13 +196,12 @@ async def create_key(
|
||||
old_key_name: str = None,
|
||||
plan: int = None,
|
||||
):
|
||||
"""
|
||||
Универсальная точка входа для создания ключа.
|
||||
Делегирует выполнение в зависимости от выбранного режима (страна или кластер).
|
||||
Также отвечает за первичное подключение пользователя.
|
||||
"""
|
||||
if not await check_user_exists(tg_id):
|
||||
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
|
||||
if not await check_user_exists(session, tg_id):
|
||||
from_user = (
|
||||
message_or_query.from_user
|
||||
if isinstance(message_or_query, CallbackQuery | Message)
|
||||
else None
|
||||
)
|
||||
if from_user:
|
||||
await add_user(
|
||||
tg_id=from_user.id,
|
||||
@@ -197,6 +222,7 @@ async def create_key(
|
||||
session=session,
|
||||
message_or_query=message_or_query,
|
||||
old_key_name=old_key_name,
|
||||
plan=plan,
|
||||
)
|
||||
else:
|
||||
await key_cluster_mode(
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_tariffs
|
||||
from handlers.notifications.notify_kb import build_tariffs_keyboard
|
||||
from .key_create import select_tariff_plan
|
||||
from logger import logger
|
||||
from datetime import datetime, timedelta
|
||||
from config import DISCOUNT_ACTIVE_HOURS
|
||||
from database import get_tariffs
|
||||
from database.models import Notification
|
||||
from handlers.notifications.notify_kb import build_tariffs_keyboard
|
||||
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
|
||||
from logger import logger
|
||||
|
||||
from .key_create import select_tariff_plan
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "hot_lead_discount")
|
||||
async def handle_discount_entry(callback: CallbackQuery, session):
|
||||
async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
|
||||
last_time = await session.fetchval("""
|
||||
SELECT last_notification_time
|
||||
FROM notifications
|
||||
WHERE tg_id = $1 AND notification_type = 'hot_lead_step_2'
|
||||
""", tg_id)
|
||||
result = await session.execute(
|
||||
select(Notification.last_notification_time).where(
|
||||
Notification.tg_id == tg_id,
|
||||
Notification.notification_type == "hot_lead_step_2",
|
||||
)
|
||||
)
|
||||
last_time = result.scalar_one_or_none()
|
||||
|
||||
if not last_time:
|
||||
await callback.message.edit_text("❌ Скидка недоступна.")
|
||||
@@ -32,14 +39,13 @@ async def handle_discount_entry(callback: CallbackQuery, session):
|
||||
return
|
||||
|
||||
tariffs = await get_tariffs(session=session, group_code="discounts")
|
||||
|
||||
if not tariffs:
|
||||
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
DISCOUNT_TARIFF,
|
||||
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff")
|
||||
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,14 +68,16 @@ async def handle_discount_tariff_selection(callback: CallbackQuery, session, sta
|
||||
|
||||
|
||||
@router.callback_query(F.data == "hot_lead_final_discount")
|
||||
async def handle_ultra_discount(callback: CallbackQuery, session):
|
||||
async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
|
||||
last_time = await session.fetchval("""
|
||||
SELECT last_notification_time
|
||||
FROM notifications
|
||||
WHERE tg_id = $1 AND notification_type = 'hot_lead_step_3'
|
||||
""", tg_id)
|
||||
result = await session.execute(
|
||||
select(Notification.last_notification_time).where(
|
||||
Notification.tg_id == tg_id,
|
||||
Notification.notification_type == "hot_lead_step_3",
|
||||
)
|
||||
)
|
||||
last_time = result.scalar_one_or_none()
|
||||
|
||||
if not last_time:
|
||||
await callback.message.edit_text("❌ Скидка недоступна.")
|
||||
@@ -87,5 +95,5 @@ async def handle_ultra_discount(callback: CallbackQuery, session):
|
||||
|
||||
await callback.message.edit_text(
|
||||
DISCOUNT_TARIFF_MAX,
|
||||
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff")
|
||||
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
|
||||
)
|
||||
|
||||
+155
-83
@@ -1,36 +1,29 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from config import USE_NEW_PAYMENT_FLOW
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_key_by_server,
|
||||
get_key_details,
|
||||
get_tariff_by_id,
|
||||
get_tariffs,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from database.models import Server
|
||||
from handlers.buttons import BACK, MAIN_MENU, PAYMENT
|
||||
from handlers.keys.key_utils import renew_key_in_cluster
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.stars_pay import process_custom_amount_input_stars
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.texts import (
|
||||
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||
@@ -38,19 +31,21 @@ from handlers.texts import (
|
||||
PLAN_SELECTION_MSG,
|
||||
SUCCESS_RENEWAL_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, format_months, format_days
|
||||
from handlers.utils import edit_or_send_message, format_days, format_months
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
|
||||
async def process_callback_renew_key(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await callback_query.message.answer("<b>Ключ не найден.</b>")
|
||||
return
|
||||
@@ -61,35 +56,40 @@ async def process_callback_renew_key(callback_query: CallbackQuery, session: Any
|
||||
|
||||
logger.info(f"[RENEW] Получение тарифной группы для server_id={server_id}")
|
||||
|
||||
row = await session.fetchrow(
|
||||
"""
|
||||
SELECT tariff_group FROM servers
|
||||
WHERE id::text = $1 OR server_name = $1 OR cluster_name = $1
|
||||
LIMIT 1
|
||||
""",
|
||||
server_id,
|
||||
)
|
||||
try:
|
||||
server_id_int = int(server_id)
|
||||
filter_condition = or_(
|
||||
Server.id == server_id_int,
|
||||
Server.server_name == server_id,
|
||||
Server.cluster_name == server_id,
|
||||
)
|
||||
except ValueError:
|
||||
filter_condition = or_(
|
||||
Server.server_name == server_id,
|
||||
Server.cluster_name == server_id,
|
||||
)
|
||||
|
||||
if not row or not row["tariff_group"]:
|
||||
logger.warning(f"[RENEW] Тарифная группа не найдена для server_id={server_id}")
|
||||
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
|
||||
row = await session.execute(
|
||||
select(Server.tariff_group).where(filter_condition).limit(1)
|
||||
)
|
||||
row = row.first()
|
||||
if not row or not row[0]:
|
||||
logger.warning(
|
||||
f"[RENEW] Тарифная группа не найдена для server_id={server_id}"
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"❌ Не удалось определить тарифную группу."
|
||||
)
|
||||
return
|
||||
|
||||
tariff_group = row["tariff_group"]
|
||||
logger.info(f"[RENEW] Найдена тарифная группа '{tariff_group}' для server_id={server_id}")
|
||||
|
||||
tariffs = await session.fetch(
|
||||
"""
|
||||
SELECT * FROM tariffs
|
||||
WHERE group_code = $1 AND is_active = TRUE
|
||||
ORDER BY id
|
||||
""",
|
||||
tariff_group,
|
||||
)
|
||||
tariff_group = row[0]
|
||||
tariffs = await get_tariffs(session, group_code=tariff_group)
|
||||
|
||||
if not tariffs:
|
||||
logger.warning(f"[RENEW] Нет активных тарифов для группы '{tariff_group}'")
|
||||
await callback_query.message.answer("❌ Нет доступных тарифов для этой группы.")
|
||||
await callback_query.message.answer(
|
||||
"❌ Нет доступных тарифов для этой группы."
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -102,12 +102,16 @@ async def process_callback_renew_key(callback_query: CallbackQuery, session: Any
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}")
|
||||
)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
balance = await get_balance(session, tg_id)
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
@@ -117,19 +121,23 @@ async def process_callback_renew_key(callback_query: CallbackQuery, session: Any
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
|
||||
logger.error(
|
||||
f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}"
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"❌ Произошла ошибка при обработке. Попробуйте позже."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
tg_id = callback_query.from_user.id
|
||||
tariff_id, client_id = callback_query.data.split("|")[1:]
|
||||
tariff_id = int(tariff_id)
|
||||
|
||||
try:
|
||||
tariff = await session.fetchrow("SELECT * FROM tariffs WHERE id = $1 AND is_active = TRUE", tariff_id)
|
||||
if not tariff:
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff or not tariff["is_active"]:
|
||||
await callback_query.message.answer("❌ Тариф не найден или отключён.")
|
||||
return
|
||||
|
||||
@@ -137,7 +145,7 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
cost = tariff["price_rub"]
|
||||
total_gb = tariff["traffic_limit"] or 0
|
||||
|
||||
record = await get_key_by_server(tg_id, client_id, session)
|
||||
record = await get_key_by_server(session, tg_id, client_id)
|
||||
if not record:
|
||||
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
|
||||
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
|
||||
@@ -148,13 +156,18 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
|
||||
if expiry_time <= current_time:
|
||||
new_expiry_time = int(current_time + timedelta(days=duration_days).total_seconds() * 1000)
|
||||
new_expiry_time = int(
|
||||
current_time + timedelta(days=duration_days).total_seconds() * 1000
|
||||
)
|
||||
else:
|
||||
new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000)
|
||||
new_expiry_time = int(
|
||||
expiry_time + timedelta(days=duration_days).total_seconds() * 1000
|
||||
)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
balance = round(await get_balance(session, tg_id), 2)
|
||||
cost = round(cost, 2)
|
||||
if balance < cost:
|
||||
required_amount = cost - balance
|
||||
required_amount = round(cost - balance, 2)
|
||||
logger.info(f"[RENEW] Недостаточно средств: {required_amount}₽")
|
||||
|
||||
await create_temporary_data(
|
||||
@@ -176,31 +189,80 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, session: An
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||
await process_custom_amount_input_stars(callback_query, session)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
|
||||
)
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(
|
||||
required_amount=required_amount
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней")
|
||||
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, tariff_id)
|
||||
logger.info(
|
||||
f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней"
|
||||
)
|
||||
await complete_key_renewal(
|
||||
session,
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
new_expiry_time,
|
||||
total_gb,
|
||||
cost,
|
||||
callback_query,
|
||||
tariff_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
|
||||
logger.error(
|
||||
f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, tariff_id):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
async def resolve_cluster_name(
|
||||
session: AsyncSession, server_or_cluster: str
|
||||
) -> str | None:
|
||||
result = await session.execute(
|
||||
select(Server).where(Server.cluster_name == server_or_cluster).limit(1)
|
||||
)
|
||||
server = result.scalars().first()
|
||||
if server:
|
||||
return server_or_cluster
|
||||
|
||||
result = await session.execute(
|
||||
select(Server.cluster_name)
|
||||
.where(Server.server_name == server_or_cluster)
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar()
|
||||
return row
|
||||
|
||||
|
||||
async def complete_key_renewal(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
client_id: str,
|
||||
email: str,
|
||||
new_expiry_time: int,
|
||||
total_gb: int,
|
||||
cost: float,
|
||||
callback_query: CallbackQuery | None,
|
||||
tariff_id: int,
|
||||
):
|
||||
try:
|
||||
logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)")
|
||||
logger.info(
|
||||
f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)"
|
||||
)
|
||||
|
||||
tariff = await conn.fetchrow("SELECT * FROM tariffs WHERE id = $1", tariff_id)
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff:
|
||||
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
|
||||
return
|
||||
@@ -223,32 +285,42 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
|
||||
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
else:
|
||||
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
|
||||
await bot.send_message(
|
||||
tg_id, response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
key_info = await get_key_details(email, conn)
|
||||
key_info = await get_key_details(session, email)
|
||||
if not key_info:
|
||||
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
|
||||
return
|
||||
|
||||
server_id = key_info["server_id"]
|
||||
if USE_COUNTRY_SELECTION:
|
||||
cluster_info = await check_server_name_by_cluster(server_id, conn)
|
||||
if not cluster_info:
|
||||
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
|
||||
return
|
||||
cluster_id = cluster_info["cluster_name"]
|
||||
else:
|
||||
cluster_id = server_id
|
||||
server_or_cluster = key_info["server_id"]
|
||||
cluster_id = await resolve_cluster_name(session, server_or_cluster)
|
||||
|
||||
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb, hwid_device_limit=tariff["device_limit"])
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
await update_balance(tg_id, -cost, conn)
|
||||
if not cluster_id:
|
||||
logger.error(f"[Error] Кластер для {server_or_cluster} не найден.")
|
||||
return
|
||||
|
||||
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
|
||||
await renew_key_in_cluster(
|
||||
cluster_id,
|
||||
email,
|
||||
client_id,
|
||||
new_expiry_time,
|
||||
total_gb,
|
||||
session,
|
||||
hwid_device_limit=tariff["device_limit"],
|
||||
)
|
||||
|
||||
await update_key_expiry(session, client_id, new_expiry_time)
|
||||
await update_balance(session, tg_id, -cost)
|
||||
|
||||
logger.info(
|
||||
f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
+496
-328
File diff suppressed because it is too large
Load Diff
+132
-53
@@ -1,17 +1,17 @@
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
@@ -23,6 +23,7 @@ from config import (
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import get_key_details, get_keys, get_servers
|
||||
from database.models import Key
|
||||
from handlers.buttons import (
|
||||
ADD_SUB,
|
||||
ALIAS,
|
||||
@@ -41,23 +42,17 @@ from handlers.buttons import (
|
||||
TV_BUTTON,
|
||||
UNFREEZE,
|
||||
)
|
||||
from handlers.texts import (
|
||||
FROZEN_SUBSCRIPTION_MSG,
|
||||
NO_SUBSCRIPTIONS_MSG,
|
||||
key_message,
|
||||
)
|
||||
from handlers.texts import FROZEN_SUBSCRIPTION_MSG, NO_SUBSCRIPTIONS_MSG, key_message
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
format_days,
|
||||
format_hours,
|
||||
format_minutes,
|
||||
get_russian_month,
|
||||
handle_error,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -67,19 +62,26 @@ class RenameKeyState(StatesGroup):
|
||||
|
||||
@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):
|
||||
async def process_callback_or_message_view_keys(
|
||||
callback_query_or_message: Message | CallbackQuery, session: Any
|
||||
):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
target_message = callback_query_or_message
|
||||
|
||||
tg_id = callback_query_or_message.from_user.id
|
||||
|
||||
try:
|
||||
records = await get_keys(target_message.chat.id, session)
|
||||
records = await get_keys(session, tg_id)
|
||||
inline_keyboard, response_message = build_keys_response(records)
|
||||
image_path = os.path.join("img", "pic_keys.jpg")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
|
||||
target_message=target_message,
|
||||
text=response_message,
|
||||
reply_markup=inline_keyboard,
|
||||
media_path=image_path,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении ключей: {e}"
|
||||
@@ -96,26 +98,34 @@ def build_keys_response(records):
|
||||
if records:
|
||||
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
alias = record.get("alias")
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record.get("expiry_time")
|
||||
alias = record.alias
|
||||
email = record.email
|
||||
client_id = record.client_id
|
||||
expiry_time = record.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)
|
||||
expiry_date_full = datetime.fromtimestamp(
|
||||
expiry_time / 1000, tz=moscow_tz
|
||||
)
|
||||
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
|
||||
else:
|
||||
formatted_date_full = "без срока действия"
|
||||
|
||||
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
|
||||
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
|
||||
key_button = InlineKeyboardButton(
|
||||
text=f"🔑 {key_display}", callback_data=f"view_key|{email}"
|
||||
)
|
||||
rename_button = InlineKeyboardButton(
|
||||
text=ALIAS, callback_data=f"rename_key|{client_id}"
|
||||
)
|
||||
builder.row(key_button, rename_button)
|
||||
|
||||
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
|
||||
|
||||
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
|
||||
response_message += (
|
||||
"</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
|
||||
)
|
||||
else:
|
||||
response_message = NO_SUBSCRIPTIONS_MSG
|
||||
|
||||
@@ -133,7 +143,9 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
|
||||
await state.update_data(client_id=client_id, target_message=callback.message)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BACK, callback_data="cancel_and_back_to_view_keys")
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
@@ -142,12 +154,25 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_and_back_to_view_keys")
|
||||
async def cancel_and_back(
|
||||
callback: CallbackQuery, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
await process_callback_or_message_view_keys(callback, session)
|
||||
|
||||
|
||||
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
|
||||
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
|
||||
async def handle_new_alias_input(
|
||||
message: Message, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
alias = message.text.strip()
|
||||
|
||||
if len(alias) > 10:
|
||||
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
|
||||
await message.answer(
|
||||
"❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод."
|
||||
)
|
||||
return
|
||||
|
||||
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
@@ -161,11 +186,12 @@ async def handle_new_alias_input(message: Message, state: FSMContext, session: A
|
||||
|
||||
try:
|
||||
await session.execute(
|
||||
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
|
||||
alias,
|
||||
message.chat.id,
|
||||
client_id,
|
||||
update(Key)
|
||||
.where(Key.tg_id == message.chat.id, Key.client_id == client_id)
|
||||
.values(alias=alias)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
await message.answer("❌ Не удалось переименовать подписку.")
|
||||
logger.error(f"Ошибка при обновлении alias: {e}")
|
||||
@@ -182,11 +208,13 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
|
||||
await render_key_info(callback_query.message, session, key_name, image_path)
|
||||
|
||||
|
||||
async def render_key_info(message: Message, session: Any, key_name: str, image_path: str):
|
||||
async def render_key_info(
|
||||
message: Message, session: Any, key_name: str, image_path: str
|
||||
):
|
||||
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
record = await get_key_details(key_name, session)
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await message.answer("<b>Информация о подписке не найдена.</b>")
|
||||
return
|
||||
@@ -201,7 +229,11 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if is_frozen:
|
||||
builder.row(InlineKeyboardButton(text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
@@ -224,20 +256,22 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
days_left_message = (
|
||||
f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
|
||||
)
|
||||
days_left_message = f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
|
||||
|
||||
formatted_expiry_date = (
|
||||
f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
|
||||
)
|
||||
formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
|
||||
|
||||
hwid_count = 0
|
||||
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
|
||||
if is_full_remnawave and client_id:
|
||||
servers = await get_servers()
|
||||
servers = await get_servers(session)
|
||||
remna_server = next(
|
||||
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None
|
||||
(
|
||||
srv
|
||||
for cl in servers.values()
|
||||
for srv in cl
|
||||
if srv.get("panel_type") == "remnawave"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if remna_server:
|
||||
api = RemnawaveAPI(remna_server["api_url"])
|
||||
@@ -255,19 +289,40 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
)
|
||||
|
||||
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text="🔄 Обновить подписку", callback_data=f"update_subscription|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
if is_full_remnawave and final_link:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
|
||||
)
|
||||
)
|
||||
else:
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
|
||||
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}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
|
||||
)
|
||||
)
|
||||
|
||||
if HWID_RESET_BUTTON and hwid_count > 0:
|
||||
builder.row(
|
||||
@@ -286,13 +341,23 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
|
||||
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
|
||||
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}"))
|
||||
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=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"))
|
||||
@@ -311,25 +376,37 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(key_name, session)
|
||||
record = await get_key_details(session, key_name)
|
||||
if not record:
|
||||
await callback_query.answer("❌ Ключ не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
client_id = record.get("client_id")
|
||||
if not client_id:
|
||||
await callback_query.answer("❌ У ключа отсутствует client_id.", show_alert=True)
|
||||
await callback_query.answer(
|
||||
"❌ У ключа отсутствует client_id.", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
servers = await get_servers()
|
||||
remna_server = next((srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None)
|
||||
servers = await get_servers(session=session)
|
||||
remna_server = next(
|
||||
(
|
||||
srv
|
||||
for cl in servers.values()
|
||||
for srv in cl
|
||||
if srv.get("panel_type") == "remnawave"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not remna_server:
|
||||
await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
api = RemnawaveAPI(remna_server["api_url"])
|
||||
if not await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await callback_query.answer("❌ Авторизация в Remnawave не удалась.", show_alert=True)
|
||||
await callback_query.answer(
|
||||
"❌ Авторизация в Remnawave не удалась.", show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
devices = await api.get_user_hwid_devices(client_id)
|
||||
@@ -340,7 +417,9 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
|
||||
for device in devices:
|
||||
if await api.delete_user_hwid_device(client_id, device["hwid"]):
|
||||
deleted += 1
|
||||
await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
|
||||
await callback_query.answer(
|
||||
f"✅ Устройства сброшены ({deleted})", show_alert=True
|
||||
)
|
||||
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
await render_key_info(callback_query.message, session, key_name, image_path)
|
||||
|
||||
+42
-40
@@ -1,39 +1,25 @@
|
||||
import asyncio
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import (
|
||||
delete_key,
|
||||
get_key_details,
|
||||
get_servers,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
APPLY,
|
||||
BACK,
|
||||
CANCEL,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
delete_key_from_cluster,
|
||||
update_subscription,
|
||||
)
|
||||
from database import delete_key, get_key_details, get_servers
|
||||
from handlers.buttons import APPLY, BACK, CANCEL
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, update_subscription
|
||||
from handlers.keys.key_view import process_callback_view_key
|
||||
from handlers.texts import (
|
||||
DELETE_KEY_CONFIRM_MSG,
|
||||
KEY_DELETED_MSG_SIMPLE,
|
||||
)
|
||||
from handlers.texts import DELETE_KEY_CONFIRM_MSG, KEY_DELETED_MSG_SIMPLE
|
||||
from handlers.utils import edit_or_send_message, handle_error
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("update_subscription|"))
|
||||
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
|
||||
async def process_callback_update_subscription(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
@@ -48,7 +34,9 @@ async def process_callback_update_subscription(callback_query: CallbackQuery, se
|
||||
await process_callback_view_key(callback_query, session)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}")
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при обновлении подписки: {e}")
|
||||
await handle_error(
|
||||
tg_id, callback_query, f"Ошибка при обновлении подписки: {e}"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_key|"))
|
||||
@@ -59,8 +47,7 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=APPLY,
|
||||
callback_data=f"confirm_delete|{client_id}",
|
||||
text=APPLY, callback_data=f"confirm_delete|{client_id}"
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
|
||||
@@ -72,49 +59,64 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
|
||||
caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(text=DELETE_KEY_CONFIRM_MSG, 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}")
|
||||
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):
|
||||
async def process_callback_confirm_delete(
|
||||
callback_query: CallbackQuery, session: AsyncSession
|
||||
):
|
||||
email = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(email, session)
|
||||
record = await get_key_details(session, email)
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
response_message = KEY_DELETED_MSG_SIMPLE
|
||||
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text=BACK, callback_data="view_keys"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await delete_key(client_id, session)
|
||||
await delete_key(session, client_id)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
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))
|
||||
tasks = [
|
||||
delete_key_from_cluster(cluster_id, email, client_id, session)
|
||||
for cluster_id in servers
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id}: {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")
|
||||
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
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.error(f"Ошибка при подтверждении удаления ключа: {e}")
|
||||
await handle_error(callback_query.message.chat.id, callback_query, str(e))
|
||||
|
||||
@@ -6,25 +6,27 @@ import time
|
||||
import urllib.parse
|
||||
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
|
||||
from aiohttp import web
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
PROJECT_NAME,
|
||||
RANDOM_SUBSCRIPTIONS,
|
||||
SUPERNODE,
|
||||
SUPPORT_CHAT_URL,
|
||||
USERNAME_BOT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USERNAME_BOT,
|
||||
)
|
||||
from database import get_key_details, get_servers
|
||||
from database.models import Server
|
||||
from handlers.utils import convert_to_bytes
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[str, str]]:
|
||||
async def fetch_url_content(
|
||||
url: str, identifier: str
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=5)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
@@ -33,7 +35,9 @@ async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[
|
||||
content = await response.text()
|
||||
lines = base64.b64decode(content).decode("utf-8").split("\n")
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
logger.debug(f"Fetched {url}: {len(lines)} lines, headers: {headers}")
|
||||
logger.debug(
|
||||
f"Fetched {url}: {len(lines)} lines, headers: {headers}"
|
||||
)
|
||||
return lines, headers
|
||||
return [], {}
|
||||
except Exception as e:
|
||||
@@ -45,7 +49,9 @@ async def combine_unique_lines(
|
||||
urls: list[str], identifier: str, query_string: str
|
||||
) -> tuple[list[str], list[dict[str, str]]]:
|
||||
if SUPERNODE:
|
||||
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
|
||||
logger.info(
|
||||
f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}"
|
||||
)
|
||||
if not urls:
|
||||
return [], []
|
||||
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
|
||||
@@ -65,14 +71,19 @@ async def combine_unique_lines(
|
||||
return list(all_lines), all_headers
|
||||
|
||||
|
||||
async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]:
|
||||
async def get_subscription_urls(
|
||||
server_id: str, email: str, session: AsyncSession, include_remnawave_key: str = None
|
||||
) -> list[str]:
|
||||
urls = []
|
||||
if USE_COUNTRY_SELECTION:
|
||||
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
|
||||
if server_data and server_data["subscription_url"]:
|
||||
urls.append(f"{server_data['subscription_url']}/{email}")
|
||||
result = await session.execute(
|
||||
select(Server.subscription_url).where(Server.server_name == server_id)
|
||||
)
|
||||
server_data = result.scalar()
|
||||
if server_data:
|
||||
urls.append(f"{server_data}/{email}")
|
||||
else:
|
||||
servers = await get_servers(conn)
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(server_id, [])
|
||||
for server in cluster_servers:
|
||||
if url := server.get("subscription_url"):
|
||||
@@ -85,9 +96,13 @@ async def get_subscription_urls(server_id: str, email: str, conn, include_remnaw
|
||||
|
||||
|
||||
def calculate_traffic(
|
||||
cleaned_subscriptions: list[str], expiry_time_ms: int | None, headers_list: list[dict[str, str]]
|
||||
cleaned_subscriptions: list[str],
|
||||
expiry_time_ms: int | None,
|
||||
headers_list: list[dict[str, str]],
|
||||
) -> str:
|
||||
logger.debug(f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}")
|
||||
logger.debug(
|
||||
f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}"
|
||||
)
|
||||
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
|
||||
|
||||
upload = 0
|
||||
@@ -128,7 +143,9 @@ def calculate_traffic(
|
||||
country_remaining[country] = remaining_bytes
|
||||
logger.debug(f"Found traffic: {value}{unit} for {country}")
|
||||
|
||||
consumed_traffic_bytes = total - sum(country_remaining.values()) if country_remaining else download
|
||||
consumed_traffic_bytes = (
|
||||
total - sum(country_remaining.values()) if country_remaining else download
|
||||
)
|
||||
if consumed_traffic_bytes < 0:
|
||||
consumed_traffic_bytes = 0
|
||||
download = max(download, consumed_traffic_bytes)
|
||||
@@ -171,7 +188,10 @@ def format_time_left(expiry_time_ms: int | None) -> str:
|
||||
|
||||
|
||||
def prepare_headers(
|
||||
user_agent: str, project_name: str, subscription_info: str, subscription_userinfo: str
|
||||
user_agent: str,
|
||||
project_name: str,
|
||||
subscription_info: str,
|
||||
subscription_userinfo: str,
|
||||
) -> dict[str, str]:
|
||||
if "Happ" in user_agent:
|
||||
encoded_project_name = f"{project_name}"
|
||||
@@ -180,9 +200,11 @@ def prepare_headers(
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"profile-update-interval": "3",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"profile-title": "base64:"
|
||||
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"support-url": SUPPORT_CHAT_URL,
|
||||
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
|
||||
"announce": "base64:"
|
||||
+ base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
|
||||
"profile-web-page-url": f"https://t.me/{USERNAME_BOT}",
|
||||
"subscription-userinfo": subscription_userinfo,
|
||||
}
|
||||
@@ -192,7 +214,8 @@ def prepare_headers(
|
||||
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
|
||||
return {
|
||||
"profile-update-interval": "3",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"profile-title": "base64:"
|
||||
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"subscription-userinfo": subscription_userinfo,
|
||||
}
|
||||
elif "v2raytun" in user_agent:
|
||||
@@ -202,9 +225,11 @@ def prepare_headers(
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"update-always": "true",
|
||||
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
|
||||
"announce": "base64:"
|
||||
+ base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
|
||||
"announce-url": f"{SUPPORT_CHAT_URL}",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"profile-title": "base64:"
|
||||
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"subscription-userinfo": subscription_userinfo,
|
||||
}
|
||||
else:
|
||||
@@ -213,7 +238,8 @@ def prepare_headers(
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"profile-update-interval": "3",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"profile-title": "base64:"
|
||||
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
}
|
||||
|
||||
|
||||
@@ -224,42 +250,59 @@ async def handle_subscription(request: web.Request) -> web.Response:
|
||||
if not email or not tg_id:
|
||||
return web.Response(text="❌ Неверные параметры запроса.", status=400)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
client_data = await get_key_details(email, conn)
|
||||
if not client_data:
|
||||
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
|
||||
sessionmaker = request.app["sessionmaker"]
|
||||
|
||||
stored_tg_id = client_data.get("tg_id")
|
||||
server_id = client_data["server_id"]
|
||||
async with sessionmaker() as session:
|
||||
try:
|
||||
key = await get_key_details(session, email)
|
||||
if not key:
|
||||
return web.Response(
|
||||
text="❌ Клиент с таким email не найден.", status=404
|
||||
)
|
||||
|
||||
if int(tg_id) != int(stored_tg_id):
|
||||
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
|
||||
if int(tg_id) != int(key["tg_id"]):
|
||||
return web.Response(
|
||||
text="❌ Неверные данные. Получите свой ключ в боте.", status=403
|
||||
)
|
||||
|
||||
expiry_time_ms = client_data.get("expiry_time")
|
||||
time_left = format_time_left(expiry_time_ms)
|
||||
expiry_time_ms = key["expiry_time"]
|
||||
server_id = key["server_id"]
|
||||
remnawave_link = key["remnawave_link"]
|
||||
|
||||
urls = await get_subscription_urls(
|
||||
server_id, email, conn, include_remnawave_key=client_data.get("remnawave_link")
|
||||
)
|
||||
time_left = format_time_left(expiry_time_ms)
|
||||
|
||||
if not urls:
|
||||
return web.Response(text="❌ Сервер не найден.", status=404)
|
||||
urls = await get_subscription_urls(
|
||||
server_id, email, session, include_remnawave_key=remnawave_link
|
||||
)
|
||||
if not urls:
|
||||
return web.Response(text="❌ Сервер не найден.", status=404)
|
||||
|
||||
query_string = request.query_string
|
||||
combined_subscriptions, headers_list = await combine_unique_lines(urls, tg_id or email, query_string)
|
||||
if RANDOM_SUBSCRIPTIONS:
|
||||
random.shuffle(combined_subscriptions)
|
||||
query_string = request.query_string
|
||||
combined_subscriptions, headers_list = await combine_unique_lines(
|
||||
urls, tg_id or email, query_string
|
||||
)
|
||||
if RANDOM_SUBSCRIPTIONS:
|
||||
random.shuffle(combined_subscriptions)
|
||||
|
||||
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
|
||||
cleaned_subscriptions = [
|
||||
clean_subscription_line(line) for line in combined_subscriptions
|
||||
]
|
||||
|
||||
base64_encoded = base64.b64encode("\n".join(cleaned_subscriptions).encode("utf-8")).decode("utf-8")
|
||||
subscription_info = f"📄 Подписка: {email} - {time_left}"
|
||||
base64_encoded = base64.b64encode(
|
||||
"\n".join(cleaned_subscriptions).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
subscription_info = f"📄 Подписка: {email} — {time_left}"
|
||||
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms, headers_list)
|
||||
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
subscription_userinfo = calculate_traffic(
|
||||
cleaned_subscriptions, expiry_time_ms, headers_list
|
||||
)
|
||||
headers = prepare_headers(
|
||||
user_agent, PROJECT_NAME, subscription_info, subscription_userinfo
|
||||
)
|
||||
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
finally:
|
||||
await conn.close()
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в handle_subscription: {e}", exc_info=True)
|
||||
return web.Response(text=f"❌ Ошибка сервера: {e}", status=500)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,23 +1,20 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import Bot, Router
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
NOTIFICATION_TIME,
|
||||
NOTIFY_DELETE_DELAY,
|
||||
NOTIFY_DELETE_KEY,
|
||||
NOTIFY_HOT_LEADS,
|
||||
NOTIFY_INACTIVE_TRAFFIC,
|
||||
NOTIFY_MAXPRICE,
|
||||
NOTIFY_RENEW,
|
||||
NOTIFY_RENEW_EXPIRED,
|
||||
TRIAL_TIME_DISABLE,
|
||||
NOTIFY_HOT_LEADS
|
||||
)
|
||||
from database import (
|
||||
add_notification,
|
||||
@@ -51,128 +48,140 @@ from handlers.texts import (
|
||||
from handlers.utils import format_hours, format_minutes
|
||||
from logger import logger
|
||||
|
||||
from .hot_leads_notifications import notify_hot_leads
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
|
||||
from .hot_leads_notifications import notify_hot_leads
|
||||
|
||||
|
||||
router = Router()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
notification_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def periodic_notifications(bot: Bot):
|
||||
"""
|
||||
Периодическая проверка и отправка уведомлений.
|
||||
Защищена от одновременного запуска с помощью asyncio.Lock.
|
||||
"""
|
||||
async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
|
||||
while True:
|
||||
if notification_lock.locked():
|
||||
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
|
||||
logger.warning("Уведомления уже выполняются. Пропуск...")
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
continue
|
||||
|
||||
async with notification_lock:
|
||||
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)
|
||||
async with sessionmaker() as session:
|
||||
logger.info("🔔 Запуск обработки уведомлений")
|
||||
|
||||
logger.info("Запуск обработки уведомлений")
|
||||
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
threshold_10h = int(
|
||||
(datetime.now(moscow_tz) + timedelta(hours=10)).timestamp()
|
||||
* 1000
|
||||
)
|
||||
threshold_24h = int(
|
||||
(datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000
|
||||
)
|
||||
|
||||
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:
|
||||
try:
|
||||
await notify_inactive_trial_users(bot, conn)
|
||||
keys = await get_all_keys(session=session)
|
||||
keys = [k for k in keys if not k.is_frozen]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.error(f"Ошибка при получении ключей: {e}")
|
||||
keys = []
|
||||
|
||||
try:
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_24h_keys: {e}")
|
||||
await asyncio.sleep(1)
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
try:
|
||||
await notify_inactive_trial_users(bot, session)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
|
||||
|
||||
try:
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_10h_keys: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
await handle_expired_keys(bot, conn, current_time, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в handle_expired_keys: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if NOTIFY_INACTIVE_TRAFFIC:
|
||||
try:
|
||||
await notify_users_no_traffic(bot, conn, current_time, keys)
|
||||
await notify_24h_keys(
|
||||
bot, session, current_time, threshold_24h, keys
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
if NOTIFY_HOT_LEADS:
|
||||
logger.error(f"Ошибка в notify_24h_keys: {e}")
|
||||
|
||||
try:
|
||||
await notify_hot_leads(bot)
|
||||
await notify_10h_keys(
|
||||
bot, session, current_time, threshold_10h, keys
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_hot_leads: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
logger.error(f"Ошибка в notify_10h_keys: {e}")
|
||||
|
||||
logger.info("Завершена обработка уведомлений")
|
||||
try:
|
||||
await handle_expired_keys(bot, session, current_time, keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в handle_expired_keys: {e}")
|
||||
|
||||
if NOTIFY_INACTIVE_TRAFFIC:
|
||||
try:
|
||||
await notify_users_no_traffic(
|
||||
bot, session, current_time, keys
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
|
||||
|
||||
if NOTIFY_HOT_LEADS:
|
||||
try:
|
||||
await notify_hot_leads(bot, session)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в notify_hot_leads: {e}")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_24h: int, keys: list):
|
||||
async def notify_24h_keys(
|
||||
bot: Bot,
|
||||
session: AsyncSession,
|
||||
current_time: int,
|
||||
threshold_time_24h: int,
|
||||
keys: list,
|
||||
):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 24 часа.")
|
||||
|
||||
expiring_keys = [
|
||||
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_24h
|
||||
key
|
||||
for key in keys
|
||||
if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
emails = [key.email or "" for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk(
|
||||
session, "key_24h", 24, tg_ids=tg_ids, emails=emails
|
||||
)
|
||||
|
||||
users = await check_notifications_bulk("key_24h", 24, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
email = key.email or ""
|
||||
notification_id = f"{email}_key_24h"
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
|
||||
can_notify = await check_notification_time(
|
||||
session, tg_id, notification_id, hours=24
|
||||
)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
user = next(
|
||||
(u for u in users if u["tg_id"] == tg_id and u["email"] == email), None
|
||||
)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
expiry_timestamp = key.expiry_time
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}"
|
||||
if hours_left > 0
|
||||
else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
@@ -187,74 +196,102 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
if NOTIFY_RENEW:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text
|
||||
bot,
|
||||
session,
|
||||
key,
|
||||
notification_id,
|
||||
1,
|
||||
"notify_24h.jpg",
|
||||
notification_text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
logger.error(
|
||||
f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_24h.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_24h.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
}
|
||||
)
|
||||
|
||||
if messages:
|
||||
results = await send_messages_with_limit(bot, messages, conn=conn)
|
||||
results = await send_messages_with_limit(bot, messages, session=session)
|
||||
sent_count = 0
|
||||
for msg, result in zip(messages, results, strict=False):
|
||||
tg_id = msg["tg_id"]
|
||||
if result:
|
||||
await add_notification(tg_id, msg["notification_id"], session=conn)
|
||||
await add_notification(session, tg_id, msg["notification_id"])
|
||||
sent_count += 1
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
|
||||
logger.info(
|
||||
f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
)
|
||||
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
|
||||
logger.info(
|
||||
f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа."
|
||||
)
|
||||
|
||||
logger.info("Обработка всех уведомлений за 24 часа завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_10h: int, keys: list):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 10 часов.
|
||||
"""
|
||||
async def notify_10h_keys(
|
||||
bot: Bot,
|
||||
session: AsyncSession,
|
||||
current_time: int,
|
||||
threshold_time_10h: int,
|
||||
keys: list,
|
||||
):
|
||||
logger.info("Начало проверки подписок, истекающих через 10 часов.")
|
||||
|
||||
expiring_keys = [
|
||||
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_10h
|
||||
key
|
||||
for key in keys
|
||||
if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
tg_ids = [key.tg_id for key in expiring_keys]
|
||||
emails = [key.email or "" for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_10h", 10, conn, tg_ids=tg_ids, emails=emails)
|
||||
users = await check_notifications_bulk(
|
||||
session, "key_10h", 10, tg_ids=tg_ids, emails=emails
|
||||
)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
notification_id = f"{email}_key_10h"
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
|
||||
can_notify = await check_notification_time(
|
||||
session, tg_id, notification_id, hours=10
|
||||
)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
user = next(
|
||||
(u for u in users if u["tg_id"] == tg_id and u["email"] == email), None
|
||||
)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
expiry_timestamp = key.expiry_time
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}"
|
||||
if hours_left > 0
|
||||
else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
@@ -269,71 +306,102 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
if NOTIFY_RENEW:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
bot, conn, key, notification_id, 1, "notify_10h.jpg", notification_text
|
||||
bot,
|
||||
session,
|
||||
key,
|
||||
notification_id,
|
||||
1,
|
||||
"notify_10h.jpg",
|
||||
notification_text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
logger.error(
|
||||
f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_10h.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_10h.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
}
|
||||
)
|
||||
|
||||
if messages:
|
||||
results = await send_messages_with_limit(bot, messages, conn=conn)
|
||||
results = await send_messages_with_limit(bot, messages, session=session)
|
||||
sent_count = 0
|
||||
for msg, result in zip(messages, results, strict=False):
|
||||
tg_id = msg["tg_id"]
|
||||
if result:
|
||||
await add_notification(tg_id, msg["notification_id"], session=conn)
|
||||
await add_notification(session, tg_id, msg["notification_id"])
|
||||
sent_count += 1
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
|
||||
logger.info(
|
||||
f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
|
||||
)
|
||||
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
|
||||
logger.info(
|
||||
f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов."
|
||||
)
|
||||
|
||||
logger.info("Обработка всех уведомлений за 10 часов завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
|
||||
async def handle_expired_keys(
|
||||
bot: Bot,
|
||||
session: AsyncSession,
|
||||
current_time: int,
|
||||
keys: list,
|
||||
):
|
||||
logger.info("Начало обработки истекших ключей.")
|
||||
|
||||
expired_keys = [key for key in keys if key.get("expiry_time") and key["expiry_time"] < current_time]
|
||||
expired_keys = [
|
||||
key for key in keys if key.expiry_time and key.expiry_time < current_time
|
||||
]
|
||||
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expired_keys]
|
||||
emails = [key.get("email", "") for key in expired_keys]
|
||||
users = await check_notifications_bulk("key_expired", 0, conn, tg_ids=tg_ids, emails=emails)
|
||||
tg_ids = [key.tg_id for key in expired_keys]
|
||||
emails = [key.email or "" for key in expired_keys]
|
||||
users = await check_notifications_bulk(
|
||||
session, "key_expired", 0, tg_ids=tg_ids, emails=emails
|
||||
)
|
||||
|
||||
messages = []
|
||||
|
||||
for key in expired_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key["email"]
|
||||
client_id = key["client_id"]
|
||||
server_id = key["server_id"]
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
client_id = key.client_id
|
||||
server_id = key.server_id
|
||||
notification_id = f"{email}_key_expired"
|
||||
|
||||
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
|
||||
last_notification_time = await get_last_notification_time(
|
||||
session, tg_id, notification_id
|
||||
)
|
||||
|
||||
if NOTIFY_RENEW_EXPIRED:
|
||||
try:
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(conn, server_id)
|
||||
balance = await get_balance(session, tg_id)
|
||||
tariffs = await get_tariffs_for_cluster(session, server_id)
|
||||
tariff = tariffs[0] if tariffs else None
|
||||
|
||||
if tariff and balance >= tariff["price_rub"]:
|
||||
if tariff and balance >= tariff.price_rub:
|
||||
await process_auto_renew_or_notify(
|
||||
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
|
||||
bot,
|
||||
session,
|
||||
key,
|
||||
notification_id,
|
||||
1,
|
||||
"notify_expired.jpg",
|
||||
KEY_RENEWED_TEMP_MSG,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -345,32 +413,42 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
delete_after_delay = False
|
||||
|
||||
if last_notification_time is not None:
|
||||
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= NOTIFY_DELETE_DELAY
|
||||
delete_after_delay = (
|
||||
current_time - last_notification_time.timestamp() * 1000
|
||||
) / (1000 * 60) >= NOTIFY_DELETE_DELAY
|
||||
logger.info(
|
||||
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
|
||||
f"Прошло минут={(current_time - last_notification_time.timestamp() * 1000) / (1000 * 60):.2f} "
|
||||
f"NOTIFY_DELETE_DELAY={NOTIFY_DELETE_DELAY}"
|
||||
)
|
||||
|
||||
if delete_immediately or delete_after_delay:
|
||||
try:
|
||||
await delete_key_from_cluster(server_id, email, client_id)
|
||||
await delete_key(client_id, conn)
|
||||
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
|
||||
await delete_key_from_cluster(server_id, email, client_id, session)
|
||||
await delete_key(session, client_id)
|
||||
logger.info(
|
||||
f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён."
|
||||
)
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": KEY_DELETED_MSG.format(email=email),
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": KEY_DELETED_MSG.format(email=email),
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
logger.error(
|
||||
f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
|
||||
if last_notification_time is None and any(
|
||||
u["tg_id"] == tg_id and u["email"] == email for u in users
|
||||
):
|
||||
keyboard = build_notification_kb(email)
|
||||
|
||||
if NOTIFY_DELETE_DELAY > 0:
|
||||
@@ -378,10 +456,14 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
minutes = NOTIFY_DELETE_DELAY % 60
|
||||
if hours > 0 and minutes > 0:
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
|
||||
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
|
||||
email=email,
|
||||
hours_formatted=format_hours(hours),
|
||||
minutes_formatted=format_minutes(minutes),
|
||||
)
|
||||
elif hours > 0:
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(email=email, hours_formatted=format_hours(hours))
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
|
||||
email=email, hours_formatted=format_hours(hours)
|
||||
)
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
|
||||
email=email, minutes_formatted=format_minutes(minutes)
|
||||
@@ -389,23 +471,27 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
|
||||
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": delay_message,
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": delay_message,
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
}
|
||||
)
|
||||
|
||||
if messages:
|
||||
results = await send_messages_with_limit(bot, messages, conn=conn)
|
||||
results = await send_messages_with_limit(bot, messages, session=session)
|
||||
sent_count = 0
|
||||
for msg, result in zip(messages, results, strict=False):
|
||||
await add_notification(msg["tg_id"], msg["notification_id"], session=conn)
|
||||
await add_notification(session, msg["tg_id"], msg["notification_id"])
|
||||
if result:
|
||||
sent_count += 1
|
||||
logger.info(f"📢 Уведомление об истекшем ключе {msg['email']} отправлено пользователю {msg['tg_id']}.")
|
||||
logger.info(
|
||||
f"📢 Уведомление об истекшем ключе {msg['email']} отправлено пользователю {msg['tg_id']}."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление об истекшем ключе {msg['email']} пользователю {msg['tg_id']}."
|
||||
@@ -418,26 +504,36 @@ 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,
|
||||
notification_id: str,
|
||||
renewal_period_months: int,
|
||||
standard_photo: str,
|
||||
standard_caption: str,
|
||||
):
|
||||
tg_id = key.get("tg_id")
|
||||
email = key.get("email", "")
|
||||
tg_id = key.tg_id
|
||||
email = key.email or ""
|
||||
renew_notification_id = f"{email}_renew"
|
||||
|
||||
try:
|
||||
can_renew = await check_notification_time(tg_id, renew_notification_id, hours=24, session=conn)
|
||||
can_renew = await check_notification_time(
|
||||
conn, tg_id, renew_notification_id, hours=24
|
||||
)
|
||||
if not can_renew:
|
||||
logger.info(
|
||||
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
|
||||
)
|
||||
return
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
server_id = key.get("server_id")
|
||||
balance = await get_balance(conn, tg_id)
|
||||
server_id = key.server_id
|
||||
|
||||
tariffs = await get_tariffs_for_cluster(conn, server_id)
|
||||
if not tariffs:
|
||||
logger.warning(f"⛔ Нет доступных тарифов для продления подписки {email} (сервер: {server_id})")
|
||||
logger.warning(
|
||||
f"⛔ Нет доступных тарифов для продления подписки {email} (сервер: {server_id})"
|
||||
)
|
||||
return
|
||||
|
||||
if NOTIFY_MAXPRICE:
|
||||
@@ -448,12 +544,14 @@ async def process_auto_renew_or_notify(
|
||||
|
||||
if not selected_tariff:
|
||||
keyboard = build_notification_kb(email)
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
await add_notification(conn, tg_id, notification_id)
|
||||
await send_notification(
|
||||
bot, tg_id, standard_photo, standard_caption, keyboard
|
||||
)
|
||||
return
|
||||
|
||||
client_id = key.get("client_id")
|
||||
current_expiry = key.get("expiry_time")
|
||||
client_id = key.client_id
|
||||
current_expiry = key.expiry_time
|
||||
duration_days = selected_tariff["duration_days"]
|
||||
renewal_cost = selected_tariff["price_rub"]
|
||||
traffic_limit = selected_tariff["traffic_limit"]
|
||||
@@ -465,26 +563,38 @@ async def process_auto_renew_or_notify(
|
||||
else datetime.utcnow().timestamp() * 1000
|
||||
) + duration_days * 24 * 60 * 60 * 1000
|
||||
|
||||
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
|
||||
formatted_expiry_date = datetime.fromtimestamp(
|
||||
new_expiry_time / 1000, tz=moscow_tz
|
||||
).strftime("%d %B %Y, %H:%M")
|
||||
|
||||
logger.info(
|
||||
f"Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
|
||||
)
|
||||
|
||||
await renew_key_in_cluster(server_id, email, client_id, int(new_expiry_time), total_gb)
|
||||
await update_balance(tg_id, -renewal_cost, session=conn)
|
||||
await update_key_expiry(client_id, int(new_expiry_time), conn)
|
||||
await add_notification(tg_id, renew_notification_id, session=conn)
|
||||
await delete_notification(tg_id, notification_id, session=conn)
|
||||
await renew_key_in_cluster(
|
||||
server_id, email, client_id, int(new_expiry_time), total_gb
|
||||
)
|
||||
await update_balance(conn, tg_id, -renewal_cost)
|
||||
await update_key_expiry(conn, client_id, int(new_expiry_time))
|
||||
await add_notification(conn, tg_id, renew_notification_id)
|
||||
await delete_notification(conn, tg_id, notification_id)
|
||||
|
||||
renewed_message = KEY_RENEWED.format(email=email, months=duration_days // 30, expiry_date=formatted_expiry_date)
|
||||
renewed_message = KEY_RENEWED.format(
|
||||
email=email, months=duration_days // 30, expiry_date=formatted_expiry_date
|
||||
)
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
result = await send_notification(
|
||||
bot, tg_id, "notify_expired.jpg", renewed_message, keyboard
|
||||
)
|
||||
if result:
|
||||
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
|
||||
logger.info(
|
||||
f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}."
|
||||
)
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {e}")
|
||||
|
||||
@@ -1,74 +1,125 @@
|
||||
import asyncpg
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import DATABASE_URL, HOT_LEAD_INTERVAL_HOURS
|
||||
from database import check_notification_time, add_notification, get_hot_leads
|
||||
from handlers.notifications.notify_utils import send_notification
|
||||
from logger import logger
|
||||
from config import DISCOUNT_ACTIVE_HOURS, HOT_LEAD_INTERVAL_HOURS
|
||||
from database import add_notification, check_notification_time, get_hot_leads
|
||||
from database.models import Notification
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.notifications.notify_kb import build_hot_lead_kb
|
||||
from handlers.texts import HOT_LEAD_MESSAGE, HOT_LEAD_FINAL_MESSAGE
|
||||
from handlers.notifications.notify_utils import send_notification
|
||||
from handlers.texts import (
|
||||
HOT_LEAD_FINAL_MESSAGE,
|
||||
HOT_LEAD_LOST_OPPORTUNITY,
|
||||
HOT_LEAD_MESSAGE,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def notify_hot_leads(bot: Bot):
|
||||
async def notify_hot_leads(bot: Bot, session: AsyncSession):
|
||||
logger.info("🚀 Запуск уведомлений для горячих лидов.")
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
try:
|
||||
leads = await get_hot_leads(conn)
|
||||
leads = await get_hot_leads(session)
|
||||
notified = 0
|
||||
|
||||
for row in leads:
|
||||
tg_id = row["tg_id"]
|
||||
|
||||
has_step_1 = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = $1 AND notification_type = 'hot_lead_step_1')",
|
||||
tg_id
|
||||
for tg_id in leads:
|
||||
has_step_1 = await session.scalar(
|
||||
select(
|
||||
select(Notification)
|
||||
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_1")
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
if not has_step_1:
|
||||
await add_notification(tg_id, "hot_lead_step_1", session=conn)
|
||||
await add_notification(session, tg_id, "hot_lead_step_1")
|
||||
logger.info(f"[HOT LEAD] Шаг 1 — зафиксировано без отправки: {tg_id}")
|
||||
continue
|
||||
|
||||
has_step_2 = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = $1 AND notification_type = 'hot_lead_step_2')",
|
||||
tg_id
|
||||
has_step_2 = await session.scalar(
|
||||
select(
|
||||
select(Notification)
|
||||
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_2")
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
if not has_step_2:
|
||||
can_send = await check_notification_time(
|
||||
session,
|
||||
tg_id=tg_id,
|
||||
notification_type="hot_lead_step_1",
|
||||
hours=HOT_LEAD_INTERVAL_HOURS,
|
||||
session=conn
|
||||
)
|
||||
if not can_send:
|
||||
continue
|
||||
|
||||
keyboard = build_hot_lead_kb()
|
||||
result = await send_notification(bot, tg_id, None, HOT_LEAD_MESSAGE, keyboard)
|
||||
result = await send_notification(
|
||||
bot, tg_id, None, HOT_LEAD_MESSAGE, keyboard
|
||||
)
|
||||
if result:
|
||||
await add_notification(tg_id, "hot_lead_step_2", session=conn)
|
||||
await add_notification(session, tg_id, "hot_lead_step_2")
|
||||
logger.info(f"🔥 Шаг 2 — отправлено первое уведомление: {tg_id}")
|
||||
notified += 1
|
||||
continue
|
||||
|
||||
has_step_3 = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = $1 AND notification_type = 'hot_lead_step_3')",
|
||||
tg_id
|
||||
has_step_3 = await session.scalar(
|
||||
select(
|
||||
select(Notification)
|
||||
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_3")
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
has_expired_notification = await session.scalar(
|
||||
select(
|
||||
select(Notification)
|
||||
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_2_expired")
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
if not has_step_3 and not has_expired_notification:
|
||||
expired = await check_notification_time(
|
||||
session,
|
||||
tg_id=tg_id,
|
||||
notification_type="hot_lead_step_2",
|
||||
hours=DISCOUNT_ACTIVE_HOURS,
|
||||
)
|
||||
if expired:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
|
||||
)
|
||||
|
||||
result = await send_notification(
|
||||
bot, tg_id, None, HOT_LEAD_LOST_OPPORTUNITY, builder.as_markup()
|
||||
)
|
||||
if result:
|
||||
await add_notification(
|
||||
session, tg_id, "hot_lead_step_2_expired"
|
||||
)
|
||||
logger.info(
|
||||
f"📭 Скидка упущена — отправлено уведомление: {tg_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not has_step_3:
|
||||
can_send = await check_notification_time(
|
||||
session,
|
||||
tg_id=tg_id,
|
||||
notification_type="hot_lead_step_2",
|
||||
hours=HOT_LEAD_INTERVAL_HOURS,
|
||||
session=conn
|
||||
)
|
||||
if not can_send:
|
||||
continue
|
||||
|
||||
keyboard = build_hot_lead_kb(final=True)
|
||||
result = await send_notification(bot, tg_id, None, HOT_LEAD_FINAL_MESSAGE, keyboard)
|
||||
result = await send_notification(
|
||||
bot, tg_id, None, HOT_LEAD_FINAL_MESSAGE, keyboard
|
||||
)
|
||||
if result:
|
||||
await add_notification(tg_id, "hot_lead_step_3", session=conn)
|
||||
await add_notification(session, tg_id, "hot_lead_step_3")
|
||||
logger.info(f"⚡ Шаг 3 — отправлено финальное уведомление: {tg_id}")
|
||||
notified += 1
|
||||
|
||||
@@ -76,6 +127,3 @@ async def notify_hot_leads(bot: Bot):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в notify_hot_leads: {e}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from handlers.buttons import MAIN_MENU, RENEW_KEY
|
||||
from handlers.buttons import DISCOUNT_TARIFF, MAX_DISCOUNT_TARIFF
|
||||
from handlers.buttons import DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY
|
||||
|
||||
|
||||
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
|
||||
@@ -31,20 +30,30 @@ def build_notification_expired_kb() -> InlineKeyboardMarkup:
|
||||
|
||||
|
||||
def build_hot_lead_kb(final: bool = False) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=DISCOUNT_TARIFF if not final else MAX_DISCOUNT_TARIFF,
|
||||
callback_data="hot_lead_discount" if not final else "hot_lead_final_discount"
|
||||
)]
|
||||
])
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=DISCOUNT_TARIFF if not final else MAX_DISCOUNT_TARIFF,
|
||||
callback_data=(
|
||||
"hot_lead_discount" if not final else "hot_lead_final_discount"
|
||||
),
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_tariffs_keyboard(tariffs: list[dict], prefix: str = "tariff") -> InlineKeyboardMarkup:
|
||||
def build_tariffs_keyboard(
|
||||
tariffs: list[dict], prefix: str = "tariff"
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
callback_data=f"{prefix}|{t['id']}"
|
||||
)]
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"{t['name']} — {t['price_rub']}₽",
|
||||
callback_data=f"{prefix}|{t['id']}",
|
||||
)
|
||||
]
|
||||
for t in tariffs
|
||||
]
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@ import asyncio
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
import asyncpg
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.exceptions import (
|
||||
TelegramBadRequest,
|
||||
TelegramForbiddenError,
|
||||
TelegramRetryAfter,
|
||||
)
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import create_blocked_user
|
||||
from logger import logger
|
||||
@@ -15,7 +18,7 @@ from logger import logger
|
||||
async def send_messages_with_limit(
|
||||
bot: Bot,
|
||||
messages: list[dict],
|
||||
conn: asyncpg.Connection = None,
|
||||
session: AsyncSession = None,
|
||||
source_file: str = None,
|
||||
messages_per_second: int = 25,
|
||||
):
|
||||
@@ -25,49 +28,59 @@ async def send_messages_with_limit(
|
||||
"""
|
||||
batch_size = messages_per_second
|
||||
results = []
|
||||
|
||||
for i in range(0, len(messages), batch_size):
|
||||
batch = messages[i : i + batch_size]
|
||||
tasks = []
|
||||
for msg in batch:
|
||||
tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")))
|
||||
tasks = [
|
||||
send_notification(
|
||||
bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")
|
||||
)
|
||||
for msg in batch
|
||||
]
|
||||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
processed_results = []
|
||||
|
||||
for msg, result in zip(batch, batch_results, strict=False):
|
||||
tg_id = msg["tg_id"]
|
||||
|
||||
if isinstance(result, bool) and result:
|
||||
processed_results.append(True)
|
||||
results.append(True)
|
||||
elif isinstance(result, TelegramForbiddenError):
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
if source_file == "special_notifications" and conn:
|
||||
try:
|
||||
await create_blocked_user(tg_id, conn)
|
||||
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
|
||||
except Exception:
|
||||
pass
|
||||
processed_results.append(False)
|
||||
elif isinstance(result, TelegramBadRequest) and "chat not found" in str(result).lower():
|
||||
await try_add_blocked_user(tg_id, session, source_file)
|
||||
results.append(False)
|
||||
elif (
|
||||
isinstance(result, TelegramBadRequest)
|
||||
and "chat not found" in str(result).lower()
|
||||
):
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
||||
if source_file == "special_notifications" and conn:
|
||||
try:
|
||||
await create_blocked_user(tg_id, conn)
|
||||
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
|
||||
except Exception:
|
||||
pass
|
||||
processed_results.append(False)
|
||||
await try_add_blocked_user(tg_id, session, source_file)
|
||||
results.append(False)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id}.")
|
||||
if source_file == "special_notifications" and conn:
|
||||
try:
|
||||
await create_blocked_user(tg_id, conn)
|
||||
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
|
||||
except Exception:
|
||||
pass
|
||||
processed_results.append(False)
|
||||
results.extend(processed_results)
|
||||
logger.warning(
|
||||
f"📩 Не удалось отправить уведомление пользователю {tg_id}."
|
||||
)
|
||||
await try_add_blocked_user(tg_id, session, source_file)
|
||||
results.append(False)
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def try_add_blocked_user(
|
||||
tg_id: int, session: AsyncSession, source_file: str | None
|
||||
):
|
||||
"""
|
||||
Добавляет пользователя в список заблокировавших бота, если требуется.
|
||||
"""
|
||||
if source_file == "special_notifications" and session:
|
||||
try:
|
||||
await create_blocked_user(session, tg_id)
|
||||
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
|
||||
|
||||
|
||||
def rate_limited_send(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
while True:
|
||||
@@ -108,7 +121,9 @@ async def send_notification(
|
||||
|
||||
photo_path = os.path.join("img", image_filename)
|
||||
if os.path.isfile(photo_path):
|
||||
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
|
||||
return await _send_photo_notification(
|
||||
bot, tg_id, photo_path, image_filename, caption, keyboard
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Файл с изображением не найден: {photo_path}")
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
@@ -128,7 +143,9 @@ async def _send_photo_notification(
|
||||
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)
|
||||
await bot.send_photo(
|
||||
tg_id, buffered_photo, caption=caption, reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest):
|
||||
return False
|
||||
@@ -151,5 +168,7 @@ async def _send_text_notification(
|
||||
except (TelegramForbiddenError, TelegramBadRequest):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
|
||||
logger.error(
|
||||
f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
@@ -15,7 +12,12 @@ from config import (
|
||||
SUPPORT_CHAT_URL,
|
||||
TRIAL_TIME,
|
||||
)
|
||||
from database import add_notification, check_notifications_bulk, create_blocked_user
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notifications_bulk,
|
||||
mark_trial_extended,
|
||||
update_key_notified,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.keys.key_utils import get_user_traffic
|
||||
from handlers.texts import (
|
||||
@@ -26,39 +28,36 @@ from handlers.texts import (
|
||||
from handlers.utils import format_days
|
||||
from logger import logger
|
||||
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
|
||||
from .notify_utils import send_messages_with_limit
|
||||
|
||||
router = Router()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
"""
|
||||
Проверяет пользователей, не активировавших пробный период, и отправляет им напоминания.
|
||||
Первое уведомление — стандартное.
|
||||
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
|
||||
"""
|
||||
async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
|
||||
users = await check_notifications_bulk(session, "inactive_trial", NOTIFY_INACTIVE)
|
||||
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
||||
messages = []
|
||||
|
||||
for user in users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user["username"]
|
||||
first_name = user["first_name"]
|
||||
last_name = user["last_name"]
|
||||
display_name = username or first_name or last_name or "Пользователь"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
text="🚀 Активировать пробный период", callback_data="create_key"
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
trial_extended = user["last_notification_time"] is not None
|
||||
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
|
||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||
@@ -66,118 +65,127 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
|
||||
total_days_formatted=format_days(total_days),
|
||||
)
|
||||
await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
|
||||
await mark_trial_extended(tg_id, session)
|
||||
else:
|
||||
message = TRIAL_INACTIVE_FIRST_MSG.format(
|
||||
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
|
||||
)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": message,
|
||||
"keyboard": keyboard,
|
||||
"notification_id": "inactive_trial",
|
||||
})
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": message,
|
||||
"keyboard": keyboard,
|
||||
"notification_id": "inactive_trial",
|
||||
}
|
||||
)
|
||||
|
||||
if messages:
|
||||
results = await send_messages_with_limit(
|
||||
bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
|
||||
bot,
|
||||
messages,
|
||||
session=session,
|
||||
source_file="special_notifications",
|
||||
messages_per_second=25,
|
||||
)
|
||||
sent_count = 0
|
||||
for msg, result in zip(messages, results, strict=False):
|
||||
tg_id = msg["tg_id"]
|
||||
if result:
|
||||
await add_notification(tg_id, msg["notification_id"], session=conn)
|
||||
await add_notification(session, msg["tg_id"], msg["notification_id"])
|
||||
sent_count += 1
|
||||
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление неактивному пользователю {tg_id}.")
|
||||
logger.info(f"Отправлено {sent_count} уведомлений неактивным пользователям.")
|
||||
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
|
||||
|
||||
|
||||
async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
|
||||
"""
|
||||
Проверяет трафик пользователей, у которых ещё не отправлялось уведомление о нулевом трафике.
|
||||
Если трафик 0 ГБ и прошло более 2 часов с момента создания ключа, отправляет уведомление,
|
||||
но исключает пользователей, у которых подписка недавно продлилась.
|
||||
"""
|
||||
async def notify_users_no_traffic(
|
||||
bot: Bot, session: AsyncSession, current_time: int, keys: list
|
||||
):
|
||||
logger.info("Проверка пользователей с нулевым трафиком...")
|
||||
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
|
||||
messages = []
|
||||
|
||||
for key in keys:
|
||||
tg_id = key.get("tg_id")
|
||||
email = key.get("email")
|
||||
created_at = key.get("created_at")
|
||||
client_id = key.get("client_id")
|
||||
expiry_time = key.get("expiry_time")
|
||||
notified = key.get("notified")
|
||||
tg_id = key.tg_id
|
||||
email = key.email
|
||||
created_at = key.created_at
|
||||
client_id = key.client_id
|
||||
expiry_time = key.expiry_time
|
||||
notified = key.notified
|
||||
|
||||
if created_at is None:
|
||||
logger.warning(f"Для {email} нет значения created_at. Пропускаем.")
|
||||
if created_at is None or notified:
|
||||
continue
|
||||
|
||||
if notified is True:
|
||||
created_at_dt = pytz.utc.localize(
|
||||
datetime.fromtimestamp(created_at / 1000)
|
||||
).astimezone(moscow_tz)
|
||||
if current_dt < created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC):
|
||||
continue
|
||||
|
||||
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
|
||||
created_at_plus_2 = created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
|
||||
|
||||
if expiry_time:
|
||||
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
|
||||
renewal_threshold = expiry_dt - timedelta(days=30)
|
||||
renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
|
||||
if renewal_recent:
|
||||
expiry_dt = pytz.utc.localize(
|
||||
datetime.fromtimestamp(expiry_time / 1000)
|
||||
).astimezone(moscow_tz)
|
||||
if (current_dt - (expiry_dt - timedelta(days=30))) < timedelta(
|
||||
hours=NOTIFY_INACTIVE_TRAFFIC
|
||||
):
|
||||
continue
|
||||
|
||||
if current_dt < created_at_plus_2:
|
||||
continue
|
||||
|
||||
try:
|
||||
traffic_data = await get_user_traffic(conn, tg_id, email)
|
||||
traffic_data = await get_user_traffic(session, tg_id, email)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения трафика для {email}: {e}")
|
||||
continue
|
||||
|
||||
if traffic_data.get("status") != "success":
|
||||
logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
||||
logger.warning(
|
||||
f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}"
|
||||
)
|
||||
continue
|
||||
|
||||
total_traffic = sum(
|
||||
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
|
||||
value if isinstance(value, int | float) else 0
|
||||
for value in traffic_data.get("traffic", {}).values()
|
||||
)
|
||||
|
||||
try:
|
||||
await conn.execute("UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id)
|
||||
await update_key_notified(session, tg_id, client_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления notified для пользователя {tg_id} (client_id: {client_id}): {e}")
|
||||
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {e}")
|
||||
continue
|
||||
|
||||
if total_traffic == 0:
|
||||
logger.info(f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||
logger.info(
|
||||
f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление."
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
|
||||
)
|
||||
keyboard = builder.as_markup()
|
||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": message,
|
||||
"keyboard": keyboard,
|
||||
"client_id": client_id,
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": message,
|
||||
"keyboard": keyboard,
|
||||
"client_id": client_id,
|
||||
}
|
||||
)
|
||||
|
||||
if messages:
|
||||
results = await send_messages_with_limit(
|
||||
bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
|
||||
bot,
|
||||
messages,
|
||||
session=session,
|
||||
source_file="special_notifications",
|
||||
messages_per_second=25,
|
||||
)
|
||||
sent_count = 0
|
||||
for msg, result in zip(messages, results, strict=False):
|
||||
tg_id = msg["tg_id"]
|
||||
if result:
|
||||
sent_count += 1
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
sent_count = sum(result for result in results if result)
|
||||
logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
|
||||
|
||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
||||
|
||||
+51
-52
@@ -1,10 +1,12 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
@@ -15,6 +17,7 @@ from config import (
|
||||
YOOMONEY_ENABLE,
|
||||
)
|
||||
from database import get_last_payments
|
||||
from database.models import User
|
||||
from handlers.buttons import (
|
||||
BALANCE_HISTORY,
|
||||
COUPON,
|
||||
@@ -26,78 +29,78 @@ from handlers.buttons import (
|
||||
YOOKASSA,
|
||||
YOOMONEY,
|
||||
)
|
||||
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||
from handlers.payments.cryprobot_pay import process_callback_pay_cryptobot
|
||||
from handlers.payments.robokassa_pay import process_callback_pay_robokassa
|
||||
from handlers.payments.stars_pay import process_callback_pay_stars
|
||||
from handlers.payments.yookassa_pay import process_callback_pay_yookassa
|
||||
from handlers.payments.yoomoney_pay import process_callback_pay_yoomoney
|
||||
from handlers.texts import BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay")
|
||||
async def handle_pay(callback_query: CallbackQuery):
|
||||
async def handle_pay(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: AsyncSession
|
||||
):
|
||||
|
||||
payment_handlers = []
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_yookassa)
|
||||
if YOOMONEY_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_yoomoney)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_cryptobot)
|
||||
if STARS_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_stars)
|
||||
if ROBOKASSA_ENABLE:
|
||||
payment_handlers.append(process_callback_pay_robokassa)
|
||||
|
||||
if len(payment_handlers) == 1:
|
||||
await callback_query.answer()
|
||||
return await payment_handlers[0](callback_query, state, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=YOOKASSA,
|
||||
callback_data="pay_yookassa",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=YOOKASSA, callback_data="pay_yookassa"))
|
||||
if YOOMONEY_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=YOOMONEY,
|
||||
callback_data="pay_yoomoney",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=YOOMONEY, callback_data="pay_yoomoney"))
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CRYPTOBOT,
|
||||
callback_data="pay_cryptobot",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=CRYPTOBOT, callback_data="pay_cryptobot"))
|
||||
if STARS_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=STARS,
|
||||
callback_data="pay_stars",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=STARS, callback_data="pay_stars"))
|
||||
if ROBOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=ROBOKASSA,
|
||||
callback_data="pay_robokassa",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa"))
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=PAYMENT_METHODS_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "balance")
|
||||
async def balance_handler(callback_query: CallbackQuery, session: Any):
|
||||
result = await session.fetchrow(
|
||||
"SELECT balance FROM users WHERE tg_id = $1",
|
||||
callback_query.from_user.id,
|
||||
)
|
||||
balance = result["balance"] if result else 0.0
|
||||
async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
|
||||
stmt = select(User.balance).where(User.tg_id == callback_query.from_user.id)
|
||||
result = await session.execute(stmt)
|
||||
balance = result.scalar_one_or_none() or 0.0
|
||||
balance = int(balance)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
@@ -119,21 +122,17 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
records = await get_last_payments(callback_query.from_user.id, session)
|
||||
records = await get_last_payments(session, callback_query.from_user.id)
|
||||
|
||||
if records:
|
||||
history_text = BALANCE_HISTORY_HEADER
|
||||
history_text = "<b>💳 История операций:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
amount = record["amount"]
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
history_text += (
|
||||
f"<b>Сумма:</b> {amount}₽\n"
|
||||
f"<b>Способ оплаты:</b> {payment_system}\n"
|
||||
f"<b>Статус:</b> {status}\n"
|
||||
f"<b>Дата:</b> {date}\n\n"
|
||||
)
|
||||
history_text += f"Сумма: {amount}₽\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
|
||||
history_text += "</blockquote>"
|
||||
else:
|
||||
history_text = "❌ У вас пока нет операций с балансом."
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,38 +1,37 @@
|
||||
import hashlib
|
||||
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 InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiohttp import web
|
||||
from robokassa import HashAlgorithm, Robokassa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
ROBOKASSA_ENABLE,
|
||||
ROBOKASSA_LOGIN,
|
||||
ROBOKASSA_PASSWORD1,
|
||||
ROBOKASSA_PASSWORD2,
|
||||
ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
from robokassa import HashAlgorithm, Robokassa
|
||||
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
add_payment,
|
||||
add_user,
|
||||
async_session_maker,
|
||||
check_user_exists,
|
||||
get_key_count,
|
||||
get_temporary_data,
|
||||
update_balance,
|
||||
)
|
||||
from handlers.buttons import BACK, PAY_2
|
||||
from handlers.payments.utils import send_payment_success_notification
|
||||
from handlers.texts import PAYMENT_OPTIONS, ENTER_SUM, DEFAULT_PAYMENT_MESSAGE
|
||||
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from handlers.buttons import BACK, PAY_2
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -55,7 +54,9 @@ if ROBOKASSA_ENABLE:
|
||||
|
||||
def generate_payment_link(amount, inv_id, description, tg_id):
|
||||
"""Генерация ссылки на оплату."""
|
||||
logger.debug(f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}")
|
||||
logger.debug(
|
||||
f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}"
|
||||
)
|
||||
payment_link = robokassa._payment.link.generate_by_script(
|
||||
out_sum=amount,
|
||||
inv_id=inv_id,
|
||||
@@ -67,7 +68,9 @@ def generate_payment_link(amount, inv_id, description, tg_id):
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_robokassa")
|
||||
async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
|
||||
async def process_callback_pay_robokassa(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated Robokassa payment.")
|
||||
|
||||
@@ -91,12 +94,12 @@ 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=BACK, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
key_count = await get_key_count(session, tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_user_exists(tg_id)
|
||||
exists = await check_user_exists(session, tg_id)
|
||||
if not exists:
|
||||
from_user = callback_query.from_user
|
||||
await add_user(
|
||||
@@ -110,20 +113,23 @@ async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, st
|
||||
)
|
||||
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
|
||||
|
||||
|
||||
await callback_query.message.delete()
|
||||
|
||||
|
||||
new_message = await callback_query.message.answer(
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
|
||||
await state.update_data(
|
||||
message_id=new_message.message_id, chat_id=new_message.chat.id
|
||||
)
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_robokassa)
|
||||
logger.info(f"Displayed amount selection for user {tg_id}.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("robokassa_amount|"))
|
||||
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
logger.info(f"Получены данные callback_data: {callback_query.data}")
|
||||
|
||||
data = callback_query.data.split("|")
|
||||
@@ -177,8 +183,7 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
|
||||
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
|
||||
|
||||
|
||||
async def robokassa_webhook(request):
|
||||
"""Обработка webhook-уведомлений от Robokassa с учетом shp_id."""
|
||||
async def robokassa_webhook(request: web.Request):
|
||||
try:
|
||||
params = await request.post()
|
||||
|
||||
@@ -189,7 +194,9 @@ async def robokassa_webhook(request):
|
||||
shp_id = params.get("shp_id")
|
||||
signature_value = params.get("SignatureValue")
|
||||
|
||||
logger.info(f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}")
|
||||
logger.info(
|
||||
f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}"
|
||||
)
|
||||
|
||||
if not check_payment_signature(params):
|
||||
logger.error("Неверная подпись или данные запроса.")
|
||||
@@ -203,10 +210,10 @@ async def robokassa_webhook(request):
|
||||
|
||||
logger.info(f"Processing payment for user {tg_id} with amount {amount}.")
|
||||
|
||||
await update_balance(int(tg_id), float(amount))
|
||||
await send_payment_success_notification(tg_id, float(amount))
|
||||
|
||||
await add_payment(int(tg_id), float(amount), "robokassa")
|
||||
async with async_session_maker() as session:
|
||||
await update_balance(session, int(tg_id), float(amount))
|
||||
await send_payment_success_notification(tg_id, float(amount), session)
|
||||
await add_payment(session, int(tg_id), float(amount), "robokassa")
|
||||
|
||||
logger.info(f"Payment successful. Balance updated for user {tg_id}.")
|
||||
|
||||
@@ -228,7 +235,9 @@ def check_payment_signature(params):
|
||||
|
||||
logger.info(f"Signature string before hashing: {signature_string}")
|
||||
|
||||
expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
|
||||
expected_signature = (
|
||||
hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
|
||||
)
|
||||
|
||||
logger.info(f"Expected signature: {expected_signature}")
|
||||
logger.info(f"Received signature: {signature_value}")
|
||||
@@ -237,7 +246,9 @@ def check_payment_signature(params):
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_robokassa")
|
||||
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
async def process_custom_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} chose to enter a custom amount.")
|
||||
|
||||
@@ -251,12 +262,16 @@ async def process_custom_amount_selection(callback_query: types.CallbackQuery, s
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_robokassa
|
||||
)
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
|
||||
async def handle_custom_amount_input(
|
||||
message: types.Message | types.CallbackQuery, state: FSMContext = None, session: Any = None
|
||||
message: types.Message | types.CallbackQuery,
|
||||
state: FSMContext = None,
|
||||
session: AsyncSession = None,
|
||||
):
|
||||
if isinstance(message, types.CallbackQuery):
|
||||
tg_id = message.message.chat.id
|
||||
@@ -269,9 +284,7 @@ async def handle_custom_amount_input(
|
||||
inv_id = 0
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
user_data = await get_temporary_data(conn, tg_id)
|
||||
await conn.close()
|
||||
user_data = await get_temporary_data(session, tg_id)
|
||||
|
||||
if not user_data:
|
||||
await edit_or_send_message(
|
||||
@@ -300,13 +313,9 @@ async def handle_custom_amount_input(
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
|
||||
|
||||
if state_type == "waiting_for_payment":
|
||||
message_text = (
|
||||
f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
message_text = f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
|
||||
elif state_type == "waiting_for_renewal_payment":
|
||||
message_text = (
|
||||
f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
message_text = f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
|
||||
else:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
@@ -315,7 +324,11 @@ async def handle_custom_amount_input(
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_send_message(target_message=target_message, text=message_text, reply_markup=builder.as_markup())
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
if isinstance(state, FSMContext):
|
||||
await state.clear()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+49
-57
@@ -1,19 +1,12 @@
|
||||
import html
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
|
||||
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
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
GIFT_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
NEWS_MESSAGE,
|
||||
@@ -32,13 +25,13 @@ from handlers.buttons import (
|
||||
MY_SUBS,
|
||||
TRIAL_SUB,
|
||||
)
|
||||
from handlers.texts import ADD_SUBSCRIPTION_HINT
|
||||
from logger import logger
|
||||
|
||||
from .admin.panel.keyboard import AdminPanelCallback
|
||||
from .texts import profile_message_send
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -48,6 +41,7 @@ async def process_callback_view_profile(
|
||||
callback_query_or_message: Message | CallbackQuery,
|
||||
state: FSMContext,
|
||||
admin: bool,
|
||||
session,
|
||||
):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
chat = callback_query_or_message.message.chat
|
||||
@@ -74,56 +68,54 @@ async def process_callback_view_profile(
|
||||
image_path = os.path.join("img", "profile.jpg")
|
||||
logger.info(f"Переход в профиль. Используется изображение: {image_path}")
|
||||
|
||||
key_count = await get_key_count(chat_id)
|
||||
balance = await get_balance(chat_id) or 0
|
||||
key_count = await get_key_count(session, chat_id)
|
||||
balance = await get_balance(session, chat_id) or 0
|
||||
trial_status = await get_trial(session, chat_id)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
trial_status = await get_trial(chat_id, conn)
|
||||
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
|
||||
if key_count == 0:
|
||||
profile_message += ADD_SUBSCRIPTION_HINT
|
||||
else:
|
||||
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
|
||||
|
||||
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
|
||||
if key_count == 0:
|
||||
profile_message += (
|
||||
"\n<blockquote>🔧 <i>Нажмите кнопку ➕ Добавить новую подписку, чтобы настроить VPN-подключение</i></blockquote>"
|
||||
)
|
||||
else:
|
||||
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
|
||||
builder = InlineKeyboardBuilder()
|
||||
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 = InlineKeyboardBuilder()
|
||||
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"))
|
||||
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)
|
||||
|
||||
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())
|
||||
)
|
||||
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,
|
||||
text=profile_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
disable_web_page_preview=False,
|
||||
force_text=True,
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions")
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
if admin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Администратор",
|
||||
callback_data=AdminPanelCallback(action="admin").pack(),
|
||||
)
|
||||
)
|
||||
if SHOW_START_MENU_ONCE:
|
||||
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=profile_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
disable_web_page_preview=False,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
+88
-86
@@ -1,11 +1,7 @@
|
||||
import os
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import qrcode
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -18,24 +14,20 @@ from aiogram.types import (
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
ADMIN_ID,
|
||||
DATABASE_URL,
|
||||
INLINE_MODE,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
TRIAL_TIME,
|
||||
USERNAME_BOT,
|
||||
)
|
||||
from database import add_referral, add_user, check_user_exists, get_referral_by_referred_id, get_referral_stats
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
INVITE,
|
||||
MAIN_MENU,
|
||||
QR,
|
||||
TOP_FIVE,
|
||||
from config import ADMIN_ID, INLINE_MODE, TOP_REFERRAL_BUTTON, TRIAL_TIME, USERNAME_BOT
|
||||
from database import (
|
||||
add_referral,
|
||||
add_user,
|
||||
check_user_exists,
|
||||
get_referral_by_referred_id,
|
||||
get_referral_stats,
|
||||
)
|
||||
from database.models import Referral
|
||||
from handlers.buttons import BACK, INVITE, MAIN_MENU, QR, TOP_FIVE
|
||||
from handlers.texts import (
|
||||
INVITE_TEXT_NON_INLINE,
|
||||
NEW_REFERRAL_NOTIFICATION,
|
||||
@@ -48,14 +40,14 @@ from logger import logger
|
||||
from .texts import get_referral_link, invite_message_send
|
||||
from .utils import edit_or_send_message, format_days
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "invite")
|
||||
@router.message(F.text == "/invite")
|
||||
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
|
||||
chat_id = None
|
||||
async def invite_handler(
|
||||
callback_query_or_message: Message | CallbackQuery, session: AsyncSession
|
||||
):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
chat_id = callback_query_or_message.message.chat.id
|
||||
target_message = callback_query_or_message.message
|
||||
@@ -64,7 +56,7 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery):
|
||||
target_message = callback_query_or_message
|
||||
|
||||
referral_link = get_referral_link(chat_id)
|
||||
referral_stats = await get_referral_stats(chat_id)
|
||||
referral_stats = await get_referral_stats(session, chat_id)
|
||||
invite_message = invite_message_send(referral_link, referral_stats)
|
||||
image_path = os.path.join("img", "pic_invite.jpg")
|
||||
|
||||
@@ -91,13 +83,17 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery):
|
||||
|
||||
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
|
||||
async def inline_referral_handler(inline_query: InlineQuery):
|
||||
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
|
||||
referral_link = (
|
||||
f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
|
||||
)
|
||||
trial_time_formatted = format_days(TRIAL_TIME)
|
||||
results: list[InlineQueryResultArticle] = []
|
||||
|
||||
for index, offer in enumerate(REFERRAL_OFFERS):
|
||||
description = offer["description"][:64]
|
||||
message_text = offer["message"].format(trial_time=TRIAL_TIME, trial_time_formatted=trial_time_formatted)[:4096]
|
||||
message_text = offer["message"].format(
|
||||
trial_time=TRIAL_TIME, trial_time_formatted=trial_time_formatted
|
||||
)[:4096]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
|
||||
@@ -107,7 +103,9 @@ async def inline_referral_handler(inline_query: InlineQuery):
|
||||
id=str(index),
|
||||
title=offer["title"],
|
||||
description=description,
|
||||
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text, parse_mode=ParseMode.HTML
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
)
|
||||
@@ -148,109 +146,111 @@ async def show_referral_qr(callback_query: CallbackQuery):
|
||||
os.remove(qr_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
|
||||
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
|
||||
async def top_referrals_handler(callback_query: CallbackQuery, session: AsyncSession):
|
||||
user_id = callback_query.from_user.id
|
||||
|
||||
result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Referral)
|
||||
.where(Referral.referrer_tg_id == user_id)
|
||||
)
|
||||
user_referral_count = result.scalar_one() or 0
|
||||
|
||||
personal_block = "Твоё место в рейтинге:\n"
|
||||
if user_referral_count > 0:
|
||||
subquery = (
|
||||
select(func.count().label("cnt"))
|
||||
.select_from(Referral)
|
||||
.group_by(Referral.referrer_tg_id)
|
||||
.having(func.count() > user_referral_count)
|
||||
.subquery()
|
||||
)
|
||||
result = await session.execute(select(func.count()).select_from(subquery))
|
||||
user_position = result.scalar_one() + 1
|
||||
personal_block += f"{user_position}. {user_id} - {user_referral_count} чел."
|
||||
else:
|
||||
personal_block += "Ты еще не приглашал пользователей в проект."
|
||||
|
||||
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
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(
|
||||
Referral.referrer_tg_id,
|
||||
func.count(Referral.referred_tg_id).label("referral_count"),
|
||||
)
|
||||
.group_by(Referral.referrer_tg_id)
|
||||
.order_by(desc("referral_count"))
|
||||
.limit(5)
|
||||
)
|
||||
top_referrals = result.all()
|
||||
|
||||
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"
|
||||
is_admin = 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)
|
||||
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"))
|
||||
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()
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def handle_referral_link(
|
||||
referral_code: str,
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
session: AsyncSession,
|
||||
user_data: dict | None = None,
|
||||
):
|
||||
try:
|
||||
referrer_tg_id = int(referral_code)
|
||||
|
||||
user = user_data or message.from_user or message.chat
|
||||
user_id = user["tg_id"] if isinstance(user, dict) else user.id
|
||||
|
||||
if referrer_tg_id == user_id:
|
||||
await message.answer("❌ Вы не можете быть реферальной ссылкой самого себя.")
|
||||
await message.answer(
|
||||
"❌ Вы не можете быть реферальной ссылкой самого себя."
|
||||
)
|
||||
return
|
||||
|
||||
existing_referral = await get_referral_by_referred_id(user_id, session)
|
||||
existing_referral = await get_referral_by_referred_id(session, user_id)
|
||||
if existing_referral:
|
||||
await message.answer("❌ Вы уже использовали реферальную ссылку.")
|
||||
return
|
||||
|
||||
user_exists = await check_user_exists(user_id)
|
||||
user_exists = await check_user_exists(session, user_id)
|
||||
if not user_exists:
|
||||
if isinstance(user, dict):
|
||||
await add_user(session=session, **user)
|
||||
else:
|
||||
await add_user(
|
||||
session=session,
|
||||
tg_id=user.id,
|
||||
username=getattr(user, "username", None),
|
||||
first_name=getattr(user, "first_name", None),
|
||||
last_name=getattr(user, "last_name", None),
|
||||
language_code=getattr(user, "language_code", None),
|
||||
is_bot=getattr(user, "is_bot", False),
|
||||
session=session,
|
||||
)
|
||||
|
||||
await add_referral(user_id, referrer_tg_id, session)
|
||||
await add_referral(session, user_id, referrer_tg_id)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
@@ -258,7 +258,9 @@ async def handle_referral_link(
|
||||
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
|
||||
logger.error(
|
||||
f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}"
|
||||
)
|
||||
|
||||
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
||||
|
||||
|
||||
+112
-80
@@ -1,34 +1,36 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
Message,
|
||||
)
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
CAPTCHA_ENABLE,
|
||||
CHANNEL_EXISTS,
|
||||
CHANNEL_ID,
|
||||
CHANNEL_REQUIRED,
|
||||
CHANNEL_URL,
|
||||
DONATIONS_ENABLE,
|
||||
SHOW_START_MENU_ONCE,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
get_trial,
|
||||
from database import add_user, check_user_exists, get_trial
|
||||
from database.models import TrackingSource, User
|
||||
from handlers.buttons import (
|
||||
ABOUT_VPN,
|
||||
BACK,
|
||||
CHANNEL,
|
||||
MAIN_MENU,
|
||||
SUB_CHANELL,
|
||||
SUB_CHANELL_DONE,
|
||||
SUPPORT,
|
||||
TRIAL_SUB,
|
||||
)
|
||||
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT, TRIAL_SUB, SUB_CHANELL_DONE, SUB_CHANELL
|
||||
from handlers.captcha import generate_captcha
|
||||
from handlers.coupons import activate_coupon
|
||||
from handlers.payments.gift import handle_gift_link
|
||||
@@ -47,23 +49,28 @@ from .admin.panel.keyboard import AdminPanelCallback
|
||||
from .refferal import handle_referral_link
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "start")
|
||||
async def handle_start_callback_query(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool, captcha: bool = False
|
||||
callback_query: CallbackQuery,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
admin: bool,
|
||||
captcha: bool = False,
|
||||
):
|
||||
await start_command(callback_query.message, state, session, admin, captcha)
|
||||
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True):
|
||||
async def start_command(
|
||||
message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True
|
||||
):
|
||||
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
|
||||
|
||||
if CAPTCHA_ENABLE and captcha:
|
||||
user_exists = await check_user_exists(message.chat.id)
|
||||
user_exists = await check_user_exists(session, message.chat.id)
|
||||
if not user_exists:
|
||||
captcha_data = await generate_captcha(message, state)
|
||||
await edit_or_send_message(
|
||||
@@ -79,18 +86,28 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
|
||||
|
||||
@router.callback_query(F.data == "check_subscription")
|
||||
async def check_subscription_callback(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool):
|
||||
async def check_subscription_callback(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool
|
||||
):
|
||||
user_id = callback_query.from_user.id
|
||||
logger.info(f"[CALLBACK] Получен callback 'check_subscription' от пользователя {user_id}")
|
||||
logger.info(
|
||||
f"[CALLBACK] Получен callback 'check_subscription' от пользователя {user_id}"
|
||||
)
|
||||
try:
|
||||
member = await bot.get_chat_member(CHANNEL_ID, user_id)
|
||||
logger.info(f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}")
|
||||
logger.info(
|
||||
f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}"
|
||||
)
|
||||
|
||||
if member.status not in ["member", "administrator", "creator"]:
|
||||
await callback_query.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
|
||||
builder.row(InlineKeyboardButton(text=SUB_CHANELL_DONE, callback_data="check_subscription"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=SUB_CHANELL_DONE, callback_data="check_subscription"
|
||||
)
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
SUBSCRIPTION_REQUIRED_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
@@ -108,13 +125,17 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
|
||||
text_to_process=original_text,
|
||||
user_data=user_data,
|
||||
)
|
||||
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
|
||||
logger.info(
|
||||
f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
await callback_query.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
|
||||
|
||||
|
||||
|
||||
async def process_start_logic(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
@@ -123,7 +144,6 @@ async def process_start_logic(
|
||||
text_to_process: str = None,
|
||||
user_data: dict | None = None,
|
||||
):
|
||||
|
||||
user_data = user_data or {
|
||||
"tg_id": (message.from_user or message.chat).id,
|
||||
"username": getattr(message.from_user, "username", None),
|
||||
@@ -135,35 +155,10 @@ async def process_start_logic(
|
||||
|
||||
text = text_to_process or message.text or message.caption
|
||||
|
||||
if CHANNEL_EXISTS and CHANNEL_REQUIRED:
|
||||
try:
|
||||
member = await bot.get_chat_member(CHANNEL_ID, user_data["tg_id"])
|
||||
if member.status not in ["member", "administrator", "creator"]:
|
||||
await state.update_data(original_text=text, user_data=user_data)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
|
||||
builder.row(InlineKeyboardButton(text=SUB_CHANELL_DONE, callback_data="check_subscription"))
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=SUBSCRIPTION_REQUIRED_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписки для {user_data['tg_id']}: {e}")
|
||||
await state.update_data(original_text=text, user_data=user_data)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
|
||||
builder.row(InlineKeyboardButton(text=SUB_CHANELL_DONE, callback_data="check_subscription"))
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=SUBSCRIPTION_REQUIRED_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not text:
|
||||
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}")
|
||||
logger.info(
|
||||
f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}"
|
||||
)
|
||||
await show_start_menu(message, admin, session)
|
||||
return
|
||||
|
||||
@@ -180,7 +175,14 @@ async def process_start_logic(
|
||||
if "coupons" in part:
|
||||
logger.info(f"Обнаружена ссылка на купон: {part}")
|
||||
coupon_code = part.split("coupons")[1].strip("_")
|
||||
await activate_coupon(message, state, session, coupon_code, admin=admin, user_data=user_data)
|
||||
await activate_coupon(
|
||||
message,
|
||||
state,
|
||||
session,
|
||||
coupon_code,
|
||||
admin=admin,
|
||||
user_data=user_data,
|
||||
)
|
||||
continue
|
||||
|
||||
if "gift" in part:
|
||||
@@ -193,7 +195,9 @@ async def process_start_logic(
|
||||
gift_id = parts[0]
|
||||
sender_id = parts[1]
|
||||
logger.info(f"[GIFT] Обнаружен подарок {gift_id} от {sender_id}")
|
||||
await handle_gift_link(gift_id, message, state, session, user_data=user_data)
|
||||
await handle_gift_link(
|
||||
gift_id, message, state, session, user_data=user_data
|
||||
)
|
||||
gift_detected = True
|
||||
break
|
||||
|
||||
@@ -201,7 +205,9 @@ async def process_start_logic(
|
||||
referrer_tg_id = part.split("referral")[1].strip("_")
|
||||
try:
|
||||
referrer_tg_id = int(referrer_tg_id)
|
||||
await handle_referral_link(referrer_tg_id, message, state, session, user_data=user_data)
|
||||
await handle_referral_link(
|
||||
referrer_tg_id, message, state, session, user_data=user_data
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
continue
|
||||
@@ -209,18 +215,20 @@ async def process_start_logic(
|
||||
if "utm" in part:
|
||||
utm_code = part
|
||||
logger.info(f"[UTM] Обнаружена ссылка на UTM: {utm_code}")
|
||||
await handle_utm_link(utm_code, message, state, session, user_data=user_data)
|
||||
await handle_utm_link(
|
||||
utm_code, message, state, session, user_data=user_data
|
||||
)
|
||||
continue
|
||||
|
||||
await state.clear()
|
||||
if gift_detected:
|
||||
return
|
||||
|
||||
user_exists = await check_user_exists(user_data["tg_id"])
|
||||
user_exists = await check_user_exists(session, user_data["tg_id"])
|
||||
if not user_exists:
|
||||
await add_user(session=session, **user_data)
|
||||
|
||||
trial_status = await get_trial(user_data["tg_id"], session)
|
||||
trial_status = await get_trial(session, user_data["tg_id"])
|
||||
|
||||
if SHOW_START_MENU_ONCE:
|
||||
if trial_status > 0:
|
||||
@@ -237,25 +245,39 @@ async def process_start_logic(
|
||||
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
|
||||
|
||||
async def handle_utm_link(utm_code, message, state, session, user_data: dict):
|
||||
async def handle_utm_link(
|
||||
utm_code: str,
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
user_data: dict,
|
||||
):
|
||||
user_id = user_data["tg_id"]
|
||||
exists = await session.fetchval("SELECT 1 FROM tracking_sources WHERE code = $1", utm_code)
|
||||
if exists:
|
||||
current_code = await session.fetchval("SELECT source_code FROM users WHERE tg_id = $1", user_id)
|
||||
if current_code is None:
|
||||
user_exists = await check_user_exists(user_id)
|
||||
if not user_exists:
|
||||
await add_user(session=session, source_code=utm_code, **user_data)
|
||||
else:
|
||||
await session.execute("UPDATE users SET source_code = $1 WHERE tg_id = $2", utm_code, user_id)
|
||||
logger.info(f"[UTM] Привязана {utm_code} к пользователю {user_id}")
|
||||
else:
|
||||
|
||||
result = await session.execute(
|
||||
select(TrackingSource).where(TrackingSource.code == utm_code)
|
||||
)
|
||||
utm_exists = result.scalar_one_or_none()
|
||||
|
||||
if not utm_exists:
|
||||
await message.answer("❌ UTM ссылка не найдена.")
|
||||
return
|
||||
result = await session.execute(select(User).where(User.tg_id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.source_code is None:
|
||||
user.source_code = utm_code
|
||||
await session.commit()
|
||||
logger.info(f"[UTM] Привязана {utm_code} к пользователю {user_id}")
|
||||
elif not user:
|
||||
await add_user(session=session, source_code=utm_code, **user_data)
|
||||
logger.info(
|
||||
f"[UTM] Зарегистрирован и привязан {utm_code} к пользователю {user_id}"
|
||||
)
|
||||
|
||||
|
||||
async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
"""Функция для отображения стандартного меню через редактирование сообщения.
|
||||
Если редактирование не удалось, отправляем новое сообщение."""
|
||||
async def show_start_menu(message: Message, admin: bool, session: AsyncSession):
|
||||
"""Функция для отображения стандартного меню через редактирование сообщения."""
|
||||
logger.info(f"Показываю главное меню для пользователя {message.chat.id}")
|
||||
|
||||
image_path = os.path.join("img", "pic.jpg")
|
||||
@@ -263,12 +285,16 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
|
||||
trial_status = None
|
||||
if session is not None:
|
||||
trial_status = await get_trial(message.chat.id, session)
|
||||
trial_status = await get_trial(session, message.chat.id)
|
||||
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
|
||||
if trial_status == 0:
|
||||
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key")
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
|
||||
logger.warning(
|
||||
f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}"
|
||||
)
|
||||
|
||||
if trial_status != 0 or not SHOW_START_MENU_ONCE:
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
@@ -283,7 +309,10 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
|
||||
if admin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
|
||||
InlineKeyboardButton(
|
||||
text="📊 Администратор",
|
||||
callback_data=AdminPanelCallback(action="admin").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
|
||||
@@ -297,15 +326,18 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
|
||||
|
||||
@router.callback_query(F.data == "about_vpn")
|
||||
async def handle_about_vpn(callback_query: CallbackQuery, session: Any):
|
||||
async def handle_about_vpn(callback_query: CallbackQuery, session: AsyncSession):
|
||||
user_id = callback_query.from_user.id
|
||||
trial = await get_trial(user_id, session)
|
||||
trial = await get_trial(session, user_id)
|
||||
|
||||
back_target = "profile" if SHOW_START_MENU_ONCE and trial > 0 else "start"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
|
||||
)
|
||||
|
||||
support_btn = InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)
|
||||
if CHANNEL_EXISTS:
|
||||
channel_btn = InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL)
|
||||
|
||||
+88
-65
@@ -1,20 +1,23 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
|
||||
from aiogram.types import (
|
||||
BufferedInputFile,
|
||||
InlineKeyboardMarkup,
|
||||
InputMediaPhoto,
|
||||
Message,
|
||||
)
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot import bot
|
||||
from config import ADMIN_ID, DATABASE_URL
|
||||
from database import get_all_keys, get_servers
|
||||
from config import ADMIN_ID
|
||||
from database import get_servers
|
||||
from database.models import Key, Server
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -22,14 +25,18 @@ def generate_random_email(length: int = 8) -> str:
|
||||
"""
|
||||
Генерирует случайный email с заданной длиной.
|
||||
"""
|
||||
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
|
||||
return (
|
||||
"".join(
|
||||
secrets.choice(string.ascii_lowercase + string.digits)
|
||||
for _ in range(length)
|
||||
)
|
||||
if length > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
|
||||
async def get_least_loaded_cluster() -> str:
|
||||
"""
|
||||
Возвращает кластер с наименьшей загрузкой, где есть хотя бы один сервер с доступным лимитом.
|
||||
"""
|
||||
servers = await get_servers()
|
||||
async def get_least_loaded_cluster(session: AsyncSession) -> str:
|
||||
servers = await get_servers(session)
|
||||
server_to_cluster = {}
|
||||
cluster_loads = {}
|
||||
|
||||
@@ -38,36 +45,40 @@ async def get_least_loaded_cluster() -> str:
|
||||
for server in cluster_servers:
|
||||
server_to_cluster[server["server_name"]] = cluster_name
|
||||
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
keys = await get_all_keys(conn)
|
||||
for key in keys:
|
||||
server_id = key["server_id"]
|
||||
cluster_id = server_to_cluster.get(server_id, server_id)
|
||||
if cluster_id in cluster_loads:
|
||||
cluster_loads[cluster_id] += 1
|
||||
result = await session.execute(select(Key))
|
||||
keys = result.scalars().all()
|
||||
|
||||
available_clusters = {}
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
for server in cluster_servers:
|
||||
if server.get("enabled", True) and await check_server_key_limit(server, conn):
|
||||
available_clusters[cluster_name] = cluster_loads[cluster_name]
|
||||
break
|
||||
for key in keys:
|
||||
server_id = key.server_id
|
||||
cluster_id = server_to_cluster.get(server_id, server_id)
|
||||
if cluster_id in cluster_loads:
|
||||
cluster_loads[cluster_id] += 1
|
||||
|
||||
available_clusters = {}
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
for server in cluster_servers:
|
||||
if server.get("enabled", True) and await check_server_key_limit(
|
||||
server, session
|
||||
):
|
||||
available_clusters[cluster_name] = cluster_loads[cluster_name]
|
||||
break
|
||||
|
||||
if not available_clusters:
|
||||
logger.warning("❌ Нет доступных кластеров с лимитом ключей!")
|
||||
return "cluster1"
|
||||
|
||||
least_loaded_cluster = min(available_clusters, key=lambda k: (available_clusters[k], k))
|
||||
logger.info(f"✅ Выбран наименее загруженный кластер с лимитом: {least_loaded_cluster}")
|
||||
|
||||
least_loaded_cluster = min(
|
||||
available_clusters, key=lambda k: (available_clusters[k], k)
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Выбран наименее загруженный кластер с лимитом: {least_loaded_cluster}"
|
||||
)
|
||||
return least_loaded_cluster
|
||||
|
||||
|
||||
async def check_server_key_limit(server_info: dict, conn) -> bool:
|
||||
"""
|
||||
Универсальная проверка лимита ключей для сервера в режимах кластеров и стран.
|
||||
"""
|
||||
async def check_server_key_limit(server_info: dict, session: AsyncSession) -> bool:
|
||||
from database.models import Key, Notification
|
||||
|
||||
server_name = server_info.get("server_name")
|
||||
cluster_name = server_info.get("cluster_name")
|
||||
max_keys = server_info.get("max_keys")
|
||||
@@ -76,19 +87,30 @@ async def check_server_key_limit(server_info: dict, conn) -> bool:
|
||||
return True
|
||||
|
||||
identifier = cluster_name if cluster_name else server_name
|
||||
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", identifier)
|
||||
|
||||
result = await session.execute(
|
||||
select(func.count()).select_from(Key).where(Key.server_id == identifier)
|
||||
)
|
||||
total_keys = result.scalar() or 0
|
||||
|
||||
if total_keys >= max_keys:
|
||||
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}")
|
||||
logger.warning(
|
||||
f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}"
|
||||
)
|
||||
return False
|
||||
|
||||
usage_percent = total_keys / max_keys
|
||||
|
||||
if usage_percent >= 0.9:
|
||||
notif_key = f"server_warn_{server_name}"
|
||||
already_sent = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = 0 AND notification_type = $1)", notif_key
|
||||
|
||||
result = await session.execute(
|
||||
select(Notification).where(
|
||||
Notification.tg_id == 0, Notification.notification_type == notif_key
|
||||
)
|
||||
)
|
||||
already_sent = result.scalar_one_or_none()
|
||||
|
||||
if not already_sent:
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
@@ -99,26 +121,29 @@ async def check_server_key_limit(server_info: dict, conn) -> bool:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await conn.execute(
|
||||
"INSERT INTO notifications (tg_id, notification_type) VALUES (0, $1) ON CONFLICT DO NOTHING",
|
||||
notif_key,
|
||||
)
|
||||
|
||||
session.add(Notification(tg_id=0, notification_type=notif_key))
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
|
||||
async def handle_error(
|
||||
tg_id: int, callback_query: object | None = None, message: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает ошибку, отправляя сообщение пользователю.
|
||||
"""
|
||||
try:
|
||||
if callback_query and hasattr(callback_query, "message"):
|
||||
try:
|
||||
await bot.delete_message(chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as delete_error:
|
||||
logger.warning(f"Не удалось удалить сообщение: {delete_error}")
|
||||
|
||||
await bot.send_message(tg_id, message)
|
||||
await bot.send_message(tg_id, message, parse_mode=None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке ошибки: {e}")
|
||||
@@ -179,7 +204,7 @@ def format_minutes(minutes: int) -> str:
|
||||
async def edit_or_send_message(
|
||||
target_message: Message,
|
||||
text: str,
|
||||
reply_markup: InlineKeyboardMarkup,
|
||||
reply_markup: InlineKeyboardMarkup | None = None,
|
||||
media_path: str = None,
|
||||
disable_web_page_preview: bool = False,
|
||||
force_text: bool = False,
|
||||
@@ -199,7 +224,9 @@ async def edit_or_send_message(
|
||||
return
|
||||
except Exception:
|
||||
await target_message.answer_photo(
|
||||
photo=BufferedInputFile(image_data, filename=os.path.basename(media_path)),
|
||||
photo=BufferedInputFile(
|
||||
image_data, filename=os.path.basename(media_path)
|
||||
),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
@@ -208,7 +235,9 @@ async def edit_or_send_message(
|
||||
else:
|
||||
if not force_text and target_message.caption is not None:
|
||||
try:
|
||||
await target_message.edit_caption(caption=text, reply_markup=reply_markup)
|
||||
await target_message.edit_caption(
|
||||
caption=text, reply_markup=reply_markup
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка редактирования подписи: {e}")
|
||||
@@ -239,26 +268,20 @@ def convert_to_bytes(value: float, unit: str) -> int:
|
||||
return int(value * units.get(unit.upper(), 1))
|
||||
|
||||
|
||||
async def is_full_remnawave_cluster(cluster_id: str, session) -> bool:
|
||||
"""
|
||||
Универсальная проверка:
|
||||
- Если cluster_id — это имя кластера, проверяет, что все его сервера используют Remnawave.
|
||||
- Если cluster_id — это имя одиночного сервера, проверяет, что он Remnawave.
|
||||
"""
|
||||
cluster_servers = await session.fetch(
|
||||
"SELECT panel_type FROM servers WHERE cluster_name = $1",
|
||||
cluster_id,
|
||||
async def is_full_remnawave_cluster(cluster_id: str, session: AsyncSession) -> bool:
|
||||
result = await session.execute(
|
||||
select(Server.panel_type).where(Server.cluster_name == cluster_id)
|
||||
)
|
||||
panel_types = result.scalars().all()
|
||||
|
||||
if cluster_servers:
|
||||
panel_types = [s["panel_type"].lower() for s in cluster_servers if s.get("panel_type")]
|
||||
return all(pt == "remnawave" for pt in panel_types)
|
||||
if panel_types:
|
||||
return all(pt.lower() == "remnawave" for pt in panel_types)
|
||||
|
||||
server = await session.fetchrow(
|
||||
"SELECT panel_type FROM servers WHERE server_name = $1",
|
||||
cluster_id,
|
||||
result = await session.execute(
|
||||
select(Server.panel_type).where(Server.server_name == cluster_id)
|
||||
)
|
||||
return server and server["panel_type"].lower() == "remnawave"
|
||||
panel_type = result.scalar_one_or_none()
|
||||
return panel_type and panel_type.lower() == "remnawave"
|
||||
|
||||
|
||||
def sanitize_key_name(key_name: str) -> str:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from config import LOG_ROTATION_TIME
|
||||
|
||||
log_folder = "logs"
|
||||
|
||||
@@ -47,7 +47,7 @@ logger.add(
|
||||
log_file_path,
|
||||
level="DEBUG",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} | {message}",
|
||||
rotation=timedelta(minutes=60),
|
||||
rotation=LOG_ROTATION_TIME,
|
||||
retention=timedelta(days=3),
|
||||
)
|
||||
|
||||
|
||||
+17
-6
@@ -3,37 +3,48 @@ from collections.abc import Iterable
|
||||
from aiogram import Dispatcher
|
||||
from aiogram.dispatcher.middlewares.base import BaseMiddleware
|
||||
|
||||
from logger import logger
|
||||
from middlewares.ban_checker import BanCheckerMiddleware
|
||||
from middlewares.subscription import SubscriptionMiddleware
|
||||
|
||||
from .admin import AdminMiddleware
|
||||
from .loggings import LoggingMiddleware
|
||||
from .maintenance import MaintenanceModeMiddleware
|
||||
from .session import SessionMiddleware
|
||||
from .throttling import ThrottlingMiddleware
|
||||
from .user import UserMiddleware
|
||||
from middlewares.ban_checker import BanCheckerMiddleware
|
||||
|
||||
|
||||
def register_middleware(
|
||||
dispatcher: Dispatcher,
|
||||
middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None,
|
||||
exclude: Iterable[str] | None = None,
|
||||
pool=None
|
||||
pool=None,
|
||||
sessionmaker=None,
|
||||
) -> None:
|
||||
"""Регистрирует middleware в диспетчере."""
|
||||
if middlewares is None:
|
||||
available_middlewares = {
|
||||
"admin": AdminMiddleware(),
|
||||
"session": SessionMiddleware(),
|
||||
"session": (
|
||||
SessionMiddleware(sessionmaker) if sessionmaker else SessionMiddleware()
|
||||
),
|
||||
"maintenance": MaintenanceModeMiddleware(),
|
||||
"logging": LoggingMiddleware(),
|
||||
"throttling": ThrottlingMiddleware(),
|
||||
"user": UserMiddleware(),
|
||||
}
|
||||
|
||||
if pool:
|
||||
available_middlewares["ban_checker"] = BanCheckerMiddleware(pool)
|
||||
if sessionmaker:
|
||||
dispatcher.update.outer_middleware(SubscriptionMiddleware())
|
||||
dispatcher.update.outer_middleware(BanCheckerMiddleware(sessionmaker))
|
||||
|
||||
exclude_set = set(exclude or [])
|
||||
middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set]
|
||||
middlewares = [
|
||||
middleware
|
||||
for name, middleware in available_middlewares.items()
|
||||
if name not in exclude_set
|
||||
]
|
||||
|
||||
handlers = [
|
||||
dispatcher.message,
|
||||
|
||||
+5
-19
@@ -14,7 +14,9 @@ class AdminMiddleware(BaseMiddleware):
|
||||
является ли пользователь администратором.
|
||||
"""
|
||||
|
||||
_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,
|
||||
@@ -22,28 +24,12 @@ class AdminMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
"""Обрабатывает событие и добавляет флаг администратора в data.
|
||||
|
||||
Args:
|
||||
handler: Обработчик события
|
||||
event: Событие Telegram
|
||||
data: Словарь с данными события
|
||||
|
||||
Returns:
|
||||
Результат выполнения обработчика
|
||||
"""
|
||||
"""Обрабатывает событие и добавляет флаг администратора в data."""
|
||||
data["admin"] = self._check_admin_access(event)
|
||||
return await handler(event, data)
|
||||
|
||||
def _check_admin_access(self, event: TelegramObject) -> bool:
|
||||
"""Проверяет, имеет ли пользователь права администратора.
|
||||
|
||||
Args:
|
||||
event: Событие Telegram
|
||||
|
||||
Returns:
|
||||
True, если пользователь администратор, иначе False
|
||||
"""
|
||||
"""Проверяет, имеет ли пользователь права администратора."""
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
return event.from_user and event.from_user.id in self._admin_ids
|
||||
|
||||
+60
-37
@@ -1,59 +1,82 @@
|
||||
from typing import Callable, Any, Awaitable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from asyncpg import Pool
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
|
||||
from pytz import timezone
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import SUPPORT_CHAT_URL
|
||||
from database.models import ManualBan
|
||||
from logger import logger
|
||||
|
||||
TZ = timezone("Europe/Moscow")
|
||||
|
||||
TZ = timezone("Europe/Moscow") # или другая зона, если нужно
|
||||
|
||||
class BanCheckerMiddleware(BaseMiddleware):
|
||||
def __init__(self, pool: Pool):
|
||||
self.pool = pool
|
||||
def __init__(self, session_factory: Callable[[], AsyncSession]) -> None:
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
|
||||
event: Message | CallbackQuery,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
tg_id = (
|
||||
event.from_user.id
|
||||
if isinstance(event, (Message, CallbackQuery))
|
||||
else None
|
||||
)
|
||||
tg_id = None
|
||||
obj = None
|
||||
|
||||
if isinstance(event, Update):
|
||||
if event.message:
|
||||
tg_id = event.message.from_user.id
|
||||
obj = event.message
|
||||
elif event.callback_query:
|
||||
tg_id = event.callback_query.from_user.id
|
||||
obj = event.callback_query
|
||||
elif isinstance(event, Message | CallbackQuery):
|
||||
tg_id = event.from_user.id
|
||||
obj = event
|
||||
|
||||
if tg_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
async with self.pool.acquire() as conn:
|
||||
record = await conn.fetchrow(
|
||||
"""
|
||||
SELECT until FROM manual_bans
|
||||
WHERE tg_id = $1 AND (until IS NULL OR until > NOW())
|
||||
""",
|
||||
tg_id,
|
||||
async with self.session_factory() as session:
|
||||
logger.debug(f"[BanChecker] Проверка блокировки для пользователя {tg_id}")
|
||||
result = await session.execute(
|
||||
select(ManualBan).where(
|
||||
ManualBan.tg_id == tg_id,
|
||||
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()),
|
||||
)
|
||||
)
|
||||
ban = result.scalar_one_or_none()
|
||||
|
||||
if record:
|
||||
until = record["until"]
|
||||
if until:
|
||||
until_local = until.astimezone(TZ).strftime("%Y-%m-%d %H:%M")
|
||||
text = (
|
||||
f"🚫 Вы заблокированы до <b>{until_local}</b> по МСК.\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
else:
|
||||
text = (
|
||||
f"🚫 Вы заблокированы <b>навсегда</b>.\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
if ban:
|
||||
reason = ban.reason or "не указана"
|
||||
until = ban.until
|
||||
|
||||
logger.warning(
|
||||
f"[BanChecker] Пользователь {tg_id} заблокирован (до: {until}, причина: {reason})"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(text, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(text, show_alert=True)
|
||||
return
|
||||
if until:
|
||||
until_local = until.astimezone(TZ).strftime("%Y-%m-%d %H:%M")
|
||||
text = (
|
||||
f"🚫 Вы заблокированы до <b>{until_local}</b> по МСК.\n"
|
||||
f"📄 Причина: <i>{reason}</i>\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
else:
|
||||
text = (
|
||||
f"🚫 Вы заблокированы <b>навсегда</b>.\n"
|
||||
f"📄 Причина: <i>{reason}</i>\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
|
||||
)
|
||||
|
||||
if isinstance(obj, Message):
|
||||
await obj.answer(text, parse_mode="HTML")
|
||||
elif isinstance(obj, CallbackQuery):
|
||||
await obj.answer(text, show_alert=True)
|
||||
return
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -3,7 +3,6 @@ from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from config import ADMIN_ID
|
||||
|
||||
|
||||
maintenance_mode = False
|
||||
|
||||
|
||||
@@ -17,7 +16,9 @@ class MaintenanceModeMiddleware(BaseMiddleware):
|
||||
user_id = event.from_user.id
|
||||
|
||||
if user_id and user_id not in ADMIN_ID:
|
||||
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
|
||||
await event.answer(
|
||||
"⚙️ Бот временно недоступен. Ведутся технические работы."
|
||||
)
|
||||
return
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
+11
-25
@@ -1,33 +1,19 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from config import DATABASE_URL
|
||||
|
||||
|
||||
class SessionMiddleware(BaseMiddleware):
|
||||
pool: asyncpg.Pool | None = None
|
||||
def __init__(self, sessionmaker=None) -> None:
|
||||
super().__init__()
|
||||
if sessionmaker is None:
|
||||
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=0)
|
||||
self.sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
else:
|
||||
self.sessionmaker = sessionmaker
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
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
|
||||
async def __call__(self, handler, event, data):
|
||||
async with self.sessionmaker() as session:
|
||||
data["session"] = session
|
||||
return await handler(event, data)
|
||||
|
||||
@classmethod
|
||||
async def close(cls) -> None:
|
||||
"""Закрыть пул соединений при завершении работы приложения."""
|
||||
if cls.pool is not None:
|
||||
await cls.pool.close()
|
||||
cls.pool = None
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import InlineKeyboardButton, Message, Update
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import CHANNEL_EXISTS, CHANNEL_ID, CHANNEL_REQUIRED, CHANNEL_URL
|
||||
from handlers.buttons import SUB_CHANELL, SUB_CHANELL_DONE
|
||||
from handlers.texts import SUBSCRIPTION_REQUIRED_MSG
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
|
||||
class SubscriptionMiddleware(BaseMiddleware):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
if not CHANNEL_EXISTS or not CHANNEL_REQUIRED:
|
||||
return await handler(event, data)
|
||||
|
||||
tg_id = None
|
||||
message = None
|
||||
|
||||
if event.message:
|
||||
tg_id = event.message.from_user.id
|
||||
message = event.message
|
||||
elif event.callback_query:
|
||||
tg_id = event.callback_query.from_user.id
|
||||
message = event.callback_query.message
|
||||
else:
|
||||
return await handler(event, data)
|
||||
|
||||
try:
|
||||
member = await bot.get_chat_member(CHANNEL_ID, tg_id)
|
||||
if member.status not in ("member", "administrator", "creator"):
|
||||
logger.info(f"[SubMiddleware] Пользователь {tg_id} не подписан")
|
||||
return await self._ask_to_subscribe(message)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[SubMiddleware] Ошибка при проверке подписки для {tg_id}: {e}"
|
||||
)
|
||||
return await self._ask_to_subscribe(message)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
async def _ask_to_subscribe(self, message: Message):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=SUB_CHANELL_DONE, callback_data="check_subscription"
|
||||
)
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=message,
|
||||
text=SUBSCRIPTION_REQUIRED_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
@@ -16,7 +16,10 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
current_count = self.cache.get(user_id, 0)
|
||||
|
||||
if current_count >= 3:
|
||||
if isinstance(event, CallbackQuery) and user_id not in self.throttle_notice_cache:
|
||||
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(
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user