ORM update and lots of improvements

This commit is contained in:
Vladless
2025-05-24 19:30:23 +03:00
parent d2d6ba1be9
commit d4d2ff1867
106 changed files with 7634 additions and 5279 deletions
+2 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
3.12
-297
View File
@@ -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$$;
+3 -3
View File
@@ -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}")
+59 -26
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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 *
+14
View File
@@ -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()
+121
View File
@@ -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()
+12
View File
@@ -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()
+39
View File
@@ -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
+25
View File
@@ -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]
+7
View File
@@ -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)
+187
View File
@@ -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},
)
+201
View File
@@ -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)
+136
View File
@@ -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 []
+48
View File
@@ -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 []
+276
View File
@@ -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()
]
+120
View File
@@ -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
+98
View File
@@ -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)))
+104
View File
@@ -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
+44
View File
@@ -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}")
+126
View File
@@ -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),
}
+209
View File
@@ -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()
-1
View File
@@ -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(
-1
View File
@@ -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(
+73 -37
View File
@@ -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())
+27 -8
View File
@@ -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()
+11 -7
View File
@@ -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)
+63 -46
View File
@@ -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(
"❌ Ошибка при удалении вручную забаненных пользователей."
)
+19 -5
View File
@@ -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()
+401 -194
View File
@@ -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(
"❌ Произошла ошибка при установке тарифной группы."
)
+75 -27
View File
@@ -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)
+64 -32
View File
@@ -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(),
)
+23 -5
View File
@@ -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:
+29 -7
View File
@@ -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)
+33 -16
View File
@@ -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()
+41 -12
View File
@@ -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()
)
+8 -5
View File
@@ -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())
+31 -25
View File
@@ -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)
+30 -7
View File
@@ -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())
+68 -63
View File
@@ -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()
+23 -9
View File
@@ -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()
+156 -70
View File
@@ -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()
+7 -3
View File
@@ -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)
+138 -133
View File
@@ -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}")
+91 -18
View File
@@ -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}")],
]
)
+211 -70
View File
@@ -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
):
+174 -90
View File
@@ -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
View File
@@ -12,12 +12,13 @@ CANCEL = "❌ Отмена"
ADD_SUB = "➕ Добавить новую подписку"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
BALANCE = "💵 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
INSTRUCTIONS = "📘 Инструкции"
TOP_FIVE = "🏆 Топ-5"
TRIAL_SUB = "🎁 Пробная подписка"
MY_SUB = "🔐 Моя подписка"
# Меню Оплат и баланса
+9 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+21
View File
@@ -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(),
)
-1
View File
@@ -4,7 +4,6 @@ from aiogram import Router
from .instructions import router as instructions_router
router = Router(name="instructions_main_router")
router.include_routers(
+26 -18
View File
@@ -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"))
+8 -2
View File
@@ -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,
)
+91 -58
View File
@@ -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
View File
@@ -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,
+1 -7
View File
@@ -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)
+66 -43
View File
@@ -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,
+282 -178
View File
@@ -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
+55 -29
View File
@@ -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(
+28 -20
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+132 -53
View File
@@ -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
View File
@@ -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))
+94 -51
View File
@@ -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)
-1
View File
@@ -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)
+283 -173
View File
@@ -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()
+23 -14
View File
@@ -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
]
+55 -36
View File
@@ -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
+85 -77
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,7 @@
__all__ = ("router",)
from aiogram import Router
from config import (
CRYPTO_BOT_ENABLE,
ROBOKASSA_ENABLE,
+52 -39
View File
@@ -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()
+49 -57
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+2 -2
View File
@@ -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),
)
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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
+66
View File
@@ -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(),
)
+4 -1
View File
@@ -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