Merge pull request #188 from Capybara-z/patch-1

Fix time declination
This commit is contained in:
Vladislav Lisitsyn
2025-04-16 20:09:17 +03:00
committed by GitHub
41 changed files with 2760 additions and 2210 deletions
+19
View File
@@ -44,6 +44,21 @@ CREATE TABLE IF NOT EXISTS keys
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'key' AND is_nullable = 'NO'
) THEN
ALTER TABLE keys ALTER COLUMN key DROP NOT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'remnawave_link'
) THEN
ALTER TABLE keys ADD COLUMN remnawave_link TEXT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'is_frozen'
@@ -115,6 +130,10 @@ CREATE TABLE IF NOT EXISTS servers
UNIQUE (cluster_name, server_name)
);
ALTER TABLE servers ADD COLUMN IF NOT EXISTS panel_type TEXT NOT NULL DEFAULT '3x-ui';
ALTER TABLE servers
ALTER COLUMN subscription_url DROP NOT NULL;
CREATE TABLE IF NOT EXISTS gifts
(
+1 -1
View File
@@ -19,7 +19,7 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
version = "4.1-Release"
version = "4.2-a140499"
register_middleware(dp)
+30 -25
View File
@@ -65,6 +65,10 @@ def install_dependencies():
console.print("[blue]🔧 Установка зависимостей...[/blue]")
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
try:
if not os.path.exists("venv"):
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
subprocess.run("python3 -m venv venv", shell=True, check=True)
subprocess.run("bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
@@ -221,34 +225,35 @@ def main():
sys.exit(1)
os.chdir(PROJECT_DIR)
print_logo()
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 9)])
if choice == "1":
os.system(f"sudo systemctl start {SERVICE_NAME}")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
os.system("sudo venv/bin/python main.py")
elif choice == "3":
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
os.system(f"sudo systemctl restart {SERVICE_NAME}")
elif choice == "4":
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
os.system(f"sudo systemctl stop {SERVICE_NAME}")
elif choice == "5":
os.system(f"sudo journalctl -u {SERVICE_NAME} -n 80 --no-pager")
elif choice == "6":
os.system(f"sudo systemctl status {SERVICE_NAME}")
elif choice == "7":
show_update_menu()
elif choice == "8":
console.print("[bold cyan] Выход из CLI. Удачного дня![/bold cyan]")
break
try:
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 9)])
if choice == "1":
os.system(f"sudo systemctl start {SERVICE_NAME}")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
os.system("sudo venv/bin/python main.py")
elif choice == "3":
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
os.system(f"sudo systemctl restart {SERVICE_NAME}")
elif choice == "4":
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
os.system(f"sudo systemctl stop {SERVICE_NAME}")
elif choice == "5":
os.system(f"sudo journalctl -u {SERVICE_NAME} -n 80 --no-pager")
elif choice == "6":
os.system(f"sudo systemctl status {SERVICE_NAME}")
elif choice == "7":
show_update_menu()
elif choice == "8":
console.print("[bold cyan] Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
if __name__ == "__main__":
+22 -18
View File
@@ -382,26 +382,26 @@ async def store_key(
key: str,
server_id: str,
session: Any,
remnawave_link: str = None,
):
"""
Сохраняет информацию о ключе в базу данных.
Args:
tg_id (int): Telegram ID пользователя
client_id (str): Уникальный идентификатор клиента
email (str): Электронная почта или имя устройства
expiry_time (int): Время истечения ключа в миллисекундах
key (str): Ключ доступа
server_id (str): Идентификатор сервера
Raises:
Exception: Если возникает ошибка при сохранении ключа в базу данных
Сохраняет информацию о ключе в базу данных, если ключ ещё не существует.
"""
try:
existing_key = await session.fetchrow(
"SELECT 1 FROM keys WHERE tg_id = $1 AND client_id = $2",
tg_id,
client_id,
)
if existing_key:
logger.info(f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}")
return
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id, remnawave_link)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
tg_id,
client_id,
@@ -410,10 +410,12 @@ async def store_key(
expiry_time,
key,
server_id,
remnawave_link,
)
logger.info(f"Ключ успешно сохранен для пользователя {tg_id} на сервере {server_id}")
logger.info(f"Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
except Exception as e:
logger.error(f"Ошибка при сохранении ключа для пользователя {tg_id}: {e}")
logger.error(f"Ошибка при сохранении ключа для tg_id={tg_id}, client_id={client_id}: {e}")
raise
@@ -1346,7 +1348,7 @@ async def get_servers(session: Any = None):
result = await conn.fetch(
"""
SELECT cluster_name, server_name, api_url, subscription_url, inbound_id
SELECT cluster_name, server_name, api_url, subscription_url, inbound_id, panel_type
FROM servers
"""
)
@@ -1361,6 +1363,7 @@ async def get_servers(session: Any = None):
"api_url": row["api_url"],
"subscription_url": row["subscription_url"],
"inbound_id": row["inbound_id"],
"panel_type": row["panel_type"],
})
return servers
@@ -1442,7 +1445,7 @@ async def store_gift_link(
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT k.server_id, k.key, k.email, k.is_frozen, k.expiry_time, k.client_id, k.created_at, c.tg_id, c.balance
SELECT k.server_id, k.key, k.remnawave_link, k.email, k.is_frozen, k.expiry_time, k.client_id, k.created_at, c.tg_id, c.balance
FROM keys k
JOIN connections c ON k.tg_id = c.tg_id
WHERE k.email = $1
@@ -1470,6 +1473,7 @@ async def get_key_details(email, session):
return {
"key": record["key"],
"remnawave_link": record["remnawave_link"],
"server_id": record["server_id"],
"created_at": record["created_at"],
"expiry_time": record["expiry_time"],
+107 -73
View File
@@ -10,12 +10,13 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from py3xui import AsyncApi
from datetime import datetime, timedelta
from backup import create_backup_and_send_to_admins
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, TOTAL_GB, USE_COUNTRY_SELECTION
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, TOTAL_GB, USE_COUNTRY_SELECTION, REMNAWAVE_PASSWORD, REMNAWAVE_LOGIN
from database import check_unique_server_name, get_servers, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster, renew_key_in_cluster
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster, renew_key_in_cluster, delete_key_from_cluster
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
@@ -26,7 +27,9 @@ from .keyboard import (
build_clusters_editor_kb,
build_manage_cluster_kb,
build_sync_cluster_kb,
build_panel_type_kb
)
from panels.remnawave import RemnawaveAPI
router = Router()
@@ -143,9 +146,11 @@ async def handle_server_name_input(message: Message, state: FSMContext, session:
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Ссылку можно найти в поисковой строке браузера, при входе в 3X-UI.\n\n"
"️ Формат API URL:\n"
"<code>https://your_domain:port/panel_path/</code>"
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
"<b>Формат для 3X-UI:</b>\n"
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
"️ <b>Формат для Remnawave:</b>\n"
"<code>https://your-domain.com/api</code>"
)
await message.answer(
@@ -158,58 +163,38 @@ async def handle_server_name_input(message: Message, state: FSMContext, session:
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Ссылку можно найти в панели 3X-UI, в информации о клиенте.\n\n"
"Формат Subscription URL:\n"
"<code>https://your_domain:port_sub/sub_path/</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
"Если вы используете Remnawave — введите <code>0</code>\n\n"
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
)
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
subscription_url = message.text.strip().rstrip("/")
raw = message.text.strip()
subscription_url = None if raw == "0" else raw.rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
text = (
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n"
)
await message.answer(
text=text,
text=f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
f"Для Remnawave это UUID Инбаунда, для 3x-ui — просто ID (например, <code>1</code>).",
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
@@ -218,39 +203,48 @@ async def handle_subscription_url_input(message: Message, state: FSMContext):
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: Message, state: FSMContext):
inbound_id = message.text.strip()
await state.update_data(inbound_id=inbound_id)
if not inbound_id.isdigit():
await message.answer(
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await message.answer(
text=(
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
"Поддержка режима выбора стран — <b>ограничена</b>."
),
reply_markup=build_panel_type_kb(),
)
@router.callback_query(AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])), IsAdminFilter())
async def handle_panel_type_selection(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
inbound_id = user_data.get("inbound_id")
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id, panel_type)
VALUES ($1, $2, $3, $4, $5, $6)
""",
cluster_name,
server_name,
api_url,
subscription_url,
inbound_id,
panel_type,
)
await conn.close()
await message.answer(
text=f"Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
await callback_query.message.edit_text(
text=f"Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
reply_markup=build_admin_back_kb("clusters"),
)
await state.clear()
@@ -281,30 +275,59 @@ async def handle_cluster_availability(
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
text = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
await callback_query.message.edit_text(
text=(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
)
await callback_query.message.edit_text(text=text)
total_online_users = 0
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
now = datetime.utcnow()
start_time = now - timedelta(minutes=5)
start_iso = start_time.strftime("%Y-%m-%dT%H:%M:%SZ")
end_iso = now.strftime("%Y-%m-%dT%H:%M:%SZ")
for server in cluster_servers:
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
server_name = server["server_name"]
panel_type = server.get("panel_type", "3x-ui").lower()
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
try:
await xui.login()
online_clients = await xui.client.online()
inbound_id = int(server["inbound_id"])
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{server['server_name']}</b> - {online_inbound_users} онлайн\n"
if panel_type == "3x-ui":
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
await xui.login()
inbound_id = int(server["inbound_id"])
online_clients = await xui.client.online()
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
elif panel_type == "remnawave":
remna = RemnawaveAPI(server["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise Exception("Не удалось авторизоваться")
node_uuid = server.get("inbound_id")
if not node_uuid:
raise Exception("Не указан UUID ноды (inbound_id)")
data = await remna.get_node_users_usage(node_uuid, start=start_iso, end=end_iso)
online_remna_users = len(data) if data else 0
total_online_users += online_remna_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
except Exception as e:
result_text += f"❌ <b>{server['server_name']}</b> - ошибка: {str(e) if str(e).strip() else 'Сервер недоступен'}\n"
error_text = str(e) or "Сервер недоступен"
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
@@ -414,10 +437,10 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
try:
query_keys = """
SELECT tg_id, client_id, email, expiry_time
FROM keys
WHERE server_id = $1
"""
SELECT tg_id, client_id, email, expiry_time
FROM keys
WHERE server_id = $1
"""
keys_to_sync = await session.fetch(query_keys, cluster_name)
if not keys_to_sync:
@@ -427,33 +450,44 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
)
return
text = f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
await callback_query.message.edit_text(
text=text,
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
)
for key in keys_to_sync:
try:
await create_key_on_cluster(
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"])
await session.execute(
"DELETE FROM keys WHERE tg_id = $1 AND client_id = $2",
key["tg_id"],
key["client_id"]
)
result = await create_key_on_cluster(
cluster_name,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
session=session,
)
await asyncio.sleep(0.6)
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}")
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters")
text=f"❌ Произошла ошибка при синхронизации: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
+14
View File
@@ -130,3 +130,17 @@ def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKey
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_panel_type_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🌐 3X-UI",
callback_data=AdminClusterCallback(action="panel_3xui").pack()
)
builder.button(
text="🌀 Remnawave",
callback_data=AdminClusterCallback(action="panel_remnawave").pack()
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
+1 -167
View File
@@ -18,19 +18,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
from database import (
add_connection,
check_connection_exists,
create_coupon,
create_coupon_usage,
delete_coupon,
get_all_coupons,
get_keys,
update_key_expiry,
)
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.utils import format_days
from logger import logger
@@ -45,7 +38,6 @@ class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
waiting_for_key_selection = State()
@router.callback_query(
@@ -328,162 +320,4 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
results=[result],
cache_time=86400,
is_personal=True
)
@router.message(F.text.regexp(r"^/start coupons_(.+)$"))
async def handle_coupon_activation(
message: Message, state: FSMContext, session: Any, admin: bool = False, text: str = None, user_id: int = None
):
coupon_text = text if text is not None else message.text
logger.info(f"Текст купона в handle_coupon_activation: {coupon_text}")
coupon_code = coupon_text.split("coupons_")[1]
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
if not coupon:
await message.answer("❌ Купон не найден.")
return
if coupon["usage_count"] >= coupon["usage_limit"] or coupon["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
return
effective_user_id = user_id if user_id is not None else message.from_user.id
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon["id"],
effective_user_id
)
if usage:
await message.answer("❌ Вы уже активировали этот купон.")
return
connection_exists = await check_connection_exists(effective_user_id)
if not connection_exists:
await add_connection(tg_id=effective_user_id, session=session)
if coupon["amount"] > 0:
await session.execute(
"UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
coupon["amount"],
effective_user_id
)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], effective_user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon['amount']} рублей.")
await process_callback_view_profile(message, state, admin)
return
if coupon["days"] is not None and coupon["days"] > 0:
keys = await get_keys(effective_user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(AdminCouponsState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon["id"], user_id=effective_user_id)
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon_id,
callback_query.from_user.id
)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2",
callback_query.from_user.id,
client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
try:
await renew_key_in_cluster(
cluster_id=key["server_id"],
email=key["email"],
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M")
text = f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
await callback_query.message.answer(text)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Произошла ошибка при продлении подписки.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.answer("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
)
+107 -37
View File
@@ -13,14 +13,13 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import PUBLIC_LINK, RENEWAL_PRICES, TOTAL_GB
from config import RENEWAL_PRICES, TOTAL_GB, USE_COUNTRY_SELECTION
from database import (
delete_key,
delete_user_data,
get_balance,
get_client_id_by_email,
get_servers,
store_key,
update_balance,
update_key_expiry,
update_trial,
@@ -48,7 +47,6 @@ from .keyboard import (
build_key_edit_kb,
build_user_delete_kb,
build_user_edit_kb,
build_user_key_kb,
build_users_balance_change_kb,
build_users_balance_kb,
build_users_key_expiry_kb,
@@ -71,6 +69,7 @@ class UserEditorState(StatesGroup):
waiting_for_message_text = State()
selecting_cluster = State()
selecting_duration = State()
selecting_country = State()
@router.callback_query(
@@ -246,8 +245,15 @@ async def handle_balance_add(
tg_id = callback_data.tg_id
amount = callback_data.data
if amount:
await update_balance(tg_id, int(amount), session, is_admin=True)
if amount is not None:
amount = int(amount)
if amount >= 0:
await update_balance(tg_id, amount, session, is_admin=True)
else:
current_balance = await get_balance(tg_id)
new_balance = max(0, current_balance + amount)
await set_user_balance(tg_id, new_balance, session)
await handle_balance_change(callback_query, callback_data, session)
return
@@ -304,8 +310,11 @@ async def handle_balance_input(message: Message, state: FSMContext, session: Any
text = f"✅ К балансу пользователя добавлено <b>{amount}Р</b>"
await update_balance(tg_id, amount, session)
elif op_type == "take":
text = f"✅ Из баланса пользователя было вычтено <b>{amount}Р</b>"
await update_balance(tg_id, -amount, session)
current_balance = await get_balance(tg_id)
new_balance = max(0, current_balance - amount)
deducted = current_balance if amount > current_balance else amount
text = f"✅ Из баланса пользователя было вычтено <b>{deducted}Р</b>"
await set_user_balance(tg_id, new_balance, session)
else:
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
await set_user_balance(tg_id, amount, session)
@@ -330,9 +339,11 @@ async def handle_key_edit(
)
return
key_value = key_details.get("key") or key_details.get("remnawave_link") or ""
text = (
f"<b>🔑 Информация о ключе</b>"
f"\n\n<code>{key_details['key']}</code>"
f"\n\n<code>{key_value}</code>"
f"\n\n⏰ Дата истечения: <b>{key_details['expiry_date']} (UTC)</b>"
f"\n🌐 Кластер: <b>{key_details['cluster_name']}</b>"
f"\n🆔 ID клиента: <b>{key_details['tg_id']}</b>"
@@ -645,7 +656,7 @@ async def process_user_search(
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT k.client_id, k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
SELECT k.client_id, k.key, k.remnawave_link, k.expiry_time, k.server_id, c.tg_id, c.balance
FROM keys k
JOIN connections c ON k.tg_id = c.tg_id
WHERE k.email = $1
@@ -664,6 +675,7 @@ async def get_key_details(email, session):
"balance": record["balance"],
"tg_id": record["tg_id"],
"key": record["key"],
"remnawave_link": record["remnawave_link"],
"cluster_name": cluster_name,
"expiry_time": record["expiry_time"],
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
@@ -814,11 +826,35 @@ async def handle_users_export_referrals(
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
async def handle_create_key_select_cluster(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
async def handle_create_key_start(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id)
if USE_COUNTRY_SELECTION:
await state.set_state(UserEditorState.selecting_country)
rows = await session.fetch("SELECT DISTINCT server_name FROM servers ORDER BY server_name")
countries = [row["server_name"] for row in rows]
if not countries:
await callback_query.message.edit_text(
"❌ Нет доступных стран для создания ключа.",
reply_markup=build_editor_kb(tg_id)
)
return
builder = InlineKeyboardBuilder()
for country in countries:
builder.button(text=country, callback_data=country)
builder.adjust(1)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
"🌍 <b>Выберите страну для создания ключа:</b>",
reply_markup=builder.as_markup()
)
return
await state.set_state(UserEditorState.selecting_cluster)
servers = await get_servers(session)
@@ -826,20 +862,37 @@ async def handle_create_key_select_cluster(
if not cluster_names:
await callback_query.message.edit_text(
"❌ Нет доступных кластеров для создания ключа.", reply_markup=build_editor_kb(tg_id)
"❌ Нет доступных кластеров для создания ключа.",
reply_markup=build_editor_kb(tg_id)
)
return
builder = InlineKeyboardBuilder()
for cluster in cluster_names:
builder.button(
text=f"🌐 {cluster}",
callback_data=cluster
)
builder.button(text=f"🌐 {cluster}", callback_data=cluster)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
"🌐 <b>Выберите кластер для создания ключа:</b>", reply_markup=builder.as_markup()
"🌐 <b>Выберите кластер для создания ключа:</b>",
reply_markup=builder.as_markup()
)
@router.callback_query(UserEditorState.selecting_country, IsAdminFilter())
async def handle_create_key_country(callback_query: CallbackQuery, state: FSMContext):
country = callback_query.data
await state.update_data(country=country)
await state.set_state(UserEditorState.selecting_duration)
builder = InlineKeyboardBuilder()
for months, _ in RENEWAL_PRICES.items():
builder.button(text=f"{months} мес.", callback_data=str(months))
builder.adjust(1)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text=f"🕒 <b>Выберите срок действия ключа для страны {country}:</b>",
reply_markup=builder.as_markup()
)
@@ -866,40 +919,57 @@ async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMCo
try:
months = int(callback_query.data)
data = await state.get_data()
tg_id = data["tg_id"]
cluster_name = data["cluster_name"]
client_id = str(uuid.uuid4())
email = generate_random_email()
expiry = datetime.now(tz=timezone.utc) + timedelta(days=30 * months)
expiry_ms = int(expiry.timestamp() * 1000)
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms)
if USE_COUNTRY_SELECTION and "country" in data:
country = data["country"]
await create_key_on_cluster(
country,
tg_id,
client_id,
email,
expiry_ms,
plan=months,
session=session
)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
await state.clear()
await callback_query.message.edit_text(
f"✅ Ключ успешно создан для страны <b>{country}</b> на {months} мес.",
reply_markup=build_editor_kb(tg_id),
)
await store_key(
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_time=expiry_ms,
key=public_link,
server_id=cluster_name,
session=session,
)
elif "cluster_name" in data:
cluster_name = data["cluster_name"]
await create_key_on_cluster(
cluster_name,
tg_id,
client_id,
email,
expiry_ms,
plan=months,
session=session
)
await state.clear()
await state.clear()
await callback_query.message.edit_text(
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.",
reply_markup=build_editor_kb(tg_id),
)
await callback_query.message.edit_text(
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.!",
reply_markup=build_editor_kb(tg_id),
)
else:
await callback_query.message.edit_text("❌ Не удалось определить источник — страна или кластер.")
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
await callback_query.message.edit_text(
"❌ Не удалось создать ключ. Попробуйте позже.", reply_markup=build_editor_kb(data.get("tg_id", 0))
"❌ Не удалось создать ключ. Попробуйте позже.",
reply_markup=build_editor_kb(data.get("tg_id", 0))
)
-6
View File
@@ -15,9 +15,7 @@ MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
INVITE = "👥 Пригласить друга"
TOP_FIVE = "🏆 Топ-5"
TRIAL_SUB = "🎁 Пробная подписка"
@@ -40,8 +38,6 @@ GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
GIFTS_MENU = "В меню подарков"
SHARE_GIFT = "🎁 Поделиться подарком"
APPLY = "✅ Подтвердить"
CANCEL = "❌ Отмена"
# Создание и подключение ключей
@@ -83,6 +79,4 @@ TV_INSTRUCTIONS = "📖 Полная инструкция"
PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
PAY = "Пополнить"
PAY_2 = "Оплатить"
STARS_BOT = "🤖 Бот для покупки звезд"
+155 -23
View File
@@ -5,27 +5,37 @@ 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
import pytz
import html
from datetime import datetime
from config import ADMIN_ID
from database import (
check_coupon_usage,
create_coupon_usage,
get_coupon_by_code,
get_keys,
update_balance,
update_coupon_usage_count,
update_key_expiry,
check_connection_exists,
add_connection,
)
from handlers.buttons import MAIN_MENU, COUPON_RESTART
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.texts import (
COUPON_ACTIVATED_SUCCESS_MSG,
COUPON_ALREADY_USED_MSG,
COUPON_INPUT_PROMPT,
COUPON_NOT_FOUND_MSG,
)
from .utils import edit_or_send_message
from handlers.utils import edit_or_send_message, format_days
from handlers.profile import process_callback_view_profile
from logger import logger
class CouponActivationState(StatesGroup):
waiting_for_coupon_code = State()
waiting_for_key_selection = State()
router = Router()
@@ -54,32 +64,154 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
@router.message(CouponActivationState.waiting_for_coupon_code)
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
coupon_code = message.text.strip()
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(InlineKeyboardButton(text=COUPON_RESTART, callback_data="activate_coupon"))
await message.answer(activation_result, reply_markup=builder.as_markup())
await state.clear()
await activate_coupon(message, state, session, coupon_code=coupon_code)
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
async def activate_coupon(
message: Message, state: FSMContext, session: Any, coupon_code: str, admin: bool = False
):
logger.info(f"Активация купона: {coupon_code}")
coupon_record = await get_coupon_by_code(coupon_code, session)
if not coupon_record:
return COUPON_NOT_FOUND_MSG
await message.answer(COUPON_NOT_FOUND_MSG)
await state.clear()
return
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
if coupon_record["usage_count"] >= coupon_record["usage_limit"] or coupon_record["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
await state.clear()
return
if usage_exists:
return COUPON_ALREADY_USED_MSG
user_id = message.chat.id
coupon_amount = coupon_record["amount"]
usage = await check_coupon_usage(coupon_record["id"], user_id, session)
if usage:
await message.answer(COUPON_ALREADY_USED_MSG)
await state.clear()
return
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
connection_exists = await check_connection_exists(user_id)
if not connection_exists:
await add_connection(tg_id=user_id, session=session)
await update_balance(user_id, coupon_amount, session)
return COUPON_ACTIVATED_SUCCESS_MSG.format(coupon_amount=coupon_amount)
if coupon_record["amount"] > 0:
try:
await update_balance(user_id, coupon_record["amount"], session, skip_referral=True)
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon_record['amount']} рублей.")
is_admin = message.from_user.id in ADMIN_ID
await process_callback_view_profile(message, state, admin=is_admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
if coupon_record["days"] is not None and coupon_record["days"] > 0:
try:
keys = await get_keys(user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
await state.clear()
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon_record['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(CouponActivationState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon_record["id"], user_id=user_id)
except Exception as e:
logger.error(f"Ошибка при обработке купона на дни: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
await state.clear()
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
try:
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await check_coupon_usage(coupon_id, callback_query.from_user.id, session)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2",
callback_query.from_user.id,
client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
await renew_key_in_cluster(
cluster_id=key["server_id"],
email=key["email"],
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await update_coupon_usage_count(coupon["id"], session)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M")
await callback_query.message.answer(
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Ошибка при активации купона.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
+10 -3
View File
@@ -2,13 +2,20 @@ __all__ = ("router",)
from aiogram import Router
from .key_management import router as management_router
from .keys import router as keys_router
from .key_view import router as view_router
from .key_renew import router as renew_router
from .key_freeze import router as freeze_router
from .key_connect import router as connect_router
from .key_mode import router as key_mode_router
router = Router(name="keys_main_router")
router.include_routers(
keys_router,
management_router,
view_router,
renew_router,
freeze_router,
connect_router,
key_mode_router
)
+237
View File
@@ -0,0 +1,237 @@
import os
from io import BytesIO
from typing import Any
import asyncpg
import qrcode
from aiogram import F, Router, types
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
DATABASE_URL,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
INSTRUCTIONS_BUTTON
)
from handlers.buttons import (
ANDROID,
BACK,
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
IPHONE,
MAIN_MENU,
MANUAL_INSTRUCTIONS,
PC,
TV,
)
from handlers.texts import (
ANDROID_DESCRIPTION_TEMPLATE,
CHOOSE_DEVICE_TEXT,
IOS_DESCRIPTION_TEMPLATE,
SUBSCRIPTION_DESCRIPTION,
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
try:
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
logger.error(f"Ошибка в handle_connect_device: {e}")
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow(
"""
SELECT key FROM keys WHERE email = $1
""",
email,
)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
)
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
try:
key_name = callback_query.data.split("|")[1]
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(record["key"])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record['email']}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
+219
View File
@@ -0,0 +1,219 @@
import time
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
TOTAL_GB,
)
from database import (
get_key_details,
)
from handlers.buttons import (
APPLY,
BACK,
CANCEL,
)
from handlers.keys.key_utils import (
renew_key_in_cluster,
toggle_client_on_cluster,
)
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
SUBSCRIPTION_UNFROZEN_MSG,
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
)
from handlers.utils import edit_or_send_message, handle_error
router = Router()
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"unfreeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Размораживает (включает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = FALSE
WHERE tg_id = $2
AND client_id = $3
""",
new_expiry_time,
record["tg_id"],
client_id,
)
await renew_key_in_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=TOTAL_GB,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
key_name = callback_query.data.split("|")[1]
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"freeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Замораживает (отключает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
time_left = record["expiry_time"] - now_ms
if time_left < 0:
time_left = 0
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = TRUE
WHERE tg_id = $2
AND client_id = $3
""",
time_left,
record["tg_id"],
client_id,
)
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
-669
View File
@@ -1,669 +0,0 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, FSInputFile
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from bot import bot
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
NOTIFY_EXTRA_DAYS,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_connection,
check_connection_exists,
create_temporary_data,
get_balance,
get_key_details,
get_trial,
store_key,
update_balance,
update_trial,
check_server_name_by_cluster,
)
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PAYMENT,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_COUNTRY_MSG,
SELECT_TARIFF_PLAN_MSG,
key_message_success,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
from panels.three_xui import delete_client
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
waiting_for_key_name = "waiting_for_key_name"
viewing_profile = "viewing_profile"
waiting_for_message = "waiting_for_message"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if DISCOUNTS and plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
async def create_key(
tg_id: int,
expiry_time: datetime,
state: FSMContext | None,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
"""Создаёт ключ с заданным сроком действия."""
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
if USE_COUNTRY_SELECTION:
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
least_loaded_cluster = await get_least_loaded_cluster()
logger.info(f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов")
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
if not servers:
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
logger.info(f"[Country Selection] Список доступных серверов: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if target_message:
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await bot.send_message(
chat_id=tg_id,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
return
while True:
key_name = generate_random_email()
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
least_loaded_cluster = await get_least_loaded_cluster()
tasks = [
asyncio.create_task(
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
)
]
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
least_loaded_cluster,
session,
)
data = {}
if state:
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.replace(tzinfo=None).astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
if target_message:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
current_server,
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
"""Финализирует создание ключа с выбором стран."""
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
public_link = old_key_details["key"]
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
if old_key_name:
old_server_id = old_key_details.get("server_id")
if old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
xui = AsyncApi(
old_server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
deletion_success = await delete_client(
xui,
old_server_info["inbound_id"],
email,
client_id,
)
if not deletion_success:
logger.warning(f"Не удалось удалить клиента с сервера {old_server_id}, но продолжаем процесс.")
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
if old_key_name:
await session.execute(
"""
UPDATE keys
SET server_id = $1
WHERE tg_id = $2 AND email = $3
""",
selected_country,
tg_id,
old_key_name,
)
else:
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
selected_country,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
if state:
await state.clear()
async def check_server_availability(server_info: dict) -> bool:
"""
Проверяет доступность сервера через API.
Args:
server_info (dict): Информация о сервере (api_url, username, password).
Returns:
bool: True, если сервер доступен, False в противном случае.
"""
try:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"Сервер {server_info.get('server_name', 'unknown')} доступен.")
return True
except asyncio.TimeoutError:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} недоступен: {e}")
return False
+15
View File
@@ -0,0 +1,15 @@
__all__ = ("router",)
from aiogram import Router
from .key_create import router as create_router
from .key_cluster_mode import router as cluster_router
from .key_country_mode import router as country_router
router = Router(name="key_mode_router")
router.include_routers(
create_router,
cluster_router,
country_router,
)
+153
View File
@@ -0,0 +1,153 @@
import uuid
from datetime import datetime
import pytz
from aiogram import Router
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, FSInputFile, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
CONNECT_PHONE_BUTTON,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
)
from database import (
get_key_details,
get_trial,
update_balance,
update_trial,
)
from handlers.buttons import (
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PC_BUTTON,
TV_BUTTON,
SUPPORT
)
from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import (
key_message_success,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster, is_full_remnawave_cluster
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_cluster_mode(
tg_id: int,
expiry_time: datetime,
state,
session,
message_or_query: Message | CallbackQuery | None = None,
plan: int = None,
):
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
try:
least_loaded_cluster = await get_least_loaded_cluster()
await create_key_on_cluster(
least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan, session
)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
key_record = await get_key_details(email, session)
if not key_record:
raise ValueError(f"Ключ не найден после создания: {email}")
public_link = key_record.get("key")
remnawave_link = key_record.get("remnawave_link")
final_link = public_link or remnawave_link or ""
data = await state.get_data() if state else {}
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if await is_full_remnawave_cluster(least_loaded_cluster, session):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=final_link),
)
)
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(final_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
if target_message:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
+437
View File
@@ -0,0 +1,437 @@
import asyncio
import uuid
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD
)
from database import (
add_connection,
check_connection_exists,
get_key_details,
get_trial,
update_balance,
update_trial,
check_server_name_by_cluster,
)
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
SUPPORT
)
from handlers.keys.key_utils import create_client_on_server
from handlers.texts import (
SELECT_COUNTRY_MSG,
key_message_success,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
from panels.three_xui import delete_client
from panels.remnawave import RemnawaveAPI
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_country_mode(
tg_id: int,
expiry_time: datetime,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
):
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
least_loaded_cluster = await get_least_loaded_cluster()
servers = await session.fetch(
"SELECT server_name, api_url, panel_type FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
if not servers:
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
return
available_servers = []
tasks = [asyncio.create_task(check_server_availability(server)) for server in servers]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
return
logger.info(f"[Country Selection] Доступные серверы для выбора: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = await session.fetch(
"SELECT server_name, api_url, panel_type FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
current_server,
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
"panel_type": server["panel_type"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
panel_type = server_info["panel_type"].lower()
public_link = None
remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
if old_key_name and panel_type == "3x-ui":
old_server_id = old_key_details.get("server_id")
if old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
xui = AsyncApi(
old_server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await delete_client(
xui,
old_server_info["inbound_id"],
email,
client_id,
)
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": [server_info["inbound_id"]],
}
result = await remna.create_user(user_data)
if not result:
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
client_id = result.get("uuid")
remnawave_link = result.get("subscriptionUrl")
logger.info(f"[Key Creation] Remnawave пользователь создан: {result}")
if panel_type == "3x-ui":
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
if old_key_name:
await session.execute(
"UPDATE keys SET server_id = $1 WHERE tg_id = $2 AND email = $3",
selected_country,
tg_id,
old_key_name,
)
else:
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, remnawave_link, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
remnawave_link,
selected_country,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
except Exception as e:
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
key_message_text = key_message_success(link_to_show, f"⏳ Осталось дней: {days} 📅")
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path="img/pic.jpg",
)
if state:
await state.clear()
async def check_server_availability(server_info: dict) -> bool:
"""
Проверяет доступность сервера (3x-ui или Remnawave).
"""
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
try:
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True
else:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
return True
except asyncio.TimeoutError:
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
return False
+202
View File
@@ -0,0 +1,202 @@
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from .key_cluster_mode import key_cluster_mode
from .key_country_mode import key_country_mode
from config import (
NOTIFY_EXTRA_DAYS,
RENEWAL_PRICES,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_connection,
check_connection_exists,
create_temporary_data,
get_balance,
get_trial,
)
from handlers.buttons import (
MAIN_MENU,
PAYMENT,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if DISCOUNTS and plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
async def create_key(
tg_id: int,
expiry_time,
state,
session,
message_or_query=None,
old_key_name: str = None,
plan: int = None,
):
"""
Универсальная точка входа для создания ключа.
Делегирует выполнение в зависимости от выбранного режима (страна или кластер).
Также отвечает за первичное подключение пользователя.
"""
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
if USE_COUNTRY_SELECTION:
await key_country_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
old_key_name=old_key_name,
)
else:
await key_cluster_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
plan=plan,
)
+224
View File
@@ -0,0 +1,224 @@
from datetime import datetime, timedelta
from typing import Any
import asyncpg
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
DATABASE_URL,
RENEWAL_PLANS,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
check_server_name_by_cluster,
create_temporary_data,
get_balance,
get_key_by_server,
get_key_details,
update_balance,
update_key_expiry,
)
from handlers.buttons import (
BACK,
MAIN_MENU,
PAYMENT,
)
from handlers.keys.key_utils import (
renew_key_in_cluster,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
DISCOUNTS,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
SUCCESS_RENEWAL_MSG,
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
for plan_id, plan_details in RENEWAL_PLANS.items():
months = plan_details["months"]
price = plan_details["price"]
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
button_text = f"📅 {months} месяц{'а' if months > 1 else ''} ({price} руб.)"
if discount > 0:
button_text += f" {discount}% скидка"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{months}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await callback_query.message.answer("<b>Ключ не найден.</b>")
except Exception as e:
logger.error(f"Ошибка в process_callback_renew_key: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
days_to_extend = 30 * int(plan)
total_gb = int((int(plan) or 1) * TOTAL_GB * 1024**3)
try:
record = await get_key_by_server(tg_id, client_id, session)
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
cost = RENEWAL_PLANS[plan]["price"]
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
)
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"plan": plan,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
await handle_custom_amount_input(callback_query, session)
else:
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
else:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
logger.info(f"[Info] Продление ключа {client_id} на {plan} мес. (Start)")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
response_message = SUCCESS_RENEWAL_MSG.format(months=plan)
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
conn = await asyncpg.connect(DATABASE_URL)
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
await conn.close()
return
server_id = key_info["server_id"]
if USE_COUNTRY_SELECTION:
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
await conn.close()
return
cluster_id = cluster_info["cluster_name"]
else:
cluster_id = server_id
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
await update_key_expiry(client_id, new_expiry_time, conn)
await update_balance(tg_id, -cost, conn)
await conn.close()
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
+366 -126
View File
@@ -6,10 +6,11 @@ import asyncpg
from py3xui import AsyncApi
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database import get_servers, store_key, delete_notification
from handlers.utils import get_least_loaded_cluster
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import (
ClientConfig,
add_client,
@@ -19,16 +20,22 @@ from panels.three_xui import (
toggle_client,
)
from datetime import datetime, timezone
async def create_key_on_cluster(
cluster_id: str, tg_id: int, client_id: str, email: str, expiry_timestamp: int, plan: int = None
cluster_id: str,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
plan: int = None,
session=None,
):
"""
Создает ключ на всех серверах указанного кластера (или на конкретном сервере, если cluster_id это имя сервера).
"""
try:
servers = await get_servers()
cluster = servers.get(cluster_id)
server_id_to_store = cluster_id
if not cluster:
found_servers = []
@@ -38,23 +45,93 @@ async def create_key_on_cluster(
found_servers.append(server_info)
if found_servers:
cluster = found_servers
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
semaphore = asyncio.Semaphore(2)
if SUPERNODE:
for server_info in cluster:
await create_client_on_server(
server_info, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_created = False
remnawave_key = None
remnawave_client_id = None
if remnawave_servers:
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
else:
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
inbound_ids = [s.get("inbound_id") for s in remnawave_servers if s.get("inbound_id")]
if not inbound_ids:
logger.warning("Нет inbound_id у серверов Remnawave")
else:
traffic_limit_bytes = int((plan or 1) * TOTAL_GB * 1024**3)
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"trafficLimitBytes": traffic_limit_bytes,
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
result = await remna.create_user(user_data)
if not result:
logger.error("Ошибка при создании пользователя в Remnawave")
else:
remnawave_created = True
remnawave_key = result.get("subscriptionUrl")
remnawave_client_id = result.get("uuid")
logger.info(f"[Key Creation] Пользователь создан в Remnawave: {result}")
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" if xui_servers else None
final_client_id = remnawave_client_id or client_id
if xui_servers:
if SUPERNODE:
for server_info in xui_servers:
await create_client_on_server(
server_info,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
)
else:
await asyncio.gather(
*(
create_client_on_server(
server,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
)
for server in xui_servers
),
return_exceptions=True,
)
else:
await asyncio.gather(
*(
create_client_on_server(server, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan)
for server in cluster
),
return_exceptions=True,
if (remnawave_created and remnawave_client_id) or xui_servers:
await store_key(
tg_id,
final_client_id,
email,
expiry_timestamp,
key=public_link,
server_id=server_id_to_store,
session=session,
remnawave_link=remnawave_key,
)
except Exception as e:
@@ -96,7 +173,7 @@ async def create_client_on_server(
unique_email = email
sub_id = unique_email
total_gb_value = int(TOTAL_GB) if plan is None else int(plan) * int(TOTAL_GB)
total_gb_value = int((plan or 1) * TOTAL_GB * 1024**3)
await add_client(
xui,
@@ -150,35 +227,80 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
notification_id = f"{email}_{notif}"
await delete_notification(tg_id, notification_id, session=conn)
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
remnawave_inbound_ids = []
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if panel_type == "remnawave":
inbound_uuid = server_info.get("inbound_id")
if inbound_uuid:
remnawave_inbound_ids.append(inbound_uuid)
else:
logger.warning(f"Не указан inbound_id для продления Remnawave на сервере {server_name}")
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
tasks.append(
extend_client_key(
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
)
if remnawave_inbound_ids:
remnawave_server = next(
(srv for srv in cluster if srv.get("panel_type", "").lower() == "remnawave" and srv.get("inbound_id") in remnawave_inbound_ids),
None
)
if not remnawave_server:
logger.error("❌ Не найден Remnawave сервер для продления")
else:
remna = RemnawaveAPI(remnawave_server["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if logged_in:
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
updated = await remna.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=remnawave_inbound_ids,
traffic_limit_bytes=total_gb
)
if updated:
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
else:
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}")
else:
logger.error("Не удалось войти в Remnawave API")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if panel_type == "3x-ui":
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
tasks.append(
extend_client_key(
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
)
)
elif panel_type != "remnawave":
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
@@ -204,32 +326,47 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
panel_type = server_info.get("panel_type", "3x-ui").lower()
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
continue
success = await remna.delete_user(client_id)
if success:
logger.info(f"Клиент Remnawave {client_id} успешно удалён")
else:
logger.warning(f"Не удалось удалить клиента Remnawave {client_id}")
continue
tasks.append(
delete_client(
elif panel_type == "3x-ui":
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
continue
await delete_client(
xui,
int(inbound_id),
email,
client_id,
)
)
await asyncio.gather(*tasks, return_exceptions=True)
else:
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_info.get('server_name')}")
except Exception as e:
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
@@ -238,7 +375,10 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id):
"""
Обновляет ключ на всех серверах указанного кластера (или сервера, если передано имя).
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
Работает с панелями 3x-ui и Remnawave. Использует новый client_id от Remnawave для всех серверов (если пересоздан).
Если SUPERNODE активен email делается уникальным на каждый сервер.
Возвращает кортеж: (новый client_id, remnawave ссылка или None).
"""
try:
servers = await get_servers()
@@ -255,8 +395,52 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
tasks = []
for server_info in cluster:
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_client_id = None
remnawave_key = None
if remnawave_servers:
inbound_ids = [s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")]
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if logged_in:
await remna.delete_user(client_id)
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
else:
logger.error(f"[Update] Ошибка создания Remnawave клиента")
else:
logger.error(f"[Update] Не удалось авторизоваться в Remnawave")
if not remnawave_client_id:
logger.warning(f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}")
remnawave_client_id = client_id
for server_info in xui_servers:
server_name = server_info.get("server_name", "unknown")
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
@@ -264,37 +448,36 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
logger=logger,
)
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
continue
if SUPERNODE:
sub_id = email
unique_email = f"{email}_{server_name.lower()}"
else:
sub_id = email
unique_email = email
tasks.append(
add_client(
xui,
ClientConfig(
client_id=client_id,
email=email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=email,
),
)
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
await asyncio.gather(*tasks, return_exceptions=True)
tasks.append(add_client(xui, config))
logger.info(f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}")
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}")
return remnawave_client_id, remnawave_key
except Exception as e:
logger.error(f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}")
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise e
@@ -324,20 +507,25 @@ async def update_subscription(tg_id: int, email: str, session: Any, cluster_over
tg_id,
email,
)
new_cluster_id = cluster_override or await get_least_loaded_cluster()
await asyncio.gather(
update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id),
return_exceptions=True,
new_client_id, remnawave_key = await update_key_on_cluster(
tg_id, client_id, email, expiry_time, new_cluster_id
)
servers = await get_servers()
cluster_servers = servers.get(new_cluster_id, [])
has_xui = any(s.get("panel_type", "").lower() == "3x-ui" for s in cluster_servers)
final_key_link = public_link if has_xui else None
await store_key(
tg_id,
client_id,
new_client_id,
email,
expiry_time,
public_link,
key=final_key_link,
remnawave_link=remnawave_key,
server_id=new_cluster_id,
session=session,
)
@@ -345,7 +533,7 @@ async def update_subscription(tg_id: int, email: str, session: Any, cluster_over
async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ.
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Args:
session (Any): Сессия базы данных.
@@ -355,56 +543,74 @@ async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, An
Returns:
dict[str, Any]: Структура с данными о трафике.
"""
query = "SELECT client_id, server_id FROM keys WHERE tg_id = $1 AND email = $2"
rows = await session.fetch(query, tg_id, email)
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
return {"status": "error", "message": "У пользователя нет активных ключей."}
server_ids = {row["server_id"] for row in rows}
query_servers = """
SELECT server_name, api_url FROM servers
SELECT server_name, cluster_name, api_url, panel_type
FROM servers
WHERE server_name = ANY($1) OR cluster_name = ANY($1)
"""
server_rows = await session.fetch(query_servers, list(server_ids))
if not server_rows:
logger.error(f"Не найдено серверов для: {server_ids}")
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
logger.error(f"Не найдено серверов для: {server_ids}")
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
servers_map = {row["server_name"]: row["api_url"] for row in server_rows}
servers_map = {row["server_name"]: row for row in server_rows}
user_traffic_data = {}
async def fetch_traffic(api_url: str, client_id: str, server: str) -> tuple[str, Any]:
"""
Получает трафик с сервера для заданного client_id.
Возвращает кортеж: (server, used_gb) или (server, ошибка).
"""
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
async def fetch_traffic(server_info: dict, client_id: str) -> tuple[str, Any]:
server_name = server_info["server_name"]
api_url = server_info["api_url"]
panel_type = server_info.get("panel_type", "3x-ui").lower()
try:
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server, round(used_gb, 2)
if panel_type == "3x-ui":
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
await xui.login()
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server_name, round(used_gb, 2)
else:
return server_name, "Ошибка получения трафика"
elif panel_type == "remnawave":
remna = RemnawaveAPI(api_url)
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
return server_name, "Не удалось авторизоваться"
user_data = await remna.get_user_by_uuid(client_id)
if not user_data:
return server_name, "Клиент не найден"
used_bytes = user_data.get("usedTrafficBytes", 0)
used_gb = used_bytes / 1073741824
return server_name, round(used_gb, 2)
else:
return server, "Ошибка получения трафика"
return server_name, f"Неизвестная панель: {panel_type}"
except Exception as e:
return server, f"Ошибка: {e}"
return server_name, f"Ошибка: {e}"
tasks = []
for row in rows:
client_id = row["client_id"]
server_id = row["server_id"]
if server_id in servers_map:
api_url = servers_map[server_id]
tasks.append(fetch_traffic(api_url, client_id, server_id))
else:
for server, api_url in servers_map.items():
tasks.append(fetch_traffic(api_url, client_id, server))
matched_servers = [s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id]
for server_info in matched_servers:
tasks.append(fetch_traffic(server_info, client_id))
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in results:
@@ -491,6 +697,7 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
"""
Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
Работает с 3x-ui и Remnawave.
Args:
cluster_id (str): ID кластера или имя сервера
@@ -513,24 +720,57 @@ async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
for server_info in cluster:
api_url = server_info["api_url"]
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
remnawave_done = False
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
api_url = server_info.get("api_url")
inbound_id = server_info.get("inbound_id")
if panel_type == "remnawave" and not remnawave_done:
conn = await asyncpg.connect(DATABASE_URL)
try:
row = await conn.fetchrow(
"SELECT client_id FROM keys WHERE email = $1 AND server_id = $2 LIMIT 1",
email,
cluster_id,
)
finally:
await conn.close()
if not row:
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row["client_id"]
remna = RemnawaveAPI(api_url)
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
remnawave_done = True
continue
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
await xui.login()
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
else:
logger.warning(f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f" Трафик клиента {email} успешно сброшен на всех серверах кластера {cluster_id}")
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(f" Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+306
View File
@@ -0,0 +1,306 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery, Message, InlineKeyboardButton, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
import pytz
import html
import os
import re
from datetime import datetime
from aiogram.fsm.state import State, StatesGroup
from typing import Any
from aiogram.fsm.context import FSMContext
from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
PUBLIC_LINK,
QRCODE,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
)
from database import (
get_key_details,
get_keys,
)
from handlers.buttons import (
ADD_SUB,
ALIAS,
BACK,
CHANGE_LOCATION,
CONNECT_DEVICE,
CONNECT_PHONE,
DELETE,
FREEZE,
MAIN_MENU,
PC_BUTTON,
QR,
RENEW,
RENEW_FULL,
TV_BUTTON,
UNFREEZE,
)
from handlers.texts import (
FROZEN_SUBSCRIPTION_MSG,
NO_SUBSCRIPTIONS_MSG,
key_message,
)
from handlers.utils import edit_or_send_message, handle_error, is_full_remnawave_cluster
from logger import logger
router = Router()
class RenameKeyState(StatesGroup):
waiting_for_new_alias = State()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
try:
records = await get_keys(target_message.chat.id, session)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
for record in records:
alias = record.get("alias")
email = record["email"]
client_id = record["client_id"]
expiry_time = record.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
else:
response_message = NO_SUBSCRIPTIONS_MSG
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("rename_key|"))
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
client_id = callback.data.split("|")[1]
await state.set_state(RenameKeyState.waiting_for_new_alias)
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
await edit_or_send_message(
target_message=callback.message,
text="✏️ Введите новое имя подписки (до 10 символов):",
reply_markup=builder.as_markup()
)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
alias = message.text.strip()
if len(alias) > 10:
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
await message.answer("❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод.")
return
data = await state.get_data()
client_id = data.get("client_id")
try:
await session.execute(
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
alias,
message.chat.id,
client_id,
)
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
finally:
await state.clear()
await process_callback_or_message_view_keys(message, session)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
return
is_frozen = record["is_frozen"]
builder = InlineKeyboardBuilder()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
if is_frozen:
builder.row(
InlineKeyboardButton(
text=UNFREEZE,
callback_data=f"unfreeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=FROZEN_SUBSCRIPTION_MSG,
reply_markup=builder.as_markup(),
media_path=image_path,
)
return
key = record.get("key")
remnawave_link = record.get("remnawave_link")
final_link = key or remnawave_link
expiry_time = record["expiry_time"]
server_name = record["server_id"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
time_left = expiry_date - datetime.utcnow()
if time_left.total_seconds() <= 0:
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0\nОсталось минут: 0"
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = f"Осталось: <b>{days}</b> дней, <b>{hours}</b> часов, <b>{minutes}</b> минут"
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
response_message = key_message(
final_link,
formatted_expiry_date,
days_left_message,
server_name,
server_name if USE_COUNTRY_SELECTION else None,
)
if (not key or not key.startswith(PUBLIC_LINK)) or ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
if is_full_remnawave and final_link:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=final_link),
)
)
else:
if CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(
text=CONNECT_PHONE,
callback_data=f"connect_phone|{key_name}",
)
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
if QRCODE:
builder.row(
InlineKeyboardButton(
text=QR,
callback_data=f"show_qr|{key_name}",
)
)
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
)
else:
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
if USE_COUNTRY_SELECTION:
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
if TOGGLE_CLIENT:
builder.row(
InlineKeyboardButton(
text=FREEZE,
callback_data=f"freeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
)
+4 -921
View File
@@ -1,754 +1,38 @@
import asyncio
import html
import locale
import os
import time
import re
from datetime import datetime, timedelta
from io import BytesIO
from typing import Any
import asyncpg
import pytz
import qrcode
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
CONNECT_PHONE_BUTTON,
DATABASE_URL,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
PUBLIC_LINK,
QRCODE,
RENEWAL_PLANS,
TOGGLE_CLIENT,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
INSTRUCTIONS_BUTTON
)
from aiogram.types import CallbackQuery
from database import (
check_server_name_by_cluster,
create_temporary_data,
delete_key,
get_balance,
get_key_by_server,
get_key_details,
get_keys,
get_servers,
update_balance,
update_key_expiry,
)
from handlers.buttons import (
ADD_SUB,
ALIAS,
ANDROID,
APPLY,
BACK,
CANCEL,
CHANGE_LOCATION,
CONNECT_DEVICE,
CONNECT_PHONE,
DELETE,
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
FREEZE,
IMPORT_ANDROID,
IMPORT_IOS,
IPHONE,
MAIN_MENU,
MANUAL_INSTRUCTIONS,
PAYMENT,
PC,
PC_BUTTON,
QR,
RENEW,
RENEW_FULL,
TV,
TV_BUTTON,
UNFREEZE,
)
from handlers.keys.key_view import process_callback_view_key
from handlers.keys.key_utils import (
delete_key_from_cluster,
renew_key_in_cluster,
toggle_client_on_cluster,
update_subscription,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
ANDROID_DESCRIPTION_TEMPLATE,
CHOOSE_DEVICE_TEXT,
DELETE_KEY_CONFIRM_MSG,
DISCOUNTS,
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
FROZEN_SUBSCRIPTION_MSG,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
IOS_DESCRIPTION_TEMPLATE,
KEY_DELETED_MSG_SIMPLE,
KEY_NOT_FOUND_MSG,
NO_SUBSCRIPTIONS_MSG,
PLAN_SELECTION_MSG,
SUBSCRIPTION_DESCRIPTION,
SUBSCRIPTION_FROZEN_MSG,
SUBSCRIPTION_UNFROZEN_MSG,
SUCCESS_RENEWAL_MSG,
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
key_message,
)
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
router = Router()
class RenameKeyState(StatesGroup):
waiting_for_new_alias = State()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
try:
records = await get_keys(target_message.chat.id, session)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
for record in records:
alias = record.get("alias")
email = record["email"]
client_id = record["client_id"]
expiry_time = record.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
else:
response_message = NO_SUBSCRIPTIONS_MSG
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("rename_key|"))
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
client_id = callback.data.split("|")[1]
await state.set_state(RenameKeyState.waiting_for_new_alias)
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
await edit_or_send_message(
target_message=callback.message,
text="✏️ Введите новое имя подписки (до 10 символов):",
reply_markup=builder.as_markup()
)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
alias = message.text.strip()
if len(alias) > 10:
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
await message.answer("❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод.")
return
data = await state.get_data()
client_id = data.get("client_id")
try:
await session.execute(
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
alias,
message.chat.id,
client_id,
)
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
finally:
await state.clear()
await process_callback_or_message_view_keys(message, session)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
is_frozen = record["is_frozen"]
if is_frozen:
response_message = FROZEN_SUBSCRIPTION_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=UNFREEZE,
callback_data=f"unfreeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=keyboard,
media_path=image_path,
)
else:
key = record["key"]
expiry_time = record["expiry_time"]
server_name = record["server_id"]
country = server_name
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0\nОсталось минут: 0"
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = f"Осталось: <b>{days}</b> дней, <b>{hours}</b> часов, <b>{minutes}</b> минут"
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
response_message = key_message(
key,
formatted_expiry_date,
days_left_message,
server_name,
country if USE_COUNTRY_SELECTION else None,
)
builder = InlineKeyboardBuilder()
if not key.startswith(PUBLIC_LINK) or ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
if CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(
text=CONNECT_PHONE,
callback_data=f"connect_phone|{key_name}",
)
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
if QRCODE:
builder.row(
InlineKeyboardButton(
text=QR,
callback_data=f"show_qr|{key_name}",
)
)
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
)
else:
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
if USE_COUNTRY_SELECTION:
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
if TOGGLE_CLIENT:
builder.row(
InlineKeyboardButton(
text=FREEZE,
callback_data=f"freeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=keyboard,
media_path=image_path,
)
else:
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
)
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
try:
key_name = callback_query.data.split("|")[1]
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(record["key"])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record['email']}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
try:
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
logger.error(f"Ошибка в handle_connect_device: {e}")
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"unfreeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Размораживает (включает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = FALSE
WHERE tg_id = $2
AND client_id = $3
""",
new_expiry_time,
record["tg_id"],
client_id,
)
await renew_key_in_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=TOTAL_GB,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
key_name = callback_query.data.split("|")[1]
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"freeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Замораживает (отключает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
time_left = record["expiry_time"] - now_ms
if time_left < 0:
time_left = 0
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = TRUE
WHERE tg_id = $2
AND client_id = $3
""",
time_left,
record["tg_id"],
client_id,
)
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow(
"""
SELECT key FROM keys WHERE email = $1
""",
email,
)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
)
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("update_subscription|"))
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
@@ -815,7 +99,7 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
servers = await get_servers(session)
async def delete_key_from_servers():
try: # lol
try:
tasks = []
for cluster_id, _cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
@@ -835,204 +119,3 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
)
except Exception as e:
logger.error(e)
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
for plan_id, plan_details in RENEWAL_PLANS.items():
months = plan_details["months"]
price = plan_details["price"]
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
button_text = f"📅 {months} месяц{'а' if months > 1 else ''} ({price} руб.)"
if discount > 0:
button_text += f" {discount}% скидка"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{months}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await callback_query.message.answer("<b>Ключ не найден.</b>")
except Exception as e:
logger.error(f"Ошибка в process_callback_renew_key: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
days_to_extend = 30 * int(plan)
gb_multiplier = {"1": 1, "3": 3, "6": 6, "12": 12}
total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0
try:
record = await get_key_by_server(tg_id, client_id, session)
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
cost = RENEWAL_PLANS[plan]["price"]
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
)
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"plan": plan,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
await handle_custom_amount_input(callback_query, session)
else:
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
else:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
logger.info(
f"[RENEW] Начинаю процесс продления ключа с параметрами: "
f"tg_id={tg_id}, client_id={client_id}, email={email}, "
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}, cost={cost}, "
f"callback_query={'есть' if callback_query else 'отсутствует'}, plan={plan}"
)
response_message = SUCCESS_RENEWAL_MSG.format(months=plan)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка редактирования сообщения в complete_key_renewal: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
conn = await asyncpg.connect(DATABASE_URL)
logger.info(f"[RENEW] Получение данных о ключе для email: {email}")
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[RENEW] Ключ с client_id {client_id} для пользователя {tg_id} не найден.")
await conn.close()
return
server_id = key_info["server_id"]
if USE_COUNTRY_SELECTION:
logger.info(f"[RENEW] USE_COUNTRY_SELECTION включён. Проверяю информацию о сервере {server_id}")
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[RENEW] Сервер {server_id} не найден в таблице servers.")
await conn.close()
return
cluster_id = cluster_info["cluster_name"]
logger.info(f"[RENEW] Информация о сервере получена: {cluster_info}. Использую cluster_id: {cluster_id}")
else:
cluster_id = server_id
logger.info(f"[RENEW] USE_COUNTRY_SELECTION выключен. Использую server_id в качестве cluster_id: {cluster_id}")
logger.info(f"[RENEW] Запуск продления ключа для пользователя {tg_id} на {plan} мес. в кластере {cluster_id}.")
async def renew_key_on_cluster():
logger.info(
f"[RENEW] Запуск renew_key_on_cluster с параметрами: "
f"cluster_id={cluster_id}, email={email}, client_id={client_id}, "
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}"
)
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
logger.info("[RENEW] Продление ключа на сервере завершено. Обновляю срок действия в базе данных.")
await update_key_expiry(client_id, new_expiry_time, conn)
logger.info("[RENEW] Срок действия ключа обновлён. Обновляю баланс пользователя.")
await update_balance(tg_id, -cost, conn)
logger.info(f"[RENEW] Ключ {client_id} успешно продлён на {plan} мес. для пользователя {tg_id}.")
logger.info("[RENEW] Инициализация процесса продления ключа в кластере.")
await renew_key_on_cluster()
logger.info("[RENEW] Процесс продления ключа завершён. Закрываю соединение с базой данных.")
await conn.close()
+20 -29
View File
@@ -95,37 +95,25 @@ async def combine_unique_lines(urls: list[str], identifier: str, query_string: s
return list(all_lines)
async def get_subscription_urls(server_id: str, email: str, conn) -> list[str]:
"""
Получает список URL-адресов для подписки в зависимости от режима выбора страны.
Args:
server_id: Идентификатор сервера или кластера
email: Email пользователя
conn: Соединение с базой данных
Returns:
Список URL-адресов для подписки
"""
async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]:
urls = []
if USE_COUNTRY_SELECTION:
logger.info(f"Режим выбора страны активен. Ищем сервер {server_id} в БД.")
logger.info(f"[Sub] Страна-режим активен. Ищем сервер {server_id}.")
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
if not server_data:
logger.warning(f"Не найден сервер {server_id} в БД!")
return []
subscription_url = server_data["subscription_url"]
urls = [f"{subscription_url}/{email}"]
logger.info(f"Используем подписку {urls[0]}")
return urls
if server_data and server_data["subscription_url"]:
urls.append(f"{server_data['subscription_url']}/{email}")
else:
servers = await get_servers(conn)
cluster_servers = servers.get(server_id, [])
for server in cluster_servers:
if url := server.get("subscription_url"):
urls.append(f"{url}/{email}")
servers = await get_servers(conn)
logger.info(f"Режим выбора страны отключен. Используем кластер {server_id}.")
cluster_servers = servers.get(server_id, [])
if not cluster_servers:
logger.warning(f"Не найдены сервера для {server_id}")
return []
urls = [f"{server['subscription_url']}/{email}" for server in cluster_servers]
logger.info(f"Найдено {len(urls)} URL-адресов в кластере {server_id}")
if include_remnawave_key:
urls.append(include_remnawave_key)
logger.info(f"[Sub] Добавлена Remnawave ссылка: {include_remnawave_key}")
logger.info(f"[Sub] Список URL подписок: {urls}")
return urls
@@ -346,7 +334,10 @@ async def handle_subscription(request: web.Request, old_subscription: bool = Fal
expiry_time_ms = client_data.get("expiry_time")
time_left = format_time_left(expiry_time_ms)
urls = await get_subscription_urls(server_id, email, conn)
urls = await get_subscription_urls(
server_id, email, conn, include_remnawave_key=client_data.get("remnawave_link")
)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
+41 -25
View File
@@ -45,6 +45,7 @@ from handlers.texts import (
KEY_RENEWED_TEMP_MSG,
)
from logger import logger
from handlers.utils import format_hours, format_months, format_minutes
from .notify_utils import send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
@@ -65,7 +66,7 @@ async def periodic_notifications(bot: Bot):
"""
while True:
if notification_lock.locked():
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
await asyncio.sleep(NOTIFICATION_TIME)
continue
@@ -78,7 +79,7 @@ async def periodic_notifications(bot: Bot):
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
logger.info("🚀 Запуск обработки уведомлений")
logger.info("Запуск обработки уведомлений")
try:
keys = await get_all_keys(session=conn)
@@ -101,14 +102,14 @@ async def periodic_notifications(bot: Bot):
await notify_users_no_traffic(bot, conn, current_time, keys)
await asyncio.sleep(0.5)
logger.info("Завершена обработка уведомлений")
logger.info("Завершена обработка уведомлений")
except Exception as e:
logger.error(f"Ошибка в periodic_notifications: {e}")
logger.error(f"Ошибка в periodic_notifications: {e}")
finally:
if conn:
await conn.close()
logger.info("🔌 Соединение с базой данных закрыто.")
logger.info("Соединение с базой данных закрыто.")
await asyncio.sleep(NOTIFICATION_TIME)
@@ -137,8 +138,10 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
continue
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
days_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}"
if hours_left > 0
else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -146,7 +149,7 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_24H.format(
email=email,
days_left_message=days_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
@@ -161,7 +164,7 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
await asyncio.sleep(1)
@@ -192,8 +195,10 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
continue
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}"
if hours_left > 0
else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -201,7 +206,7 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_10H.format(
email=email,
hours_left_message=hours_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
@@ -221,7 +226,7 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
await asyncio.sleep(1)
@@ -292,11 +297,11 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
KEY_DELETED_MSG.format(email=email),
keyboard,
)
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
logger.info(f"Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
except Exception as e:
logger.error(f"Не удалось отправить уведомление об удалении пользователю {tg_id}: {e}")
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:
@@ -308,15 +313,23 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
if hours > 0:
if minutes > 0:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix} и {minutes} минут"
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(email=email, time_str=time_str)
time_left_formatted = f"{format_hours(hours)} и {format_minutes(minutes)}"
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
email=email,
time_left_formatted=time_left_formatted
)
else:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix}"
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(email=email, time_str=time_str)
time_left_formatted = format_hours(hours)
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
email=email,
time_left_formatted=time_left_formatted
)
else:
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(email=email, minutes=NOTIFY_DELETE_DELAY)
minutes_formatted = format_minutes(minutes)
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
email=email,
minutes_formatted=minutes_formatted
)
else:
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
@@ -330,12 +343,12 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
)
await add_notification(tg_id, notification_id, session=conn)
logger.info(
f"📢 Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}."
f"Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}."
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление о продлении подписки пользователю {tg_id}: {e}")
logger.info("Обработка истекших ключей завершена.")
logger.info("Обработка истекших ключей завершена.")
await asyncio.sleep(1)
@@ -385,6 +398,7 @@ async def process_auto_renew_or_notify(
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
months_formatted = format_months(renewal_period_months)
logger.info(
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
@@ -403,7 +417,9 @@ async def process_auto_renew_or_notify(
)
renewed_message = KEY_RENEWED.format(
email=email, months=renewal_period_months, expiry_date=formatted_expiry_date
email=email,
months_formatted=months_formatted,
expiry_date=formatted_expiry_date
)
keyboard = build_notification_expired_kb()
@@ -19,6 +19,7 @@ from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import get_user_traffic
from handlers.texts import TRIAL_INACTIVE_BONUS_MSG, TRIAL_INACTIVE_FIRST_MSG, ZERO_TRAFFIC_MSG
from logger import logger
from handlers.utils import format_days
router = Router()
@@ -49,7 +50,7 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
)
"""
)
logger.info(f"👥 Найдено {len(inactive_trial_users)} неактивных пользователей.")
logger.info(f"Найдено {len(inactive_trial_users)} неактивных пользователей.")
for user in inactive_trial_users:
tg_id = user["tg_id"]
@@ -84,12 +85,21 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
if trial_extended:
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
trial_time_formatted = format_days(TRIAL_TIME)
extra_days_formatted = format_days(NOTIFY_EXTRA_DAYS)
total_days_formatted = format_days(total_days)
message = TRIAL_INACTIVE_BONUS_MSG.format(
display_name=display_name, NOTIFY_EXTRA_DAYS=NOTIFY_EXTRA_DAYS, total_days=total_days
display_name=display_name,
extra_days_formatted=extra_days_formatted,
total_days_formatted=total_days_formatted,
)
await conn.execute("UPDATE connections SET trial = -1 WHERE tg_id = $1", tg_id)
else:
message = TRIAL_INACTIVE_FIRST_MSG.format(display_name=display_name, TRIAL_TIME=TRIAL_TIME)
trial_time_formatted = format_days(TRIAL_TIME)
message = TRIAL_INACTIVE_FIRST_MSG.format(
display_name=display_name,
trial_time_formatted=trial_time_formatted,
)
try:
await bot.send_message(tg_id, message, reply_markup=keyboard)
+6 -29
View File
@@ -29,7 +29,6 @@ from config import (
NEWS_MESSAGE,
REFERRAL_BUTTON,
REFERRAL_OFFERS,
RENEWAL_PLANS,
SHOW_START_MENU_ONCE,
TOP_REFERRAL_BUTTON,
TRIAL_TIME,
@@ -58,7 +57,7 @@ from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .texts import get_referral_link, invite_message_send, profile_message_send
from .utils import edit_or_send_message
from .utils import edit_or_send_message, format_days
router = Router()
@@ -132,7 +131,7 @@ async def process_callback_view_profile(
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
if admin:
builder.row(
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
if SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
@@ -167,12 +166,13 @@ async def balance_handler(callback_query: CallbackQuery, session: Any):
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
image_path = os.path.join("img", "pic.jpg")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
media_path=image_path,
disable_web_page_preview=False,
)
@@ -210,29 +210,6 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
)
@router.message(F.text == "/tariffs")
@router.callback_query(F.data == "view_tariffs")
async def view_tariffs_handler(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
image_path = os.path.join("img", "tariffs.jpg")
tariffs_message = "<b>🚀 Доступные тарифы VPN:</b>\n\n" + "\n".join([
f"{months} {'месяц' if months == '1' else 'месяца' if int(months) in [2, 3, 4] else 'месяцев'}: "
f"{RENEWAL_PLANS[months]['price']} "
f"{'💳' if months == '1' else '🌟' if months == '3' else '🔥' if months == '6' else '🚀'} рублей"
for months in sorted(RENEWAL_PLANS.keys(), key=int)
])
await edit_or_send_message(
target_message=callback_query.message,
text=tariffs_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "invite")
@router.message(F.text == "/invite")
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
@@ -273,12 +250,12 @@ 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}"
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)[: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))
+8 -8
View File
@@ -31,13 +31,11 @@ from database import (
get_trial,
update_balance,
)
from handlers.admin.coupons.coupons_handler import handle_coupon_activation
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT
from handlers.captcha import generate_captcha
from handlers.keys.key_management import create_key
from handlers.keys.key_mode.key_create import create_key
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPON_SUCCESS_MSG,
GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG,
NEW_REFERRAL_NOTIFICATION,
NOT_SUBSCRIBED_YET_MSG,
@@ -49,6 +47,7 @@ from handlers.texts import (
get_about_vpn,
)
from logger import logger
from handlers.coupons import activate_coupon
from .admin.panel.keyboard import AdminPanelCallback
from .utils import edit_or_send_message
@@ -120,8 +119,8 @@ async def process_start_logic(
try:
if "coupons_" in text:
logger.info(f"Обнаружена ссылка на купон: {text}")
user_id = message.chat.id
await handle_coupon_activation(message, state, session, admin, text=text, user_id=user_id)
coupon_code = text.split("coupons_")[1]
await activate_coupon(message, state, session, coupon_code=coupon_code, admin=admin)
return
if "gift_" in text:
@@ -287,7 +286,7 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
if admin:
builder.row(
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
@@ -314,11 +313,12 @@ async def handle_about_vpn(callback_query: CallbackQuery):
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
text = get_about_vpn("3.2.3-minor")
image_path = os.path.join("img", "pic.jpg")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
media_path=image_path,
force_text=False,
)
)
+42 -46
View File
@@ -115,63 +115,37 @@ async def handle_error(tg_id: int, callback_query: object | None = None, message
logger.error(f"Ошибка при обработке ошибки: {e}")
def format_time_until_deletion(seconds: int) -> str:
if seconds <= 0:
return "0 минут"
days = seconds // (3600 * 24)
hours = (seconds % (3600 * 24)) // 3600
minutes = (seconds % 3600 + 59) // 60
parts = []
if days > 0:
if days == 1:
parts.append(f"{days} день")
elif 2 <= days <= 4:
parts.append(f"{days} дня")
else:
parts.append(f"{days} дней")
if hours > 0:
if hours == 1:
parts.append(f"{hours} час")
elif 2 <= hours <= 4:
parts.append(f"{hours} часа")
else:
parts.append(f"{hours} часов")
if minutes > 0 and days == 0:
if minutes == 1:
parts.append("1 минута")
elif 2 <= minutes <= 4:
parts.append(f"{minutes} минуты")
else:
parts.append(f"{minutes} минут")
return " и ".join(parts) if parts else "менее минуты"
def get_plural_form(num: int, form1: str, form2: str, form3: str) -> str:
"""Универсальная функция для получения правильной формы множественного числа"""
n = abs(num) % 100
if 10 < n < 20:
return form3
return {1: form1, 2: form2, 3: form2, 4: form2}.get(n % 10, form3)
def format_months(months: int) -> str:
"""Форматирует количество месяцев с правильным склонением"""
if months <= 0:
return "0 месяцев"
return f"{months} {get_plural_form(months, 'месяц', 'месяца', 'месяцев')}"
def format_days(days: int) -> str:
"""
Форматирует количество дней с правильным склонением.
Args:
days (int): Количество дней.
Returns:
str: Строка с числом и склонённым словом "день/дня/дней".
"""
"""Форматирует количество дней с правильным склонением"""
if days <= 0:
return "0 дней"
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
def format_hours(hours: int) -> str:
"""Форматирует количество часов с правильным склонением"""
if hours <= 0:
return "0 часов"
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
def format_minutes(minutes: int) -> str:
"""Форматирует количество минут с правильным склонением"""
if minutes <= 0:
return "0 минут"
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
async def edit_or_send_message(
target_message: Message,
@@ -248,3 +222,25 @@ def convert_to_bytes(value: float, unit: str) -> int:
TB = GB * 1024
units = {"KB": KB, "MB": MB, "GB": GB, "TB": TB}
return int(value * units.get(unit.upper(), 1))
async def is_full_remnawave_cluster(cluster_id: str, session) -> bool:
"""
Универсальная проверка:
- Если cluster_id это имя кластера, проверяет, что все его сервера используют Remnawave.
- Если cluster_id это имя одиночного сервера, проверяет, что он Remnawave.
"""
cluster_servers = await session.fetch(
"SELECT panel_type FROM servers WHERE cluster_name = $1",
cluster_id,
)
if cluster_servers:
panel_types = [s["panel_type"].lower() for s in cluster_servers if s.get("panel_type")]
return all(pt == "remnawave" for pt in panel_types)
server = await session.fetchrow(
"SELECT panel_type FROM servers WHERE server_name = $1",
cluster_id,
)
return server and server["panel_type"].lower() == "remnawave"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 586 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 583 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 596 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 592 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 590 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.