v4.4 → v.5

This commit is contained in:
Vladislav Lisitsyn
2025-10-25 17:13:19 +03:00
committed by GitHub
173 changed files with 10901 additions and 5374 deletions
+3 -1
View File
@@ -58,4 +58,6 @@ Dockerfile
/logs
setup.py
.ruff_cache
.github/workflows/
.github/workflows/
modules/
storage/
+6 -3
View File
@@ -1,7 +1,10 @@
formatting:
format:
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
lint:
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
format-payments:
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
+191 -67
View File
@@ -1,87 +1,211 @@
# 🚀 SoloBot
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
![IMG_1565](https://github.com/user-attachments/assets/71cfb259-9889-4066-8626-b804ae4cf7fb)
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
<h1 style="margin:0;font-weight:900;letter-spacing:.3px">SoloBot</h1>
<p style="margin:6px 0 0;font-size:16px;opacity:.9"><b>Идеальный помощник для 3x-UI и Remnawave (VLESS)</b></p>
# Описание
## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
<p style="margin:16px 0 0">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Website-visit-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Website">
</a>&nbsp;
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Try_in_Telegram-demo-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Telegram">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img height="46" src="https://img.shields.io/badge/Features-overview-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=readthedocs&logoColor=ff6a00" alt="Features">
</a>&nbsp;
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/Releases-history-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Releases">
</a>
</p>
## ⚙️ Основные возможности SoloBot
<p style="margin:10px 0 0">
<a href="LICENSE" target="_blank">
<img height="32" src="https://img.shields.io/badge/License-CC_BY--NC_4.0-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=creative-commons&logoColor=ff6a00" alt="License">
</a>
</p>
| 📌 Раздел | 💡 Возможности |
|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Мультипанель** | • Работа с панелями [3x-ui](https://github.com/MHSanaei/3x-ui) <br> • Работа с панелями [Remnawave](https://github.com/remnawave/panel) <br> • Работа в режиме совместимости (различные панели одновременно) |
| **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки <br> • Пробный период на заданный период <br> • Продление ключей по тарифному плану <br> • Поддержка различных клиентов (Happ, Hiddify, v2RayTun) <br> • Кастомизируемые заголовки профилей |
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
| **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые) <br> • YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Robokassa (ИП) <br> • Cryptobot [@izzzzzi](https://github.com/izzzzzi) <br> • Telegram Stars <br> • Heleket (Физ. лица) [@JustYay](https://github.com/JustYay) <br> • Wata (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Kassai (Физ. лица) [@JustYay](https://github.com/JustYay)|
| **Безопасность и стабильность** | • Периодические бэкапы <br> • Смена домена в случае переезда <br> • Проверка доступности серверов <br> • Уведомление о недоступности сервера и его аптайм <br> |
| **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент) <br> • Напоминания о неиспользованном трафике |
| **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве) <br> • Выдача в разных режимах (по одной локации или в формате подписки) <br> • Автопроверка доступности <br> • Балансировка нагрузки при выдаче ключей <br> • Синхронизация клиентов между серверами <br> • Ограничение максимального количества ключей на сервер <br> • Возможность включения/отключения отдельных серверов |
| **Админ-панель** | • Поиск по TG ID / username / ключу / email <br> • Управление балансом, подписками, заморозками <br> • Перезагрузка, бан-лист <br> • Создание купонов, UTM, статистика |
| **Кастомизация** | • Все функции бота кастомизируемы, вплоть до логики работы <br> |
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
</div>
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img src="https://img.shields.io/badge/Возможности_бота-перейти-ffa726?style=for-the-badge" />
</a>
## 🛠 Обновления
<h2 align="center">Основные возможности</h2>
### SoloBot регулярно получает новые функции и улучшения.
<table align="center" style="max-width:980px;width:100%">
<tr>
<th align="left" style="width:30%">📌 Раздел</th>
<th align="left">💡 Возможности</th>
</tr>
#### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
<tr>
<td><b>Мультипанель</b></td>
<td>3x-ui<br>Remnawave<br>Совместимый режим</td>
</tr>
# Наш сайт и полная версия
<tr>
<td><b>Подписки</b></td>
<td>Произвольный срок и триал<br>Продление по тарифу<br>Happ / Hiddify / v2RayTun<br>Кастом заголовков</td>
</tr>
### Переходи на наш сайт!
<h3>
<tr>
<td><b>Тарификация</b></td>
<td>Лимиты устройств<br>Лимиты трафика<br>Лимиты серверов в подписках</td>
</tr>
<tr>
<td><b>Клиент</b></td>
<td>Ключ / сервер / TTL<br>Продление / удаление / начисление дней / отключение<br>Смена локации<br>Несколько устройств</td>
</tr>
<tr>
<td><b>Поддержка роутеров</b></td>
<td>Выдача VLESS подписки за отдельную цену</td>
</tr>
<tr>
<td><b>Рефералы</b></td>
<td>Уникальные ссылки<br>Инлайн и обычные сообщения<br>% или фикс</td>
</tr>
<tr>
<td><b>UTM-аналитика</b></td>
<td>Трекинг переходов<br>Привязка к рефералам / купонам / триалам<br>Конверсии<br>Просмотр в админке</td>
</tr>
<tr>
<td><b>Оплата</b></td>
<td>YooKassa (ИП/Самозанятые)<br>YooMoney (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Robokassa (ИП)<br>Telegram Stars<br>Heleket (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>Wata (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Kassai (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>И другие <a href="https://pocomacho.ru/solonetbot/modules/">см. Модули</a></td>
</tr>
<tr>
<td><b>Валютность</b></td>
<td>Оплата в разных валютах</td>
</tr>
<tr>
<td><b>Надёжность</b></td>
<td>Бэкапы<br>Смена домена<br>Проверка доступности<br>Уведомления об аптайме</td>
</tr>
<tr>
<td><b>Уведомления</b></td>
<td>О скором окончании подписки<br>О непотраченном трафике</td>
</tr>
<tr>
<td><b>Рассылки</b></td>
<td>По заданным группам<br>С картинками или видео</td>
</tr>
<tr>
<td><b>Серверы</b></td>
<td>Мультисерверность<br>Одна локация или подписка<br>Автопроверка и балансировка<br>Синхронизация и лимиты<br>Вкл/выкл</td>
</tr>
<tr>
<td><b>Воронка продаж</b></td>
<td>Доп. дни к триалу: не взял триал → оффер → активация<br>Горячие лиды: платил → перестал → таргет/КП → возврат</td>
</tr>
<tr>
<td><b>Админка</b></td>
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
</tr>
<tr>
<td><b>Модульность</b></td>
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
</tr>
<tr>
<td><b>Кастомизация</b></td>
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
</tr>
</table>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Обновления</h2>
<p align="center">Регулярные релизы. Обновление через CLI. Ветки: <code>main</code> — стабильные версии, <code>dev</code> — бета.</p>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Все_версии-смотреть-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Все версии">
</a>
</p>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Гайды и расширения</h2>
<p align="center" style="max-width:900px;margin:8px auto 14px">
На сайте всегда лежат актуальные файлы для запуска разных версий бота. Есть собственное Wiki с пошаговыми инструкциями по установке и обновлениям. Доступна система модулей: бот легко дополняется готовыми расширениями и вашим кастомным функционалом.
</p>
<p align="center">
<img src="https://github.com/user-attachments/assets/7d0a954d-41b7-4d7c-8618-aee1f4459792" alt="Guides" width="100%">
</p>
<p align="center">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ffa726?style=for-the-badge" alt="Сайт SoloNetBot">
</a>
</h3>
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/fb0028cd-c588-4548-b290-a4bfcc96a3e1)
<h3>
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img src="https://img.shields.io/badge/Попробовать_SoloBot_в_Telegram-жми-ffa726?style=for-the-badge&logo=telegram" alt="Попробовать SoloBot в Telegram">
</a>
</h3>
## Отзывы пользователей:
#### SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30)
<h3>
<img height="44" src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Site">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
<img src="https://img.shields.io/badge/Читать_отзывы-на_сайте-ffa726?style=for-the-badge&logo=discourse" alt="Читать отзывы">
<img height="44" src="https://img.shields.io/badge/Отзывы-на_сайте-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=discourse&logoColor=ff6a00" alt="Reviews">
</a>
</h3>
</p>
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
## 🚨 Права на использование
<h2 align="center">Полная версия</h2>
<p align="center">
<img src="https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30" alt="Community" width="100%">
</p>
<p align="center" style="max-width:900px;margin:8px auto 14px">
SoloBot уже помог более чем 500 пользователям из сообщества. Присоединяйтесь: попробуйте бота или напишите в поддержку, чтобы получить доступ и консультацию.
</p>
<p align="center">
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Попробовать_бота-открыть_Telegram-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Try bot">
</a>&nbsp;
<a href="https://t.me/solonet_sup" target="_blank">
<img height="46" src="https://img.shields.io/badge/Написать_в_поддержку-получить_доступ-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Support">
</a>
</p>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
| ❗ | **Этот проект распространяется по лицензии [CC BY-NC 4.0](LICENSE)** |
|----------------------------------|----------------------------------------------------------------------|
| ⛔ **Перепродажа кода запрещена** | Нельзя продавать или перепродавать код без разрешения автора. |
| ✅ **Для личного использования** | Код можно использовать и модифицировать для личного использования. |
<h2 align="center">Лицензия</h2>
<table align="center" style="max-width:900px;width:100%">
<tr>
<td style="width:8%;text-align:center">❗</td>
<td><b>Этот проект распространяется по лицензии <a href="LICENSE">CC BY-NC 4.0</a></b></td>
</tr>
<tr>
<td style="text-align:center">⛔</td>
<td><b>Перепродажа кода запрещена.</b> Нельзя продавать или перепродавать код без разрешения автора.</td>
</tr>
<tr>
<td style="text-align:center">✅</td>
<td><b>Для личного использования.</b> Код можно использовать и модифицировать для личных проектов.</td>
</tr>
</table>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Участники</h2>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" alt="Contributors">
</a>
</p>
## Участники проекта
Благодарим всех, кто помогает развивать SoloBot! 💖
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" />
</a>
-2
View File
@@ -1,5 +1,3 @@
from typing import Any, Type, Union
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+19 -2
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
@@ -18,7 +18,7 @@ gift_router = generate_crud_router(
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
enabled_methods=["get_all", "get_one", "create", "update"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@@ -46,3 +46,20 @@ gift_usage_router = generate_crud_router(
)
router.include_router(gift_usage_router, prefix="/usages", tags=["GiftUsages"])
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_gift_with_usages(
gift_id: str = Path(..., description="ID подарка"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
raise HTTPException(status_code=404, detail="Gift not found")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
await session.commit()
return {"message": "Подарок и связанные использования удалены"}
+1 -1
View File
@@ -8,7 +8,7 @@ from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.key_utils import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
+31 -1
View File
@@ -13,6 +13,7 @@ from api.schemas import (
TemporaryDataResponse,
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.models import (
Admin,
BlockedUser,
@@ -133,9 +134,38 @@ router.include_router(
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
enabled_methods=["get_all", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_admin_token)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_admin_token)]
)
async def get_tracking_source_with_stats(
code: str,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Tracking source not found")
stats = await get_tracking_source_stats(session, code)
return TrackingSourceResponse(
id=source.id,
name=source.name,
code=source.code,
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=(stats["registrations"] if stats else 0),
trials=(stats["trials"] if stats else 0),
payments=(stats["payments"] if stats else 0),
total_amount=(float(stats["total_amount"]) if stats else 0.0),
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
)
+1 -1
View File
@@ -1,5 +1,5 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import delete, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
+1 -1
View File
@@ -9,7 +9,7 @@ from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.key_utils import delete_key_from_cluster
from handlers.keys.operations import delete_key_from_cluster
from logger import logger
-1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, model_validator
-1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
-2
View File
@@ -1,5 +1,3 @@
from typing import Optional
from pydantic import BaseModel, Field
+18 -2
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Literal, Optional, Union
from pydantic import BaseModel
@@ -8,7 +7,7 @@ class PaymentBase(BaseModel):
tg_id: int
amount: float
payment_system: str
status: Literal["success", "pending", "failed"]
status: str
class PaymentResponse(PaymentBase):
@@ -94,6 +93,16 @@ class BlockedUserResponse(BaseModel):
from_attributes = True
class MonthlyStats(BaseModel):
month: str
registrations: int
trials: int
new_purchases_count: int
new_purchases_amount: float
repeat_purchases_count: int
repeat_purchases_amount: float
class TrackingSourceResponse(BaseModel):
id: int
name: str
@@ -102,5 +111,12 @@ class TrackingSourceResponse(BaseModel):
created_by: int
created_at: datetime
registrations: int = 0
trials: int = 0
payments: int = 0
total_amount: float = 0.0
monthly: list[MonthlyStats] = []
class Config:
from_attributes = True
-2
View File
@@ -1,5 +1,3 @@
from typing import Optional
from pydantic import BaseModel
+2 -1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
@@ -13,6 +12,7 @@ class TariffBase(BaseModel):
device_limit: int | None = None
is_active: bool = True
subgroup_title: str | None = None
sort_order: int | None = None
class TariffResponse(TariffBase):
@@ -33,6 +33,7 @@ class TariffUpdate(BaseModel):
device_limit: int | None = None
is_active: bool | None = None
subgroup_title: str | None = None
sort_order: int | None = None
class Config:
from_attributes = True
-1
View File
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
+58 -125
View File
@@ -1,10 +1,5 @@
import os
import subprocess
import time
import traceback
from functools import lru_cache
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -15,102 +10,19 @@ from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
from config import ADMIN_ID, API_TOKEN
from database import async_session_maker
from filters.private import IsPrivateFilter
from logger import logger
from utils.modules_loader import load_modules_from_folder, modules_hub
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
dp.include_router(modules_hub)
_last_check_time = 0
_last_git_info = ""
def _get_git_commit_number_uncached() -> str:
repo_url = "https://github.com/Vladless/Solo_bot"
cwd = os.path.abspath(os.path.dirname(__file__))
if not os.path.isdir(os.path.join(cwd, ".git")):
cwd = "/root/Prod/Solo_bot"
logger.info(f"[Git] .git не найден в текущем каталоге, используем {cwd}")
env = os.environ.copy()
env["GIT_DIR"] = os.path.join(cwd, ".git")
env["GIT_WORK_TREE"] = cwd
try:
local_number = (
subprocess.check_output(["git", "rev-list", "--count", "HEAD"], cwd=cwd, env=env).decode().strip()
)
local_hash = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=cwd, env=env).decode().strip()
try:
branch = (
subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, env=env).decode().strip()
)
if branch == "HEAD":
describe = (
subprocess.check_output(
["git", "describe", "--tags", "--exact-match"],
cwd=cwd,
env=env,
stderr=subprocess.DEVNULL,
)
.decode()
.strip()
)
branch = "main" if describe.startswith("v") or "release" in describe.lower() else "dev"
except Exception:
branch = "dev"
except Exception as e:
logger.error(f"[Git] Ошибка при получении локального коммита: {e}")
return f"\n(Требуется обновление через CLI (команда <code>sudo solobot</code>): {e})"
try:
subprocess.check_output(["git", "fetch", "origin"], cwd=cwd, env=env)
remote_commit = subprocess.check_output(
["git", "ls-remote", "origin", f"refs/heads/{branch}"], cwd=cwd, env=env
).decode()
remote_hash = remote_commit.split()[0]
remote_number = (
subprocess.check_output(["git", "rev-list", "--count", remote_hash], cwd=cwd, env=env).decode().strip()
)
if local_hash == remote_hash:
logger.info("[Git] Локальная версия актуальна")
return "\n(Актуальная версия)"
return (
f'\n(commit <a href="{repo_url}/commit/{local_hash}">'
f"#{local_number}</a> / actual commit "
f'<a href="{repo_url}/commit/{remote_hash}">#{remote_number}</a>)'
)
except Exception as e:
logger.error(f"[Git] Ошибка при получении удалённого коммита: {e}")
return "\n(Требуется обновление через CLI, команда <code>sudo solobot</code>)"
@lru_cache(maxsize=1)
def _cached_git_info() -> str:
return _get_git_commit_number_uncached()
def get_git_commit_number() -> str:
global _last_check_time, _last_git_info
now = time.time()
if now - _last_check_time > 3600:
_last_check_time = now
_cached_git_info.cache_clear()
_last_git_info = _cached_git_info()
return _last_git_info
def get_version() -> str:
return f"v4.4-Release{get_git_commit_number()}"
load_modules_from_folder()
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
@@ -130,9 +42,28 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
logger.warning("Отправляем стартовое меню.")
try:
from handlers.start import handle_start_callback_query, start_command
tb = "".join(
traceback.format_exception(type(event.exception), event.exception, event.exception.__traceback__)
)
logger.warning(f"Показываем стартовое меню из-за TelegramBadRequest: {error_message}")
logger.error(f"Traceback:\n{tb}")
if ADMIN_ID:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
tb.encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {error_message[:1021]}...",
)
except Exception as e:
logger.error(f"Сбой при логировании/отправке ошибки админу: {e}", exc_info=True)
try:
from handlers.start import start_entry
if event.update.message:
fsm_context = dp.fsm.get_context(
@@ -140,28 +71,30 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
return True
@@ -181,34 +114,34 @@ async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except TelegramBadRequest as exception:
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
+162 -20
View File
@@ -1,3 +1,4 @@
import locale
import os
import re
import shutil
@@ -18,19 +19,42 @@ from rich.table import Table
from config import BOT_SERVICE
def ensure_utf8_locale():
try:
current_locale = locale.getlocale()
if current_locale and current_locale[1] == "UTF-8":
return
except Exception:
pass
console.print("[yellow]⏳ Проверка и установка локали UTF-8...[/yellow]")
os.environ["LC_ALL"] = "en_US.UTF-8"
os.environ["LANG"] = "en_US.UTF-8"
result = subprocess.run(["locale", "-a"], capture_output=True, text=True)
if "en_US.utf8" not in result.stdout.lower():
console.print("[blue]Добавляю локаль en_US.UTF-8 в систему...[/blue]")
try:
subprocess.run(["sudo", "locale-gen", "en_US.UTF-8"], check=True)
subprocess.run(["sudo", "update-locale", "LANG=en_US.UTF-8"], check=True)
console.print("[green]Локаль успешно установлена.[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при установке локали: {e}[/red]")
else:
console.print("[green]Локаль UTF-8 уже доступна в системе.[/green]")
try:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
if not os.environ.get("LC_ALL", "").endswith("UTF-8"):
os.environ["LC_ALL"] = "en_US.UTF-8"
os.environ["LANG"] = "en_US.UTF-8"
console = Console()
ensure_utf8_locale()
BACK_DIR = os.path.expanduser("~/.solobot_backup")
BACK_DIR = os.path.expanduser("~/.solobot_backups")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == "/root"
@@ -72,16 +96,96 @@ def print_logo():
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
def list_backups():
if not os.path.isdir(BACK_DIR):
return []
pairs = []
for name in os.listdir(BACK_DIR):
path = os.path.join(BACK_DIR, name)
if os.path.isdir(path):
try:
mtime = os.path.getmtime(path)
except Exception:
mtime = 0
pairs.append((mtime, path))
pairs.sort(reverse=True)
return [p for _, p in pairs]
def prune_old_backups():
backups = list_backups()
for path in backups[3:]:
try:
shutil.rmtree(path, ignore_errors=True)
except Exception:
subprocess.run(["sudo", "rm", "-rf", path])
def backup_project():
from datetime import datetime
os.makedirs(BACK_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dst = os.path.join(BACK_DIR, f"backup-{ts}")
console.print("[yellow]Создаётся резервная копия проекта...[/yellow]")
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
subprocess.run(["rm", "-rf", BACK_DIR])
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
console.print(f"[green]Бэкап сохранён в: {BACK_DIR}[/green]")
subprocess.run(["cp", "-r", PROJECT_DIR, dst])
console.print(f"[green]Бэкап сохранён в: {dst}[/green]")
prune_old_backups()
def restore_from_backup():
from datetime import datetime
backups = list_backups()[:3]
if not backups:
console.print(f"[red]❌ Бэкапы не найдены: {BACK_DIR}[/red]")
return
console.print("\n[bold green]Доступные бэкапы:[/bold green]")
shown = []
for idx, path in enumerate(backups, 1):
try:
mtime = os.path.getmtime(path)
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
dt = "unknown"
console.print(f"[cyan]{idx}.[/cyan] {os.path.basename(path)} [dim]{dt}[/dim]")
shown.append((idx, path))
try:
choice = Prompt.ask(
"[bold blue]Выберите номер бэкапа[/bold blue]",
choices=[str(i) for i, _ in shown],
)
except Exception:
return
sel_path = shown[int(choice) - 1][1]
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны выбранным бэкапом.[/red]")
if not Confirm.ask("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
return
if is_service_exists(SERVICE_NAME):
console.print("[blue]Останавливаю службу перед восстановлением...[/blue]")
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
install_rsync_if_needed()
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
rc = subprocess.run(f"rsync -a --delete {sel_path}/ {PROJECT_DIR}/", shell=True).returncode
if rc != 0:
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
return
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]✅ Восстановление из бэкапа завершено[/green]")
def auto_update_cli():
"""Обновляет CLI, если отличается от последней версии. Перезапускает при необходимости."""
console.print("[yellow]Проверка обновлений CLI...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
@@ -109,7 +213,6 @@ def auto_update_cli():
def fix_permissions():
"""Устанавливает корректные права на все файлы и папки проекта"""
console.print("[yellow]Восстанавливаю владельца и права доступа к проекту...[/yellow]")
try:
@@ -151,19 +254,28 @@ def install_rsync_if_needed():
def clean_project_dir_safe(update_buttons=False, update_img=False):
console.print("[yellow]Очистка проекта перед обновлением...[/yellow]")
preserved_paths = {
preserved_paths = set()
preserved_paths.update([
os.path.join(PROJECT_DIR, "config.py"),
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
os.path.join(PROJECT_DIR, ".git"),
}
os.path.join(PROJECT_DIR, "modules"),
])
for root, dirs, files in os.walk(os.path.join(PROJECT_DIR, "modules")):
for name in dirs + files:
preserved_paths.add(os.path.join(root, name))
if not update_buttons:
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
if not update_img:
preserved_paths.add(os.path.join(PROJECT_DIR, "img"))
for root, _, files in os.walk(os.path.join(PROJECT_DIR, "img")):
for file in files:
preserved_paths.add(os.path.join(root, file))
for root, dirs, files in os.walk(os.path.join(PROJECT_DIR, "img")):
for name in dirs + files:
preserved_paths.add(os.path.join(root, name))
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
for file in files:
@@ -179,10 +291,17 @@ def clean_project_dir_safe(update_buttons=False, update_img=False):
for dir in dirs:
dir_path = os.path.join(root, dir)
if os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "handlers"):
if os.path.abspath(dir_path) in [
os.path.join(PROJECT_DIR, "handlers"),
os.path.join(PROJECT_DIR, "img"),
os.path.join(PROJECT_DIR, "modules"),
]:
continue
if not update_img and os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "img"):
if os.path.abspath(dir_path).startswith(os.path.join(PROJECT_DIR, "modules") + os.sep):
continue
try:
os.rmdir(dir_path)
except Exception:
@@ -306,9 +425,19 @@ def update_from_beta():
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
@@ -374,9 +503,19 @@ def update_from_release():
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
@@ -414,7 +553,7 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI v0.2.8", title_style="bold magenta", header_style="bold blue")
table = Table(title="Solobot CLI v0.3.3", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
@@ -424,7 +563,8 @@ def show_menu():
table.add_row("5", "Показать логи (80 строк)")
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Выход")
table.add_row("8", "Восстановить из бэкапа")
table.add_row("9", "Выход")
console.print(table)
@@ -437,7 +577,7 @@ def main():
show_menu()
choice = Prompt.ask(
"[bold blue]👉 Введите номер действия[/bold blue]",
choices=[str(i) for i in range(1, 9)],
choices=[str(i) for i in range(1, 10)],
show_choices=False,
)
if choice == "1":
@@ -481,6 +621,8 @@ def main():
elif choice == "7":
show_update_menu()
elif choice == "8":
restore_from_backup()
elif choice == "9":
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
View File
Binary file not shown.
Binary file not shown.
+8 -7
View File
@@ -1,23 +1,24 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment
from database.models import Key, Payment, User
async def get_hot_leads(session: AsyncSession):
"""
Возвращает пользователей, у которых есть успешные оплаты, но нет активных ключей.
"""
subquery = select(Key.tg_id).where(Key.expiry_time > func.extract("epoch", func.now()) * 1000).distinct()
now_ms = func.extract("epoch", func.now()) * 1000
sub_active = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
stmt = (
select(Payment.tg_id)
.join(User, User.tg_id == Payment.tg_id)
.distinct()
.where(User.trial == 1)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(~Payment.tg_id.in_(subquery))
.where(~Payment.tg_id.in_(sub_active))
)
result = await session.execute(stmt)
return [row.tg_id for row in result]
return result.scalars().all()
+4 -2
View File
@@ -18,11 +18,13 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
skipped = 0
if USE_COUNTRY_SELECTION:
result = await session.execute(select(Server.name).where(Server.enabled is True, Server.panel_type == "3x-ui"))
result = await session.execute(
select(Server.server_name).where(Server.enabled.is_(True), Server.panel_type == "3x-ui")
)
else:
result = await session.execute(
select(Server.cluster_name)
.where(Server.enabled is True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
.where(Server.enabled.is_(True), Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
.distinct()
)
+3
View File
@@ -5,6 +5,7 @@ from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker, engine
from database.models import Admin, Base, User
from database.tariffs import initialize_all_tariff_weights
async def init_db():
@@ -34,3 +35,5 @@ async def init_db():
)
)
await session.commit()
await initialize_all_tariff_weights(session)
+53 -23
View File
@@ -22,25 +22,41 @@ async def store_key(
):
try:
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
if exists.scalar_one_or_none():
logger.info(f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}")
return
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=int(datetime.utcnow().timestamp() * 1000),
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
)
session.add(new_key)
existing_key = exists.scalar_one_or_none()
if existing_key:
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.client_id == client_id)
.values(
email=email,
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
)
)
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
else:
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=int(datetime.utcnow().timestamp() * 1000),
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
)
session.add(new_key)
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
await session.commit()
logger.info(f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
@@ -116,11 +132,7 @@ async def delete_key(session: AsyncSession, identifier: int | str):
async def update_key_expiry(session: AsyncSession, client_id: str, new_expiry_time: int):
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(expiry_time=new_expiry_time, notified=False, notified_24h=False)
)
await session.execute(update(Key).where(Key.client_id == client_id).values(expiry_time=new_expiry_time))
await session.commit()
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
@@ -169,3 +181,21 @@ async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: in
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
await session.commit()
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
async def get_subscription_link(session: AsyncSession, email: str) -> str | None:
result = await session.execute(select(func.coalesce(Key.key, Key.remnawave_link)).where(Key.email == email))
return result.scalar_one_or_none()
async def update_key_client_id(session: AsyncSession, email: str, new_client_id: str):
await session.execute(update(Key).where(Key.email == email).values(client_id=new_client_id))
await session.commit()
logger.info(f"client_id обновлён для {email} -> {new_client_id}")
async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
q = update(Key).where(Key.email == email).values(key=link).returning(Key.client_id)
res = await session.execute(q)
await session.commit()
return res.scalar_one_or_none() is not None
+49 -3
View File
@@ -12,10 +12,13 @@ from sqlalchemy import (
Float,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
Base = declarative_base()
@@ -43,7 +46,16 @@ class User(DictLikeMixin, Base):
is_bot = Column(Boolean, default=False)
balance = Column(Float, default=0.0)
trial = Column(Integer, default=0)
source_code = Column(String, ForeignKey("tracking_sources.code"))
preferred_currency = Column(String(10), nullable=False, server_default="RUB", index=True)
source_code = Column(
String,
ForeignKey(
"tracking_sources.code",
ondelete="SET NULL",
onupdate="CASCADE",
),
nullable=True,
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
@@ -80,6 +92,8 @@ class Tariff(DictLikeMixin, Base):
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
subgroup_title = Column(String, nullable=True)
sort_order = Column(Integer, nullable=True)
vless = Column(Boolean, default=False)
class Server(DictLikeMixin, Base):
@@ -96,6 +110,34 @@ class Server(DictLikeMixin, Base):
tariff_group = Column(String)
enabled = Column(Boolean, default=True)
subgroups = relationship("ServerSubgroup", back_populates="server", cascade="all, delete-orphan")
groups = relationship("ServerSpecialgroup", back_populates="server", cascade="all, delete-orphan")
class ServerSubgroup(DictLikeMixin, Base):
__tablename__ = "server_subgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
subgroup_title = Column(String, nullable=False)
server = relationship("Server", back_populates="subgroups")
__table_args__ = (UniqueConstraint("server_id", "subgroup_title", name="uq_server_subgroup"),)
class ServerSpecialgroup(DictLikeMixin, Base):
__tablename__ = "server_specialgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
server = relationship("Server")
__table_args__ = (UniqueConstraint("server_id", "group_code", name="uq_server_group"),)
class Payment(DictLikeMixin, Base):
__tablename__ = "payments"
@@ -106,6 +148,10 @@ class Payment(DictLikeMixin, Base):
payment_system = Column(String)
status = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
original_amount = Column(Numeric(18, 8), nullable=True)
currency = Column(String(10), nullable=False, server_default="RUB")
payment_id = Column(String(128), nullable=True, index=True)
metadata_ = Column("metadata", JSONB, nullable=True)
class Coupon(DictLikeMixin, Base):
@@ -123,7 +169,7 @@ class Coupon(DictLikeMixin, Base):
class CouponUsage(DictLikeMixin, Base):
__tablename__ = "coupon_usages"
coupon_id = Column(Integer, ForeignKey("coupons.id"), primary_key=True)
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), primary_key=True)
user_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
+33 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from database.models import Key, Notification, User
from logger import logger
@@ -39,7 +40,7 @@ async def delete_notification(session: AsyncSession, tg_id: int, notification_ty
)
)
await session.commit()
logger.info(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
logger.debug(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
async def check_notification_time(session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12) -> bool:
@@ -64,6 +65,37 @@ async def get_last_notification_time(session: AsyncSession, tg_id: int, notifica
return None
async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
try:
result = await session.execute(
select(Notification.notification_type, Notification.last_notification_time)
.where(Notification.tg_id == tg_id)
.where(Notification.notification_type.in_(["hot_lead_step_2", "hot_lead_step_3"]))
.order_by(Notification.last_notification_time.desc())
.limit(1)
)
row = result.first()
if not row:
return {"available": False}
notification_type, last_time = row
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
current_time = datetime.utcnow()
if current_time > expires_at:
return {"available": False}
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
return {"available": True, "type": notification_type, "tariff_group": tariff_group, "expires_at": expires_at}
except Exception as e:
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
return {"available": False}
async def check_notifications_bulk(
session: AsyncSession,
notification_type: str,
+142 -21
View File
@@ -1,7 +1,8 @@
from datetime import datetime
from datetime import datetime, timedelta
from typing import Optional
from pytz import timezone
from sqlalchemy import insert, select
from sqlalchemy import and_, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,33 +13,153 @@ from logger import logger
MOSCOW_TZ = timezone("Europe/Moscow")
async def add_payment(session: AsyncSession, tg_id: int, amount: float, payment_system: str):
async def add_payment(
session: AsyncSession,
tg_id: int,
amount: float,
payment_system: str,
*,
status: str = "success",
currency: str = "RUB",
payment_id: str | None = None,
metadata: dict | None = None,
original_amount: float | None = None,
) -> int:
try:
now_moscow = datetime.now(MOSCOW_TZ).replace(tzinfo=None)
stmt = insert(Payment).values(
tg_id=tg_id,
amount=amount,
payment_system=payment_system,
status="success",
created_at=now_moscow,
stmt = (
insert(Payment)
.values(
tg_id=tg_id,
amount=amount,
payment_system=payment_system,
status=status,
created_at=now_moscow,
currency=currency,
payment_id=payment_id,
metadata_=metadata,
original_amount=original_amount,
)
.returning(Payment.id)
)
await session.execute(stmt)
result = await session.execute(stmt)
internal_id = result.scalar_one()
await session.commit()
logger.info(f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}")
logger.info(
f"Добавлен платёж id={internal_id}: tg_id={tg_id}, amount={amount}, system={payment_system}, status={status}"
)
return internal_id
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении платежа: {e}")
await session.rollback()
logger.error(f"Ошибка при добавлении платежа: {e}")
raise
async def get_last_payments(session: AsyncSession, tg_id: int, limit: int = 3):
async def get_last_payments(
session: AsyncSession,
tg_id: int,
limit: int = 3,
statuses: list[str] | None = None,
):
query = select(Payment).where(Payment.tg_id == tg_id)
if statuses:
query = query.where(Payment.status.in_(statuses))
query = query.order_by(Payment.created_at.desc()).limit(limit)
result = await session.execute(query)
payments = result.scalars().all()
return [
{
"id": p.id,
"tg_id": p.tg_id,
"amount": p.amount,
"currency": p.currency,
"status": p.status,
"payment_system": p.payment_system,
"payment_id": p.payment_id,
"created_at": p.created_at,
"metadata": p.metadata_,
"original_amount": p.original_amount,
}
for p in payments
]
async def get_payment_by_id(session: AsyncSession, internal_id: int) -> dict | None:
try:
result = await session.execute(
select(Payment).where(Payment.tg_id == tg_id).order_by(Payment.created_at.desc()).limit(limit)
)
payments = result.scalars().all()
logger.info(f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}")
return [dict(p.__dict__) for p in payments]
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Найден платёж id={internal_id}")
return dict(payment.__dict__)
logger.info(f"Платёж id={internal_id} не найден")
return None
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении платежей пользователя {tg_id}: {e}")
return []
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
return None
async def update_payment_status(
session: AsyncSession,
internal_id: int,
new_status: str,
*,
payment_id: str | None = None,
metadata_patch: dict | None = None,
) -> bool:
try:
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
payment = result.scalar_one_or_none()
if not payment:
logger.info(f"Не удалось сменить статус: платёж id={internal_id} не найден")
return False
payment.status = new_status
if payment_id is not None:
payment.payment_id = payment_id
if metadata_patch:
base = payment.metadata_ or {}
base.update(metadata_patch)
payment.metadata_ = base
await session.commit()
logger.info(f"Статус платежа id={internal_id} изменён на {new_status}")
return True
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"Ошибка при смене статуса платежа id={internal_id}: {e}")
return False
async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | None:
try:
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Найден платёж payment_id={pid}")
return dict(payment.__dict__)
logger.info(f"Платёж payment_id={pid} не найден")
return None
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
return None
async def cancel_expired_pending_payments(session: AsyncSession) -> int:
cutoff = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(minutes=60)
stmt = (
update(Payment)
.where(
and_(
Payment.status.in_(("pending", "issued", "processing", "awaiting_choice")),
Payment.created_at < cutoff,
)
)
.values(status="cancelled")
.returning(Payment.id)
)
res = await session.execute(stmt)
await session.commit()
return len(res.fetchall())
+102 -6
View File
@@ -2,7 +2,7 @@ from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Server
from database.models import Key, Server, ServerSpecialgroup, ServerSubgroup, Tariff
from logger import logger
@@ -44,16 +44,40 @@ async def delete_server(session: AsyncSession, server_name: str):
async def get_servers(session: AsyncSession, include_enabled: bool = False) -> dict:
from handlers.utils import ALLOWED_GROUP_CODES
try:
stmt = select(Server)
result = await session.execute(stmt)
servers = result.scalars().all()
ids = [s.id for s in servers]
subs_map = {}
if ids:
r = await session.execute(
select(ServerSubgroup.server_id, ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(ids))
)
for sid, sg in r.all():
subs_map.setdefault(sid, []).append(sg)
groups_map = {}
if ids:
r2 = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
ServerSpecialgroup.server_id.in_(ids)
)
)
for sid, gc in r2.all():
groups_map.setdefault(sid, []).append(gc)
allowed = set(ALLOWED_GROUP_CODES)
grouped = {}
for s in servers:
if not include_enabled and not s.enabled:
continue
cluster = s.cluster_name
special = sorted({g for g in groups_map.get(s.id, []) if g in allowed})
grouped.setdefault(cluster, []).append({
"server_name": s.server_name,
"api_url": s.api_url,
@@ -63,9 +87,10 @@ async def get_servers(session: AsyncSession, include_enabled: bool = False) -> d
"enabled": s.enabled,
"max_keys": s.max_keys,
"tariff_group": s.tariff_group,
"tariff_subgroups": subs_map.get(s.id, []),
"special_groups": special,
"cluster_name": cluster,
})
return grouped
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении серверов: {e}")
@@ -203,17 +228,88 @@ async def update_server_cluster(session: AsyncSession, server_name: str, new_clu
result = await session.execute(stmt_new_cluster)
new_tariff_group = result.scalar_one_or_none()
stmt_update = (
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(cluster_name=new_cluster, tariff_group=new_tariff_group)
)
await session.execute(stmt_update)
await session.commit()
logger.info(f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы")
if server_data.get("id") is None:
rid = await session.execute(select(Server.id).where(Server.server_name == server_name).limit(1))
server_id = rid.scalar_one_or_none()
else:
server_id = server_data["id"]
if server_id is not None and new_tariff_group is not None:
await session.execute(
update(ServerSubgroup).where(ServerSubgroup.server_id == server_id).values(group_code=new_tariff_group)
)
await session.commit()
logger.info(
f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы и привязок подгрупп"
)
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении кластера сервера {server_name}: {e}")
await session.rollback()
return False
async def resolve_device_limit_from_group(session: AsyncSession, server_id: str) -> int | None:
r = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
group = r.scalar_one_or_none()
if not group:
return None
q = await session.execute(
select(Tariff.device_limit)
.where(Tariff.group_code == group, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
dl = q.scalar_one_or_none()
return int(dl) if dl is not None else None
async def filter_cluster_by_subgroup(
session: AsyncSession, cluster: list, target_subgroup: str, cluster_id: str
) -> list:
names = [s.get("server_name") for s in cluster if s.get("server_name")]
if not names:
return []
q_allowed = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
ServerSubgroup.subgroup_title == target_subgroup,
)
)
allowed = {n for (n,) in q_allowed.all()}
if allowed:
return [s for s in cluster if s.get("server_name") in allowed]
total_for_subgroup = await session.scalar(
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == target_subgroup)
)
if not total_for_subgroup:
logger.info(f"Для подгруппы {target_subgroup} нет ни одного сервера. Используем весь кластер {cluster_id}.")
return cluster
q_any = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
)
)
any_bound = {n for (n,) in q_any.all()}
if any_bound:
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}. Продление пропущено.")
return []
logger.info(f"В кластере {cluster_id} нет привязок подгрупп. Продлеваем по всему кластеру.")
return cluster
+12 -6
View File
@@ -34,11 +34,9 @@ async def count_active_keys(session: AsyncSession) -> int:
async def count_trial_keys(session: AsyncSession) -> int:
subquery_success_payments = (
select(Payment.tg_id).where(and_(Payment.tg_id == Key.tg_id, Payment.status == "success")).exists()
)
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
return await session.scalar(select(func.count()).select_from(Key).where(not_(subquery_success_payments)))
return await session.scalar(select(func.count()).select_from(Key).where(Key.tariff_id.in_(trial_tariffs_subquery)))
async def get_tariff_distribution(
@@ -89,7 +87,11 @@ async def count_total_referrals(session: AsyncSession) -> int:
async def sum_payments_since(session: AsyncSession, since: date) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(Payment.created_at >= since, Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
and_(
Payment.created_at >= since,
Payment.status == "success",
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
)
)
)
return round(float(result), 2)
@@ -101,6 +103,7 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
and_(
Payment.created_at >= start,
Payment.created_at < end,
Payment.status == "success",
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
)
)
@@ -111,7 +114,10 @@ async def sum_payments_between(session: AsyncSession, start: date, end: date) ->
async def sum_total_payments(session: AsyncSession) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
Payment.payment_system.notin_(["referral", "coupon", "cashback"])
and_(
Payment.status == "success",
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
)
)
)
return round(float(result), 2)
+134 -6
View File
@@ -1,8 +1,9 @@
import hashlib
from collections import defaultdict
from datetime import datetime
from sqlalchemy import delete, insert, select, update
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,16 +35,42 @@ async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group
return None
async def get_tariffs(session: AsyncSession, tariff_id: int = None, group_code: str = None):
async def get_tariffs(
session: AsyncSession, tariff_id: int = None, group_code: str = None, with_subgroup_weights: bool = False
):
try:
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
elif group_code:
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.sort_order, Tariff.id)
)
else:
result = await session.execute(select(Tariff))
result = await session.execute(select(Tariff).order_by(Tariff.sort_order, Tariff.id))
return [dict(r.__dict__) for r in result.scalars().all()]
tariffs = [dict(r.__dict__) for r in result.scalars().all()]
if with_subgroup_weights and group_code:
tariffs_without_order = [t for t in tariffs if t.get("sort_order") is None]
if tariffs_without_order:
for tariff in tariffs_without_order:
tariff["sort_order"] = 1
await session.execute(update(Tariff).where(Tariff.id == tariff["id"]).values(sort_order=1))
await session.commit()
grouped = defaultdict(list)
for t in tariffs:
grouped[t.get("subgroup_title")].append(t)
subgroup_weights = {}
for subgroup, tariffs_list in grouped.items():
if subgroup:
total_weight = sum(t.get("sort_order", 1) for t in tariffs_list)
subgroup_weights[subgroup] = total_weight
return {"tariffs": tariffs, "subgroup_weights": subgroup_weights}
return tariffs
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
return []
@@ -77,7 +104,9 @@ async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str):
group_code = row[0]
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True)).order_by(Tariff.id)
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.sort_order, Tariff.id)
)
return [dict(r.__dict__) for r in result.scalars().all()]
except SQLAlchemyError as e:
@@ -90,6 +119,21 @@ async def create_tariff(session: AsyncSession, data: dict):
data["created_at"] = datetime.utcnow()
data["updated_at"] = datetime.utcnow()
if "sort_order" not in data:
group_code = data.get("group_code")
if group_code:
result = await session.execute(
select(func.max(Tariff.sort_order)).where(
Tariff.group_code == group_code, Tariff.sort_order.isnot(None)
)
)
max_order = result.scalar() or 0
else:
result = await session.execute(select(func.max(Tariff.sort_order)).where(Tariff.sort_order.isnot(None)))
max_order = result.scalar() or 0
data["sort_order"] = max_order + 1
stmt = insert(Tariff).values(**data).returning(Tariff)
result = await session.execute(stmt)
await session.commit()
@@ -136,3 +180,87 @@ async def check_tariff_exists(session: AsyncSession, tariff_id: int):
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
return False
async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int:
try:
result = await session.execute(select(Tariff.sort_order).where(Tariff.id == tariff_id))
sort_order = result.scalar_one_or_none()
if sort_order is None:
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=1))
await session.commit()
return 1
return sort_order
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении sort_order для тарифа {tariff_id}: {e}")
return None
async def move_tariff_up(session: AsyncSession, tariff_id: int) -> bool:
try:
current_order = await get_tariff_sort_order(session, tariff_id)
new_order = max(1, current_order - 1)
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вверх: {e}")
await session.rollback()
return False
async def move_tariff_down(session: AsyncSession, tariff_id: int) -> bool:
try:
current_order = await get_tariff_sort_order(session, tariff_id)
new_order = current_order + 1
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вниз: {e}")
await session.rollback()
return False
async def initialize_tariff_sort_orders(session: AsyncSession, group_code: str) -> bool:
try:
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
tariffs = result.scalars().all()
if not tariffs:
return True
for i, tariff in enumerate(tariffs):
new_sort_order = 1 + i
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=new_sort_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при инициализации sort_order для группы {group_code}: {e}")
await session.rollback()
return False
async def initialize_all_tariff_weights(session: AsyncSession) -> bool:
try:
result = await session.execute(select(Tariff).where(Tariff.sort_order.is_(None)))
tariffs_without_weight = result.scalars().all()
if not tariffs_without_weight:
return True
for tariff in tariffs_without_weight:
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=1))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при инициализации весов тарифов: {e}")
await session.rollback()
return False
+124 -11
View File
@@ -1,4 +1,4 @@
from sqlalchemy import func, insert, not_, select
from sqlalchemy import and_, func, insert, not_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,6 +6,9 @@ from database.models import Payment, TrackingSource, User
from logger import logger
EXCLUDED_PAYMENT_MARKERS = ["coupon", "referral", "cashback"]
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
try:
stmt = insert(TrackingSource).values(
@@ -70,12 +73,17 @@ async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict | None:
source_result = await session.execute(select(TrackingSource.created_at).where(TrackingSource.code == code))
created_at_row = source_result.first()
if not created_at_row:
def _month_key(dt) -> str:
return dt.strftime("%Y-%m")
src_row = await session.execute(
select(TrackingSource.name, TrackingSource.code, TrackingSource.created_at).where(TrackingSource.code == code)
)
src = src_row.first()
if not src:
return None
created_at = created_at_row[0]
_src_name, _src_code, created_at = src
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
@@ -95,25 +103,25 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
amount_subq = (
select(func.coalesce(func.sum(Payment.amount), 0))
select(func.coalesce(func.sum(Payment.amount), 0.0))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
query = select(
header_q = select(
TrackingSource.name,
TrackingSource.code,
TrackingSource.created_at,
@@ -123,11 +131,115 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
amount_subq.label("total_amount"),
).where(TrackingSource.code == code)
result = await session.execute(query)
row = result.first()
header_res = await session.execute(header_q)
row = header_res.first()
if not row:
return None
payments_base = (
select(
Payment.tg_id.label("tg_id"),
Payment.amount.label("amount"),
Payment.created_at.label("dt"),
)
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.subquery()
)
first_pay = (
select(
payments_base.c.tg_id.label("tg_id"),
func.min(payments_base.c.dt).label("first_dt"),
)
.group_by(payments_base.c.tg_id)
.subquery()
)
month_expr_new = func.date_trunc("month", payments_base.c.dt).label("month")
new_rows = await session.execute(
select(
month_expr_new,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(
first_pay,
and_(
payments_base.c.tg_id == first_pay.c.tg_id,
payments_base.c.dt == first_pay.c.first_dt,
),
)
.group_by(month_expr_new)
.order_by(month_expr_new)
)
new_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in new_rows.all()}
month_expr_rep = func.date_trunc("month", payments_base.c.dt).label("month")
repeat_rows = await session.execute(
select(
month_expr_rep,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(first_pay, payments_base.c.tg_id == first_pay.c.tg_id)
.where(payments_base.c.dt > first_pay.c.first_dt)
.group_by(month_expr_rep)
.order_by(month_expr_rep)
)
repeat_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in repeat_rows.all()}
month_expr_regs = func.date_trunc("month", User.created_at).label("month")
regs_rows = await session.execute(
select(
month_expr_regs,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where((User.source_code == code) & (User.created_at >= created_at))
.group_by(month_expr_regs)
.order_by(month_expr_regs)
)
regs_by_month = {r.month: int(r.cnt) for r in regs_rows.all()}
month_expr_trials = func.date_trunc("month", User.created_at).label("month")
trials_rows = await session.execute(
select(
month_expr_trials,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
.group_by(month_expr_trials)
.order_by(month_expr_trials)
)
trials_by_month = {r.month: int(r.cnt) for r in trials_rows.all()}
months = set()
months.update(regs_by_month.keys())
months.update(trials_by_month.keys())
months.update(new_by_month.keys())
months.update(repeat_by_month.keys())
monthly = []
for m in sorted(months):
regs = regs_by_month.get(m, 0)
trls = trials_by_month.get(m, 0)
new_cnt, new_amt = new_by_month.get(m, (0, 0.0))
rep_cnt, rep_amt = repeat_by_month.get(m, (0, 0.0))
monthly.append({
"month": _month_key(m),
"registrations": regs,
"trials": trls,
"new_purchases_count": new_cnt,
"new_purchases_amount": new_amt,
"repeat_purchases_count": rep_cnt,
"repeat_purchases_amount": rep_amt,
})
return {
"name": row.name,
"code": row.code,
@@ -136,4 +248,5 @@ async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict |
"trials": row.trials or 0,
"payments": row.payments or 0,
"total_amount": float(row.total_amount or 0),
"monthly": monthly,
}
+61 -28
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import delete, exists, or_, select, update
from sqlalchemy import delete, exists, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +11,7 @@ from database.models import (
CouponUsage,
Gift,
GiftUsage,
Key,
Notification,
Payment,
Referral,
@@ -44,7 +45,6 @@ async def add_user(
)
.on_conflict_do_nothing(index_elements=[User.tg_id])
)
await session.execute(stmt)
await session.commit()
logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})")
@@ -56,12 +56,19 @@ async def add_user(
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
try:
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
current = result.scalar_one_or_none() or 0
new_balance = current + amount
await session.execute(update(User).where(User.tg_id == tg_id).values(balance=new_balance))
res = await session.execute(
update(User)
.where(User.tg_id == tg_id)
.values(balance=func.coalesce(User.balance, 0) + amount)
.returning(User.balance)
)
new_balance = res.scalar_one_or_none()
await session.commit()
logger.info(f"[DB] Баланс пользователя {tg_id} обновлён: {current}{new_balance}")
if new_balance is not None:
old_balance = new_balance - amount
logger.info(f"[DB] Баланс пользователя {tg_id} обновлён: {old_balance}{new_balance}")
else:
logger.info(f"[DB] Баланс пользователя {tg_id} не изменён: пользователь не найден")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
await session.rollback()
@@ -74,9 +81,8 @@ async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
async def get_balance(session: AsyncSession, tg_id: int) -> float:
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
balance = result.scalar_one_or_none()
return round(balance, 1) if balance is not None else 0.0
result = await session.execute(select(func.coalesce(User.balance, 0.0)).where(User.tg_id == tg_id))
return round(float(result.scalar_one()), 1)
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
@@ -99,9 +105,8 @@ async def update_trial(session: AsyncSession, tg_id: int, status: int):
async def get_trial(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(select(User.trial).where(User.tg_id == tg_id))
trial = result.scalar_one_or_none()
return trial or 0
result = await session.execute(select(func.coalesce(User.trial, 0)).where(User.tg_id == tg_id))
return int(result.scalar_one())
async def upsert_user(
@@ -120,7 +125,6 @@ async def upsert_user(
user = result.scalar_one_or_none()
if not user:
return None
await session.execute(
update(User)
.where(User.tg_id == tg_id)
@@ -133,8 +137,11 @@ async def upsert_user(
updated_at=datetime.utcnow(),
)
)
await session.commit()
result = await session.execute(select(User).where(User.tg_id == tg_id))
return dict(result.scalar_one().__dict__)
else:
await session.execute(
res = await session.execute(
insert(User)
.values(
tg_id=tg_id,
@@ -157,10 +164,13 @@ async def upsert_user(
"updated_at": datetime.utcnow(),
},
)
.returning(User)
)
await session.commit()
result = await session.execute(select(User).where(User.tg_id == tg_id))
return dict(result.scalar_one().__dict__)
obj = res.scalar_one()
await session.commit()
d = obj.__dict__.copy()
d.pop("_sa_instance_state", None)
return d
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
await session.rollback()
@@ -170,16 +180,11 @@ async def upsert_user(
async def delete_user_data(session: AsyncSession, tg_id: int):
try:
await session.execute(delete(Notification).where(Notification.tg_id == tg_id))
result = await session.execute(select(Gift.gift_id).where(Gift.sender_tg_id == tg_id))
gift_ids = [row[0] for row in result.all()]
if gift_ids:
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
await session.execute(
delete(GiftUsage).where(GiftUsage.gift_id.in_(select(Gift.gift_id).where(Gift.sender_tg_id == tg_id)))
)
await session.execute(delete(Gift).where(Gift.sender_tg_id == tg_id))
await session.execute(update(Gift).where(Gift.recipient_tg_id == tg_id).values(recipient_tg_id=None))
await session.execute(delete(Payment).where(Payment.tg_id == tg_id))
await session.execute(
delete(Referral).where(or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id))
@@ -189,10 +194,8 @@ async def delete_user_data(session: AsyncSession, tg_id: int):
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
await session.execute(delete(User).where(User.tg_id == tg_id))
await session.commit()
logger.info(f"[DB] Данные пользователя {tg_id} полностью удалены")
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
@@ -202,3 +205,33 @@ async def delete_user_data(session: AsyncSession, tg_id: int):
async def mark_trial_extended(tg_id: int, session: AsyncSession):
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
await session.commit()
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
res = await session.execute(
select(func.coalesce(User.trial, 0), func.count(Key.client_id))
.select_from(User)
.join(Key, Key.tg_id == User.tg_id, isouter=True)
.where(User.tg_id == tg_id)
.group_by(User.tg_id, User.trial)
)
row = res.first()
if row is None:
return None
return int(row[0]), int(row[1])
async def upsert_source_if_empty(session: AsyncSession, tg_id: int, source_code: str) -> None:
if not source_code:
return
stmt = (
insert(User)
.values(tg_id=tg_id, source_code=source_code)
.on_conflict_do_update(
index_elements=[User.tg_id],
set_={"source_code": insert(User).excluded.source_code},
where=(User.source_code.is_(None)),
)
)
await session.execute(stmt)
await session.commit()
-2
View File
@@ -9,7 +9,6 @@ from .donate import router as donate_router
from .instructions import router as instructions_router
from .keys import router as keys_router
from .notifications import router as notifications_router
from .pay import router as pay_router
from .payments import router as payments_router
from .profile import router as profile_router
from .refferal import router as refferal_router
@@ -22,7 +21,6 @@ router.include_routers(
start_router,
captcha_router,
profile_router,
pay_router,
donate_router,
coupons_router,
notifications_router,
+2
View File
@@ -9,6 +9,7 @@ from .clusters import router as clusters_router
from .coupons import router as coupons_router
from .gifts import router as gifts_router
from .management import router as management_router
from .module import router as modules_router
from .panel import router as panel_router
from .restart import router as restart_router
from .sender import router as sender_router
@@ -21,6 +22,7 @@ from .users import router as users_router
router = Router(name="admins_main_router")
router.include_routers(
modules_router,
panel_router,
management_router,
servers_router,
+1 -1
View File
@@ -1,8 +1,8 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from backup import backup_database
from filters.admin import IsAdminFilter
from utils.backup import backup_database
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
+3 -1
View File
@@ -147,9 +147,11 @@ async def handle_delete_manual_banned(callback_query: CallbackQuery, session: As
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter())
async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids)
await callback.message.edit_text(
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
"Пример:\n<code>123456789\n987654321</code>"
"Пример:\n<code>123456789\n987654321</code>",
reply_markup=build_admin_back_kb("bans"),
)
+1 -2
View File
@@ -1,7 +1,6 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback
def build_bans_kb():
+663 -77
View File
@@ -8,39 +8,47 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from py3xui import AsyncApi
from sqlalchemy import delete, func, select, update
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backup import create_backup_and_send_to_admins
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
HAPP_CRYPTOLINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
USE_COUNTRY_SELECTION,
)
from database import check_unique_server_name, get_servers, update_key_expiry
from database.models import Key, Server, Tariff
from database.models import Key, Server, ServerSpecialgroup, ServerSubgroup, Tariff
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
from handlers.keys.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
renew_key_in_cluster,
)
from handlers.keys.operations.aggregated_links import make_aggregated_link
from handlers.utils import ALLOWED_GROUP_CODES
from logger import logger
from panels.remnawave import RemnawaveAPI
from utils.backup import create_backup_and_send_to_admins
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import (
AdminClusterCallback,
AdminServerCallback,
build_attach_tariff_kb,
build_cluster_management_kb,
build_clusters_editor_kb,
build_manage_cluster_kb,
build_panel_type_kb,
build_select_group_servers_kb,
build_select_subgroup_servers_kb,
build_sync_cluster_kb,
build_tariff_group_selection_for_servers_kb,
build_tariff_group_selection_kb,
build_tariff_subgroup_selection_kb,
)
@@ -208,8 +216,8 @@ async def handle_subscription_url_input(message: Message, state: FSMContext):
await state.update_data(subscription_url=subscription_url)
await message.answer(
text=f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
f"Для Remnawave это UUID Инбаунда, для 3x-ui — просто ID (например, <code>1</code>).",
text=f"<b>Введите inbound_id/Squads для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
f"Для Remnawave это UUID Squads, для 3x-ui — просто ID (например, <code>1</code>).",
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
@@ -320,8 +328,24 @@ async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession)
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
allowed = set(ALLOWED_GROUP_CODES)
lines = []
for s in cluster_servers:
subs = s.get("tariff_subgroups") or []
subs_str = ", ".join(sorted(subs)) if subs else ""
grps = s.get("special_groups") or []
grps = [g for g in grps if g in allowed]
grps_str = ", ".join(sorted(grps)) if grps else ""
lines.append(f"{s.get('server_name', '?')}{subs_str} | {grps_str}")
details = "\n".join(lines) if lines else "нет серверов"
await callback.message.edit_text(
text=f"<b>📡 Серверы в кластере {cluster_name}</b>",
text=(
f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы | спецгруппы:</i>\n<blockquote>{details}</blockquote>"
),
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@@ -393,21 +417,23 @@ async def handle_cluster_availability(
total_online_users += online_remna_users
nodes_info = nodes_data["nodes"]
if len(nodes_info) > 1:
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
for node_info in nodes_info:
country_code = node_info.get("country_code", "Unknown")
node_name = node_info.get("name", "Unknown")
online_users = node_info.get("online_users", 0)
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
seen = set()
for node_info in nodes_info:
node_name = node_info.get("name", "Unknown")
if node_name in seen:
continue
seen.add(node_name)
if country_code != "Unknown" and len(country_code) == 2:
flag = "".join(chr(ord(c) + 127397) for c in country_code.upper())
else:
flag = country_code
country_code = node_info.get("country_code", "Unknown")
online_users = node_info.get("online_users", 0)
result_text += f"{flag} ({node_name}): {online_users} онлайн\n"
else:
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
flag = (
"".join(chr(ord(c) + 127397) for c in country_code.upper())
if country_code != "Unknown" and len(country_code) == 2
else country_code
)
result_text += f"{flag} ({node_name}): {online_users} онлайн\n"
except Exception as e:
error_text = str(e) or "Сервер недоступен"
@@ -488,6 +514,7 @@ async def handle_sync_server(
Key.email,
Key.expiry_time,
Key.tariff_id,
Key.remnawave_link,
)
.join(Key, Server.cluster_name == Key.server_id)
.where(Server.server_name == server_name)
@@ -510,25 +537,129 @@ async def handle_sync_server(
for key in keys_to_sync:
try:
if key["panel_type"] == "remnawave":
continue
tariff = None
if key["tariff_id"]:
tariff = await session.get(Tariff, key["tariff_id"])
if tariff:
servers = await get_servers(session)
server_info = None
for cluster_servers in servers.values():
for s in cluster_servers:
if s.get("server_name") == server_name:
server_info = s
break
if server_info:
break
if server_info:
if tariff.subgroup_title and tariff.subgroup_title not in server_info.get("tariff_subgroups", []):
continue
await create_client_on_server(
{
"api_url": key["api_url"],
"inbound_id": key["inbound_id"],
"server_name": key["server_name"],
},
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
semaphore,
plan=key["tariff_id"],
session=session,
)
if tariff.group_code and tariff.group_code.lower() in ALLOWED_GROUP_CODES:
if tariff.group_code.lower() not in server_info.get("special_groups", []):
continue
expire_iso = (
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
)
remna = RemnawaveAPI(key["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"Не удалось авторизоваться в Remnawave для сервера {server_name}")
continue
traffic_limit_bytes = 0
hwid_limit = 0
if tariff:
if tariff.traffic_limit is not None:
traffic_limit_bytes = int(tariff.traffic_limit * 1024**3)
hwid_limit = tariff.device_limit
success = await remna.update_user(
uuid=key["client_id"],
expire_at=expire_iso,
telegram_id=key["tg_id"],
email=f"{key['email']}@fake.local",
active_user_inbounds=[key["inbound_id"]],
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_limit,
)
if success:
try:
sub = await remna.get_subscription_by_username(key["email"])
if sub:
new_remnawave_link = sub.get("subscriptionUrl")
if HAPP_CRYPTOLINK:
happ = sub.get("happ") or {}
new_remnawave_link = happ.get("cryptoLink") or happ.get("link") or new_remnawave_link
if new_remnawave_link:
server_result = await session.execute(
select(Server.cluster_name).where(Server.server_name == server_name)
)
cluster_name = server_result.scalar()
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
key_value = await make_aggregated_link(
session=session,
cluster_all=cluster_servers,
cluster_id=cluster_name,
email=key["email"],
client_id=key["client_id"],
tg_id=key["tg_id"],
remna_link_override=new_remnawave_link,
plan=key["tariff_id"],
)
await session.execute(
update(Key)
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
.values(
remnawave_link=new_remnawave_link,
key=key_value
)
)
await session.commit()
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
except Exception as e:
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
if not success:
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
await delete_key_from_cluster(server_name, key["email"], key["client_id"], session)
await create_key_on_cluster(
cluster_id=server_name,
tg_id=key["tg_id"],
client_id=key["client_id"],
email=key["email"],
expiry_timestamp=key["expiry_time"],
plan=key["tariff_id"],
session=session,
remnawave_link=key["remnawave_link"],
)
else:
await create_client_on_server(
{
"api_url": key["api_url"],
"inbound_id": key["inbound_id"],
"server_name": key["server_name"],
},
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
semaphore,
plan=key["tariff_id"],
session=session,
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}")
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в сервер {server_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
@@ -591,6 +722,7 @@ async def handle_sync_cluster(
traffic_limit_bytes = 0
hwid_limit = 0
subgroup_title = None
if key["tariff_id"]:
tariff = await session.get(Tariff, key["tariff_id"])
if tariff:
@@ -599,12 +731,37 @@ async def handle_sync_cluster(
else:
traffic_limit_bytes = 0
hwid_limit = tariff.device_limit
subgroup_title = tariff.subgroup_title
else:
logger.warning(
f"[Sync] Ключ {key['client_id']} с несуществующим тарифом ID={key['tariff_id']} — обновим без лимитов"
)
inbound_ids = [s["inbound_id"] for s in cluster_servers if s.get("inbound_id")]
filtered_servers = cluster_servers
if subgroup_title:
filtered_servers = [
s for s in cluster_servers if subgroup_title in s.get("tariff_subgroups", [])
]
if not filtered_servers:
logger.warning(
f"[Sync] В кластере {cluster_name} не найдено серверов для подгруппы '{subgroup_title}'. Использую весь кластер."
)
filtered_servers = cluster_servers
if tariff and tariff.group_code:
group_code = tariff.group_code.lower()
if group_code in ALLOWED_GROUP_CODES:
special_filtered = [
s for s in filtered_servers if group_code in (s.get("special_groups") or [])
]
if special_filtered:
filtered_servers = special_filtered
else:
logger.warning(
f"[Sync] В кластере {cluster_name} нет серверов со спецгруппой '{group_code}'. Использую весь кластер."
)
inbound_ids = [s["inbound_id"] for s in filtered_servers if s.get("inbound_id")]
success = await remna.update_user(
uuid=key["client_id"],
@@ -616,6 +773,43 @@ async def handle_sync_cluster(
hwid_device_limit=hwid_limit,
)
if success:
try:
sub = await remna.get_subscription_by_username(key["email"])
if sub:
new_remnawave_link = sub.get("subscriptionUrl")
if HAPP_CRYPTOLINK:
happ = sub.get("happ") or {}
new_remnawave_link = happ.get("cryptoLink") or happ.get("link") or new_remnawave_link
if new_remnawave_link:
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
key_value = await make_aggregated_link(
session=session,
cluster_all=cluster_servers,
cluster_id=cluster_name,
email=key["email"],
client_id=key["client_id"],
tg_id=key["tg_id"],
remna_link_override=new_remnawave_link,
plan=key["tariff_id"],
)
await session.execute(
update(Key)
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
.values(
remnawave_link=new_remnawave_link,
key=key_value
)
)
await session.commit()
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
except Exception as e:
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
if not success:
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
@@ -721,45 +915,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
cluster_name = user_data.get("cluster_name")
add_ms = days * 86400 * 1000
result = await session.execute(
select(Server.tariff_group)
.where(Server.cluster_name == cluster_name)
.where(Server.tariff_group.isnot(None))
.limit(1)
)
row = result.first()
if not row or not row[0]:
result = await session.execute(
select(Server.tariff_group)
.where(Server.server_name == cluster_name)
.where(Server.tariff_group.isnot(None))
.limit(1)
)
row = result.first()
if not row or not row[0]:
await message.answer("❌ Не удалось определить тарифную группу для этого кластера или сервера.")
await state.clear()
return
group_code = row[0]
result = await session.execute(
select(Tariff)
.where(
Tariff.group_code == group_code,
Tariff.is_active.is_(True),
Tariff.duration_days >= days,
)
.order_by(Tariff.duration_days.asc())
.limit(1)
)
tariff = result.scalars().first()
if not tariff:
await message.answer("❌ Нет активных тарифов, подходящих по сроку.")
await state.clear()
return
total_gb = tariff.traffic_limit or 0
logger.info(f"[Cluster Extend] Добавляем {days} дней для кластера: {cluster_name}")
server_stmt = select(Server.server_name).where(Server.cluster_name == cluster_name)
server_rows = await session.execute(server_stmt)
@@ -776,14 +932,33 @@ async def handle_days_input(message: Message, state: FSMContext, session: AsyncS
for key in keys:
new_expiry = key.expiry_time + add_ms
traffic_limit = 0
device_limit = 0
key_subgroup = None
if key.tariff_id:
result = await session.execute(
select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where(
Tariff.id == key.tariff_id, Tariff.is_active.is_(True)
)
)
tariff = result.first()
if tariff:
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
device_limit = int(tariff[1]) if tariff[1] is not None else 0
key_subgroup = tariff[2]
await renew_key_in_cluster(
cluster_name,
email=key.email,
client_id=key.client_id,
new_expiry_time=new_expiry,
total_gb=total_gb,
total_gb=traffic_limit,
session=session,
hwid_device_limit=device_limit,
reset_traffic=False,
target_subgroup=key_subgroup,
old_subgroup=key_subgroup,
)
await update_key_expiry(session, key.client_id, new_expiry)
@@ -1128,3 +1303,414 @@ async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminCluste
except Exception as e:
logger.error(f"Ошибка при применении тарифной группы: {e}")
await callback.message.edit_text("❌ Произошла ошибка при установке тарифной группы.")
@router.callback_query(AdminClusterCallback.filter(F.action == "set_subgroup"))
async def show_servers_for_subgroup(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
data = await state.get_data()
selected = set(data.get(f"subgrp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения подгруппы тарифов:</b>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_subgroup"))
async def toggle_server_for_subgroup(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
if i < 0 or i >= len(names):
await callback.answer("Сервер не найден", show_alert=True)
return
server_name = names[i]
key = f"subgrp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if server_name in selected:
selected.remove(server_name)
else:
selected.add(server_name)
await state.update_data({key: list(selected)})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения подгруппы тарифов:</b>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_subgroup_selection"))
async def reset_subgroup_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await state.update_data({f"subgrp_sel:{cluster_name}": []})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения подгруппы тарифов:</b>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, set()),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_subgroup"))
async def choose_subgroup(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
key = f"subgrp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
return
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
group_codes = [r[0] for r in res.fetchall() if r[0]]
if not group_codes:
await callback.answer("Сначала установите тарифную группу для этого кластера", show_alert=True)
return
group_code = group_codes[0]
res2 = await session.execute(
select(func.distinct(Tariff.subgroup_title))
.where(Tariff.group_code == group_code)
.where(Tariff.subgroup_title.isnot(None))
.order_by(Tariff.subgroup_title.asc())
)
subgroups = [r[0] for r in res2.fetchall()]
if not subgroups:
await callback.message.edit_text("❌ Для этой группы нет доступных подгрупп.")
return
await callback.message.edit_text(
f"<b>📚 Выберите подгруппу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
reply_markup=build_tariff_subgroup_selection_kb(cluster_name, subgroups),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_subgroup"))
async def apply_tariff_subgroup(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
try:
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
group_codes = [r[0] for r in res.fetchall() if r[0]]
if not group_codes:
await callback.answer("Не найдена тарифная группа кластера", show_alert=True)
return
group_code = group_codes[0]
res2 = await session.execute(
select(func.distinct(Tariff.subgroup_title))
.where(Tariff.group_code == group_code)
.where(Tariff.subgroup_title.isnot(None))
.order_by(Tariff.subgroup_title.asc())
)
subgroups = [r[0] for r in res2.fetchall()]
if i < 0 or i >= len(subgroups):
await callback.answer("Подгруппа не найдена", show_alert=True)
return
subgroup_title = subgroups[i]
key = f"subgrp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.message.edit_text("❌ Не выбраны серверы для назначения подгруппы.")
return
servers_q = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
id_by_name = {name: sid for sid, name in servers_q.fetchall()}
missing_ids = [id_by_name[n] for n in selected if n in id_by_name]
if not missing_ids:
await callback.answer("Серверы не найдены", show_alert=True)
return
existing_q = await session.execute(
select(ServerSubgroup.server_id)
.where(ServerSubgroup.server_id.in_(missing_ids))
.where(ServerSubgroup.subgroup_title == subgroup_title)
)
already = {r[0] for r in existing_q.fetchall()}
to_insert = [sid for sid in missing_ids if sid not in already]
if to_insert:
session.add_all([
ServerSubgroup(server_id=sid, group_code=group_code, subgroup_title=subgroup_title) for sid in to_insert
])
await session.commit()
await state.update_data({key: []})
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка при применении подгруппы тарифов: {e}")
await callback.message.edit_text("❌ Произошла ошибка при назначении подгруппы.")
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_subgroups"))
async def reset_cluster_subgroups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name = callback_data.data
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
server_ids = [row[0] for row in res.fetchall()]
if not server_ids:
await callback.answer("В кластере нет серверов", show_alert=True)
return
await session.execute(delete(ServerSubgroup).where(ServerSubgroup.server_id.in_(server_ids)))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Все подгруппы тарифов сброшены для кластера <b>{cluster_name}</b>.",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при сбросе подгрупп для кластера {cluster_name}: {e}")
await callback.message.edit_text("❌ Не удалось сбросить подгруппы.")
def render_attach_tariff_menu_text(cluster_name: str, cluster_servers: list[dict]) -> str:
sub_map: dict[str, list[str]] = {}
for s in cluster_servers:
for sg in s.get("tariff_subgroups") or []:
sub_map.setdefault(sg, []).append(s["server_name"])
allowed = tuple(ALLOWED_GROUP_CODES)
spec_map: dict[str, list[str]] = {k: [] for k in allowed}
for s in cluster_servers:
for g in s.get("special_groups") or []:
if g in spec_map:
spec_map[g].append(s["server_name"])
lines = [f"<b>🧩 Привязки тарифов • {cluster_name}</b>"]
lines.append("<b>Подгруппы:</b>")
if sub_map:
subs_lines = []
for k in sorted(sub_map):
servers_list = ", ".join(sorted(set(sub_map[k])))
subs_lines.append(f"• <b>{k}</b>: {servers_list}")
lines.append("<blockquote>\n" + "\n".join(subs_lines) + "\n</blockquote>")
else:
lines.append("<blockquote>— нет привязок</blockquote>")
lines.append("<b>Спецгруппы:</b>")
has_spec = any(spec_map[k] for k in allowed)
if has_spec:
spec_lines = []
for k in allowed:
vals = sorted(set(spec_map[k]))
spec_lines.append(f"• <b>{k}</b>: {', '.join(vals) if vals else ''}")
lines.append("<blockquote>\n" + "\n".join(spec_lines) + "\n</blockquote>")
else:
lines.append("<blockquote>— нет привязок</blockquote>")
return "\n".join(lines)
@router.callback_query(AdminClusterCallback.filter(F.action == "attach_tariff_menu"), IsAdminFilter())
async def handle_attach_tariff_menu(callback: CallbackQuery, session: AsyncSession):
packed = AdminClusterCallback.unpack(callback.data)
cluster_name = packed.data
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
@router.callback_query(AdminClusterCallback.filter(F.action == "set_group"))
async def show_servers_for_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
data = await state.get_data()
selected = set(data.get(f"grp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_group"))
async def toggle_server_for_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
if i < 0 or i >= len(names):
await callback.answer("Сервер не найден", show_alert=True)
return
server_name = names[i]
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if server_name in selected:
selected.remove(server_name)
else:
selected.add(server_name)
await state.update_data({key: list(selected)})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_group_selection"))
async def reset_group_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await state.update_data({f"grp_sel:{cluster_name}": []})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, set()),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_group"))
async def choose_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
return
groups = [(i, code) for i, code in enumerate(ALLOWED_GROUP_CODES)]
await callback.message.edit_text(
f"<b>📚 Выберите группу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
reply_markup=build_tariff_group_selection_for_servers_kb(cluster_name, groups),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_group_to_servers"))
async def apply_group_to_servers(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
try:
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
groups = ALLOWED_GROUP_CODES
if i < 0 or i >= len(groups):
await callback.answer("Группа не найдена", show_alert=True)
return
group_code = groups[i]
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.message.edit_text("❌ Не выбраны серверы для назначения группы.")
return
rows = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
id_by_name = {name: sid for sid, name in rows.fetchall()}
server_ids = [id_by_name[n] for n in selected if n in id_by_name]
if not server_ids:
await callback.answer("Серверы не найдены", show_alert=True)
return
exist_rows = await session.execute(
select(ServerSpecialgroup.server_id).where(
and_(ServerSpecialgroup.server_id.in_(server_ids), ServerSpecialgroup.group_code == group_code)
)
)
already = {r[0] for r in exist_rows.fetchall()}
to_insert = [sid for sid in server_ids if sid not in already]
if to_insert:
session.add_all([ServerSpecialgroup(server_id=sid, group_code=group_code) for sid in to_insert])
await session.commit()
logger.debug(f"[apply_group_to_servers] group={group_code} server_ids={server_ids}")
await state.update_data({key: []})
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка при назначении группы тарифов: {e}")
await callback.message.edit_text("❌ Произошла ошибка при назначении группы.")
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_groups"))
async def reset_cluster_groups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name = callback_data.data
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
server_ids = [row[0] for row in res.fetchall()]
if not server_ids:
await callback.answer("В кластере нет серверов", show_alert=True)
return
await session.execute(delete(ServerSpecialgroup).where(ServerSpecialgroup.server_id.in_(server_ids)))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Все привязки групп сброшены для кластера <b>{cluster_name}</b>.",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при сбросе групп для кластера {cluster_name}: {e}")
await callback.message.edit_text("❌ Не удалось сбросить привязки групп.")
+160 -1
View File
@@ -57,7 +57,12 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
@@ -68,6 +73,98 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
return builder.as_markup()
def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🗂 Спецгруппы",
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
),
InlineKeyboardButton(
text="🧹 Сбросить",
callback_data=AdminClusterCallback(action="reset_cluster_groups", data=cluster_name).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🗂 Подгруппы",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
),
InlineKeyboardButton(
text="🧹 Сбросить",
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_select_subgroup_servers_kb(
cluster_name: str, cluster_servers: list, selected: set[str]
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
for i, name in enumerate(names):
mark = "" if name in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {name}",
callback_data=AdminClusterCallback(action="toggle_server_subgroup", data=f"{cluster_name}|{i}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📚 Выбрать подгруппу",
callback_data=AdminClusterCallback(action="choose_subgroup", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Сбросить выбор",
callback_data=AdminClusterCallback(action="reset_subgroup_selection", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_tariff_subgroup_selection_kb(cluster_name: str, subgroups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for i, title in enumerate(subgroups):
builder.button(
text=title,
callback_data=AdminClusterCallback(action="apply_tariff_subgroup", data=f"{cluster_name}|{i}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад к выбору серверов",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
@@ -167,3 +264,65 @@ def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, s
)
builder.adjust(2, 1)
return builder.as_markup()
def build_select_group_servers_kb(cluster_name: str, cluster_servers: list, selected: set[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
for i, name in enumerate(names):
mark = "" if name in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {name}",
callback_data=AdminClusterCallback(action="toggle_server_group", data=f"{cluster_name}|{i}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📚 Выбрать спецгруппу",
callback_data=AdminClusterCallback(action="choose_group", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Сбросить выбор",
callback_data=AdminClusterCallback(action="reset_group_selection", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_tariff_group_selection_for_servers_kb(
cluster_name: str, groups: list[tuple[int, str]]
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(
action="apply_group_to_servers", data=f"{cluster_name}|{group_id}"
).pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
-2
View File
@@ -1,5 +1,3 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
+25 -27
View File
@@ -1,3 +1,5 @@
from collections import defaultdict
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
@@ -7,7 +9,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Gift, GiftUsage, Tariff
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.utils import edit_or_send_message, format_days, format_months
from logger import logger
@@ -32,9 +34,9 @@ async def admin_gift_menu(callback: CallbackQuery):
@router.callback_query(F.data == "admin_gift_create")
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
stmt = select(Tariff).where(Tariff.group_code == "gifts", Tariff.is_active.is_(True)).order_by(Tariff.duration_days)
result = await session.execute(stmt)
tariffs = result.scalars().all()
tariffs_data = await get_tariffs(session, group_code="gifts", with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"]
if not tariffs:
builder = InlineKeyboardBuilder()
@@ -42,23 +44,27 @@ async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
return
from collections import defaultdict
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.subgroup_title].append(t)
grouped_tariffs[t.get("subgroup_title")].append(t)
builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []):
if t.duration_days % 30 == 0:
duration_text = format_months(t.duration_days // 30)
if t.get("duration_days") % 30 == 0:
duration_text = format_months(t.get("duration_days") // 30)
else:
duration_text = format_days(t.duration_days)
duration_text = format_days(t.get("duration_days"))
builder.button(text=f"{t.name} {duration_text}", callback_data=f"admin_gift_select|{t.id}")
builder.row(
types.InlineKeyboardButton(
text=f"{t.get('name')} {duration_text}", callback_data=f"admin_gift_select|{t.get('id')}"
)
)
for subgroup in sorted(k for k in grouped_tariffs if k):
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
for subgroup in sorted_subgroups:
subgroup_hash = create_subgroup_hash(subgroup, "gifts")
builder.row(
types.InlineKeyboardButton(
@@ -82,30 +88,23 @@ async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session:
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
stmt = (
select(Tariff)
.where(Tariff.group_code == "gifts", Tariff.is_active.is_(True))
.order_by(Tariff.duration_days)
)
result = await session.execute(stmt)
tariffs = result.scalars().all()
filtered = [t for t in tariffs if t.subgroup_title == subgroup]
tariffs = await get_tariffs(session, group_code="gifts")
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
if not filtered:
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
return
builder = InlineKeyboardBuilder()
for t in filtered:
if t.duration_days % 30 == 0:
duration_text = format_months(t.duration_days // 30)
if t.get("duration_days") % 30 == 0:
duration_text = format_months(t.get("duration_days") // 30)
else:
duration_text = format_days(t.duration_days)
duration_text = format_days(t.get("duration_days"))
builder.row(
types.InlineKeyboardButton(
text=f"{t.name} {duration_text}",
callback_data=f"admin_gift_select|{t.id}",
text=f"{t.get('name')} {duration_text}",
callback_data=f"admin_gift_select|{t.get('id')}",
)
)
@@ -250,7 +249,6 @@ async def view_gift(callback: CallbackQuery, session: AsyncSession):
f"ID: <code>{gift.gift_id}</code>\n"
f"Срок: <b>{duration_text}</b>\n"
f"Активаций: <b>{usage_text}</b>\n"
f"Истекает: <i>{gift.expiry_time.strftime('%d.%m.%Y')}</i>\n"
f"<b>Ссылка для активации:</b>\n<blockquote>{gift.gift_link}</blockquote>"
)
+1 -1
View File
@@ -3,7 +3,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from database.models import Gift, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days, format_months, get_plural_form
from handlers.utils import format_days, format_months
from ..panel.keyboard import AdminPanelCallback
@@ -1,6 +1,7 @@
import hashlib
import json
import os
import re
import subprocess
import sys
import time
@@ -22,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database.models import Admin, Key, Server, User
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import update_subscription
from handlers.keys.operations import update_subscription
from logger import logger
from middlewares import maintenance
from panels.remnawave import RemnawaveAPI
@@ -97,7 +98,7 @@ async def process_new_domain(message: Message, state: FSMContext, session: Async
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
@@ -438,8 +439,6 @@ async def back_to_database_menu(callback: CallbackQuery):
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
await callback.answer()
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
servers = result.scalars().all()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .module_handler import router
+93
View File
@@ -0,0 +1,93 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.admin.panel.keyboard import AdminPanelCallback
from handlers.buttons import BACK
from utils.modules_manager import manager
def build_modules_kb(page: int, total_pages: int, items: list[tuple[str, str | None]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
row_buf = []
for name, _ in items:
label = name if manager.is_enabled(name) else f"{name} (off)"
row_buf.append(
InlineKeyboardButton(
text=label,
callback_data=AdminPanelCallback(action=f"module__{name}", page=page).pack(),
)
)
if len(row_buf) == 2:
builder.row(*row_buf)
row_buf = []
if row_buf:
builder.row(*row_buf)
if total_pages > 1:
nav = []
if page > 1:
nav.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminPanelCallback(action="modules", page=page - 1).pack(),
)
)
nav.append(
InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
)
)
if page < total_pages:
nav.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(action="modules", page=page + 1).pack(),
)
)
builder.row(*nav)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="admin", page=1).pack(),
)
)
return builder.as_markup()
def build_module_menu_kb(name: str, page: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
enabled = manager.is_enabled(name)
if enabled:
builder.button(
text="🔁 Перезапустить",
callback_data=AdminPanelCallback(action=f"module_restart__{name}", page=page).pack(),
)
builder.button(
text="🛑 Остановить",
callback_data=AdminPanelCallback(action=f"module_stop__{name}", page=page).pack(),
)
else:
builder.button(
text="▶️ Запустить",
callback_data=AdminPanelCallback(action=f"module_start__{name}", page=page).pack(),
)
builder.button(
text="🔄 Обновить",
callback_data=AdminPanelCallback(action=f"module_update__{name}", page=page).pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ К списку",
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
)
)
builder.adjust(1)
return builder.as_markup()
+171
View File
@@ -0,0 +1,171 @@
import os
from math import ceil
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from handlers.admin.panel.keyboard import AdminPanelCallback
from utils.modules_manager import manager
from .keyboard import build_module_menu_kb, build_modules_kb
router = Router()
def list_installed_modules() -> list[tuple[str, str | None]]:
base = "modules"
if not os.path.isdir(base):
return []
items: list[tuple[str, str | None]] = []
for name in sorted(os.listdir(base)):
path = os.path.join(base, name)
if os.path.isdir(path) and not name.startswith("."):
ver = None
vp = os.path.join(path, "VERSION")
if os.path.isfile(vp):
try:
with open(vp, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
ver = line
break
except Exception:
ver = None
items.append((name, ver))
return items
@router.callback_query(AdminPanelCallback.filter(F.action == "modules"), IsAdminFilter())
async def handle_modules(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
page = max(1, packed.page or 1)
all_items = list_installed_modules()
items = [(n, v) for n, v in all_items if n != "web_admin_panel"]
per_page = 12
total_pages = max(1, (len(items) + per_page - 1) // per_page)
page = min(page, total_pages)
start = (page - 1) * per_page
chunk = items[start : start + per_page]
if chunk:
def fmt(n, v):
return f"{n} v{v}" if v else n
lines = "\n".join(f"{fmt(n, v)}" for n, v in chunk)
text = f"🧩 Мои модули\n\nНайдено: {len(items)}\n<blockquote>{lines}</blockquote>"
else:
text = "🧩 Мои модули\n\nМодулей не найдено."
markup = build_modules_kb(page, total_pages, chunk)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
try:
await callback_query.message.edit_reply_markup(reply_markup=None)
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest:
pass
else:
raise
finally:
await callback_query.answer()
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_restart__")), IsAdminFilter())
async def handle_module_restart(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_restart__", 1)[-1]
try:
await manager.restart(name)
result = "✅ Модуль перезапущен."
except Exception as e:
result = f"❌ Ошибка перезапуска: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_stop__")), IsAdminFilter())
async def handle_module_stop(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_stop__", 1)[-1]
try:
await manager.stop(name)
result = "🛑 Модуль остановлен."
except Exception as e:
result = f"❌ Ошибка остановки: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_start__")), IsAdminFilter())
async def handle_module_start(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_start__", 1)[-1]
try:
await manager.start(name)
result = "▶️ Модуль запущен."
except Exception as e:
result = f"❌ Ошибка запуска: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
+42 -28
View File
@@ -5,6 +5,8 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK, MAIN_MENU
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
@@ -17,51 +19,63 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
super().__init__(**data)
def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="👤 Поиск пользователя",
callback_data=AdminPanelCallback(action="search_user").pack(),
)
builder.button(
text="🔑 Поиск по подписке",
callback_data=AdminPanelCallback(action="search_key").pack(),
)
builder.button(
text="🖥️ Управление серверами",
callback_data=AdminPanelCallback(action="clusters").pack(),
)
builder.row(
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.row(
InlineKeyboardButton(text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()),
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
InlineKeyboardButton(
text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack()
),
InlineKeyboardButton(text="🔑 Поиск подписок", callback_data=AdminPanelCallback(action="search_key").pack()),
)
if admin_role == "superadmin":
builder.button(
text="🖥️ Управление серверами",
callback_data=AdminPanelCallback(action="clusters").pack(),
)
builder.button(
text="💸Управление тарифами",
callback_data=AdminPanelCallback(action="tariffs").pack(),
)
builder.button(
text="🤖 Управление ботом",
callback_data=AdminPanelCallback(action="management").pack(),
)
builder.row(
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
if admin_role == "superadmin":
builder.row(
InlineKeyboardButton(
text="📊 Статистика",
callback_data=AdminPanelCallback(action="stats").pack(),
),
InlineKeyboardButton(
text="📈 Аналитика",
callback_data=AdminPanelCallback(action="ads").pack(),
),
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
InlineKeyboardButton(text="🧩 Мои модули", callback_data=AdminPanelCallback(action="modules").pack()),
)
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
)
else:
builder.button(
text="🎁 Подарки",
callback_data=AdminPanelCallback(action="gifts").pack(),
)
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
builder = insert_hook_buttons(builder, module_buttons)
builder.button(
text=MAIN_MENU,
callback_data="profile",
)
builder.adjust(1, 1, 1, 2, 2, 1, 2 if admin_role == "superadmin" else 0, 1)
if admin_role == "superadmin":
builder.adjust(2, 1, 1, 1, 2, 2, 2, 1)
else:
builder.adjust(2, 2, 1, 1)
return builder.as_markup()
+3 -3
View File
@@ -6,10 +6,10 @@ from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import get_version
from database.models import Admin
from filters.admin import IsAdminFilter
from logger import logger
from utils.versioning import get_version
from .keyboard import AdminPanelCallback, build_panel_kb
@@ -26,7 +26,7 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
result = await session.execute(select(Admin.role).where(Admin.tg_id == callback_query.from_user.id))
role = result.scalar_one_or_none() or "admin"
markup = build_panel_kb(admin_role=role)
markup = await build_panel_kb(admin_role=role)
if callback_query.message.text:
try:
@@ -69,6 +69,6 @@ async def handle_admin_message(message: Message, state: FSMContext, session: Asy
await message.answer(
text=text,
reply_markup=build_panel_kb(admin_role=role),
reply_markup=await build_panel_kb(admin_role=role),
disable_web_page_preview=True,
)
+1 -1
View File
@@ -10,7 +10,7 @@ from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
+6
View File
@@ -35,6 +35,12 @@ def build_sender_kb() -> InlineKeyboardMarkup:
callback_data=AdminSenderCallback(type="untrial").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🧪 Триал",
callback_data=AdminSenderCallback(type="trial").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔥 Горячие лиды",
+142 -75
View File
@@ -5,14 +5,15 @@ import re
from datetime import datetime
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy import distinct, func, select
from sqlalchemy import distinct, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Server, User
from database import create_blocked_user
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
from filters.admin import IsAdminFilter
from logger import logger
@@ -23,38 +24,77 @@ from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
router = Router()
async def send_broadcast_batch(bot, messages, batch_size=15):
async def try_add_blocked_user(tg_id: int, session: AsyncSession):
if session:
try:
await create_blocked_user(session, tg_id)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception as e:
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
async def send_broadcast_batch(bot, messages, batch_size=15, session=None):
results = []
min_interval = 1.0 / 15
for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size]
tasks = []
for msg in batch:
tg_id = msg["tg_id"]
text = msg["text"]
photo = msg.get("photo")
keyboard = msg.get("keyboard")
for msg in messages:
tg_id = msg["tg_id"]
text = msg["text"]
photo = msg.get("photo")
keyboard = msg.get("keyboard")
try:
if photo:
task = bot.send_photo(
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
)
await bot.send_photo(chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard)
else:
task = bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
tasks.append(task)
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
results.append(True)
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for result in batch_results:
if isinstance(result, Exception):
logger.error(f"❌ Ошибка отправки: {result}")
results.append(False)
else:
except TelegramRetryAfter as e:
retry_in = int(e.retry_after) + 1
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек. для пользователя {tg_id}")
await asyncio.sleep(e.retry_after)
try:
if photo:
await bot.send_photo(
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
)
else:
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
results.append(True)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session)
results.append(False)
except TelegramBadRequest as bad_request:
if "chat not found" in str(bad_request).lower():
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
await try_add_blocked_user(tg_id, session)
results.append(False)
except Exception as retry_error:
logger.error(f"❌ Ошибка повторной отправки пользователю {tg_id}: {retry_error}")
await try_add_blocked_user(tg_id, session)
results.append(False)
if i + batch_size < len(messages):
await asyncio.sleep(1.0)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session)
results.append(False)
except TelegramBadRequest as bad_request:
if "chat not found" in str(bad_request).lower():
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
await try_add_blocked_user(tg_id, session)
results.append(False)
except Exception as e:
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
await try_add_blocked_user(tg_id, session)
results.append(False)
await asyncio.sleep(min_interval)
return results
@@ -64,6 +104,72 @@ class AdminSender(StatesGroup):
preview = State()
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str = None) -> tuple[list[int], int]:
now_ms = int(datetime.utcnow().timestamp() * 1000)
banned_tg_ids = select(BlockedUser.tg_id).union_all(
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
)
query = None
if send_to == "subscribed":
query = (
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "hotleads":
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(Payment.amount > 0)
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "trial":
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
query = (
select(distinct(Key.tg_id))
.where(Key.tariff_id.in_(trial_tariff_subquery))
.where(~Key.tg_id.in_(banned_tg_ids))
)
else:
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
return tg_ids, len(tg_ids)
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
if "BUTTONS:" not in text:
return text, None
@@ -181,7 +287,7 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSe
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext):
async def handle_message_input(message: Message, state: FSMContext, session: AsyncSession):
original_text = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else None
@@ -196,6 +302,11 @@ async def handle_message_input(message: Message, state: FSMContext):
await state.clear()
return
data = await state.get_data()
send_to = data.get("type", "all")
cluster_name = data.get("cluster_name")
_, user_count = await get_recipients(session, send_to, cluster_name)
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
await state.set_state(AdminSender.preview)
@@ -205,7 +316,7 @@ async def handle_message_input(message: Message, state: FSMContext):
await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
await message.answer(
"👀 Это предпросмотр рассылки.\nОтправить?",
f"👀 Это предпросмотр рассылки.\n👥 Количество получателей: <b>{user_count}</b>\n\nОтправить?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -225,7 +336,6 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
keyboard_data = data.get("keyboard")
send_to = data.get("type", "all")
cluster_name = data.get("cluster_name")
now_ms = int(datetime.utcnow().timestamp() * 1000)
keyboard = None
if keyboard_data:
@@ -234,49 +344,7 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
except Exception as e:
logger.error(f"Ошибка восстановления клавиатуры: {e}")
query = None
if send_to == "subscribed":
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
)
elif send_to == "hotleads":
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(~User.tg_id.in_(subquery))
)
else:
query = select(distinct(User.tg_id))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
total_users = len(tg_ids)
success_count = 0
tg_ids, total_users = await get_recipients(session, send_to, cluster_name)
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
@@ -285,8 +353,7 @@ async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext,
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
messages.append(message_data)
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15)
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15, session=session)
success_count = sum(1 for result in results if result)
await callback_query.message.answer(
+3 -1
View File
@@ -63,7 +63,9 @@ def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKe
)
)
builder.row(InlineKeyboardButton(text="🔑 Inbound ID", callback_data=f"edit_server_field|{server_name}|inbound_id"))
builder.row(
InlineKeyboardButton(text="🔑 Inbound ID/Squads", callback_data=f"edit_server_field|{server_name}|inbound_id")
)
builder.row(InlineKeyboardButton(text="⚙️ Тип панели", callback_data=f"select_panel_type|{server_name}"))
+32 -7
View File
@@ -77,7 +77,7 @@ async def handle_server_manage(
text += f"🌐 Subscription URL: <b>{subscription_url}</b>\n"
text += (
f"🔑 Inbound ID: <b>{inbound_id}</b>\n"
f"🔑 Inbound ID/Squads: <b>{inbound_id}</b>\n"
f"⚙️ Тип панели: <b>{panel_type}</b>\n"
f"📈 Лимит ключей: <b>{limit_display}</b>\n"
)
@@ -136,10 +136,19 @@ async def process_callback_delete_server(
if all_servers:
builder = InlineKeyboardBuilder()
for s_name, key_count in all_servers:
callback_data = f"transfer_to_server|{s_name}|{server_name}"
if len(callback_data.encode("utf-8")) > 64:
await callback_query.message.edit_text(
text=f"❌ Ошибка: название сервера '{s_name}' слишком длинное.\n\n"
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
builder.row(
InlineKeyboardButton(
text=f"{s_name} ({key_count})",
callback_data=f"transfer_to_server|{s_name}|{server_name}",
callback_data=callback_data,
)
)
builder.row(
@@ -189,10 +198,19 @@ async def process_callback_delete_server(
builder = InlineKeyboardBuilder()
for cl_name, key_count in all_clusters:
callback_data = f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}"
if len(callback_data.encode("utf-8")) > 64:
await callback_query.message.edit_text(
text=f"❌ Ошибка: название сервера '{server_name}' или кластера '{cl_name}' слишком длинное.\n\n"
f"Пожалуйста, переименуйте сервер в более короткое название и попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
builder.row(
InlineKeyboardButton(
text=f"{cl_name} ({key_count})",
callback_data=f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}",
callback_data=callback_data,
)
)
builder.row(
@@ -258,7 +276,7 @@ async def toggle_server_enabled(
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {server['api_url']}\n"
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
f"<b>🔑 Inbound ID/Squads:</b> {server['inbound_id']}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
@@ -311,7 +329,7 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {server['api_url']}\n"
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
f"<b>🔑 Inbound ID/Squads:</b> {server['inbound_id']}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
@@ -361,7 +379,7 @@ async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, sessio
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID",
"inbound_id": "Inbound ID/Squads",
}
await callback.message.edit_text(
@@ -424,6 +442,13 @@ async def apply_field_edit(message: types.Message, state: FSMContext, session: A
value = message.text.strip()
if field == "server_name":
if len(value) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
success = await update_server_name_with_keys(session, server_name, value)
if success:
server_name = value
@@ -438,7 +463,7 @@ async def apply_field_edit(message: types.Message, state: FSMContext, session: A
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID",
"inbound_id": "Inbound ID/Squads",
}
await message.answer(
+18 -1
View File
@@ -29,6 +29,7 @@ from database import (
sum_total_payments,
)
from filters.admin import IsAdminFilter
from hooks.hooks import run_hooks
from logger import logger
from utils.csv_export import (
export_hot_leads_csv,
@@ -199,7 +200,23 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
extra_blocks = await run_hooks("admin_stats", session=session, now=now)
if extra_blocks:
stats_message += "\n\n" + "\n\n".join([str(b) for b in extra_blocks if b])
new_kb = build_stats_kb()
current_text = callback_query.message.html_text or callback_query.message.text or ""
cur_kb = callback_query.message.reply_markup
cur_kb_json = cur_kb.model_dump_json() if cur_kb else None
new_kb_json = new_kb.model_dump_json() if new_kb else None
if current_text == stats_message and cur_kb_json == new_kb_json:
try:
await callback_query.answer()
except Exception:
pass
else:
await callback_query.message.edit_text(text=stats_message, reply_markup=new_kb)
except TelegramBadRequest as e:
if "message is not modified" not in str(e):
+122 -10
View File
@@ -27,6 +27,12 @@ def build_tariff_menu_kb() -> InlineKeyboardMarkup:
callback_data=AdminTariffCallback(action="list").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔢 Расположение тарифов",
callback_data=AdminTariffCallback(action="arrange").pack(),
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()))
return builder.as_markup()
@@ -37,6 +43,93 @@ def build_cancel_kb() -> InlineKeyboardMarkup:
)
def build_tariff_arrangement_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
row = []
for i, group in enumerate(groups):
row.append(
InlineKeyboardButton(
text=group,
callback_data=AdminTariffCallback(action=f"arrange_group|{group}").pack(),
)
)
if len(row) == 2 or i == len(groups) - 1:
builder.row(*row)
row = []
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action="list").pack(),
)
)
return builder.as_markup()
def build_tariffs_arrangement_kb(group_code: str, tariffs: list) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.get("subgroup_title")].append(t)
for subgroup in grouped_tariffs:
grouped_tariffs[subgroup].sort(key=lambda x: x.get("sort_order"))
if grouped_tariffs.get(None):
for t in grouped_tariffs[None]:
builder.row(
InlineKeyboardButton(
text="⬆️",
callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack(),
),
InlineKeyboardButton(
text=f" {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack()
),
InlineKeyboardButton(
text="⬇️",
callback_data=AdminTariffCallback(action=f"quick_move_down|{t.get('id')}|{group_code}").pack(),
),
)
for subgroup, tariffs_list in grouped_tariffs.items():
if subgroup:
builder.row(
InlineKeyboardButton(text=f"📁 {subgroup}", callback_data=AdminTariffCallback(action="arrange").pack())
)
for t in tariffs_list:
builder.row(
InlineKeyboardButton(
text="⬆️",
callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack(),
),
InlineKeyboardButton(
text=f" {t.get('name')} ",
callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack(),
),
InlineKeyboardButton(
text="⬇️",
callback_data=AdminTariffCallback(action=f"quick_move_down|{t.get('id')}|{group_code}").pack(),
),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminPanelCallback(action="tariffs").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🏠 Главное меню",
callback_data=AdminPanelCallback(action="admin").pack(),
)
)
return builder.as_markup()
def build_tariff_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
row = []
@@ -73,14 +166,15 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
subgroup = t.get("subgroup_title")
grouped[subgroup].append(t)
for subgroup_title, _items in grouped.items():
if subgroup_title:
subgroup_hash = create_subgroup_hash(subgroup_title, group_code)
builder.row(
InlineKeyboardButton(
text=f"{subgroup_title}", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
)
)
sorted_subgroups = sorted(
[k for k in grouped if k], key=lambda x: (sum(t.get("sort_order", 1) for t in grouped[x]), x)
)
for subgroup_title in sorted_subgroups:
subgroup_hash = create_subgroup_hash(subgroup_title, group_code)
builder.row(
InlineKeyboardButton(text=f"{subgroup_title}", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")
)
for t in grouped.get(None, []):
title = f"{t['name']}{t['price_rub']}"
@@ -110,7 +204,7 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
return builder.as_markup()
def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
def build_single_tariff_kb(tariff_id: int, group_code: str = None) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -123,10 +217,22 @@ def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
callback_data=AdminTariffCallback(action=f"delete|{tariff_id}").pack(),
),
],
[
InlineKeyboardButton(
text="⬆️ Выше",
callback_data=AdminTariffCallback(action=f"move_up|{tariff_id}").pack(),
),
InlineKeyboardButton(
text="⬇️ Ниже",
callback_data=AdminTariffCallback(action=f"move_down|{tariff_id}").pack(),
),
],
[
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action="list").pack(),
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
if group_code
else AdminTariffCallback(action="list").pack(),
)
],
]
@@ -156,6 +262,12 @@ def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
callback_data=f"edit_field|{tariff_id}|device_limit",
)
],
[
InlineKeyboardButton(
text="🔗 VLESS",
callback_data=f"edit_field|{tariff_id}|vless",
)
],
[InlineKeyboardButton(text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}")],
[
InlineKeyboardButton(
+288 -56
View File
@@ -1,7 +1,10 @@
import re
from collections import defaultdict
from datetime import datetime
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
@@ -17,7 +20,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database import create_tariff
from database.models import Gift, Key, Server, Tariff
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from database.tariffs import (
create_subgroup_hash,
find_subgroup_by_hash,
get_tariffs,
move_tariff_down as db_move_tariff_down,
move_tariff_up as db_move_tariff_up,
)
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback
@@ -26,9 +35,11 @@ from .keyboard import (
build_cancel_kb,
build_edit_tariff_fields_kb,
build_single_tariff_kb,
build_tariff_arrangement_groups_kb,
build_tariff_groups_kb,
build_tariff_list_kb,
build_tariff_menu_kb,
build_tariffs_arrangement_kb,
)
@@ -43,6 +54,7 @@ class TariffCreateState(StatesGroup):
traffic = State()
confirm_more = State()
device_limit = State()
vless = State()
class TariffEditState(StatesGroup):
@@ -103,7 +115,8 @@ async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
"<b>Специальные группы:</b>\n"
"• <code>discounts</code> — тарифы со скидкой\n"
"• <code>discounts_max</code> — тарифы с максимальной скидкой\n"
"• <code>gifts</code> — тарифы для подарков",
"• <code>gifts</code> — тарифы для подарков\n"
"• <code>trial</code> — тариф для пробного периода",
reply_markup=build_cancel_kb(),
)
@@ -205,7 +218,7 @@ async def process_tariff_traffic(message: Message, state: FSMContext):
@router.message(TariffCreateState.device_limit, IsAdminFilter())
async def process_tariff_device_limit(message: Message, state: FSMContext, session: AsyncSession):
async def process_tariff_device_limit(message: Message, state: FSMContext):
try:
device_limit = int(message.text.strip())
if device_limit < 0:
@@ -214,6 +227,28 @@ async def process_tariff_device_limit(message: Message, state: FSMContext, sessi
await message.answer("❌ Введите корректный лимит устройств (целое число 0 или больше):")
return
await state.update_data(device_limit=device_limit if device_limit > 0 else None)
await state.set_state(TariffCreateState.vless)
await message.answer(
"🔗 Этот тариф для VLESS?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да (VLESS)", callback_data="create_vless|1"),
InlineKeyboardButton(text="❌ Нет", callback_data="create_vless|0"),
],
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_tariff_creation")],
]
),
)
@router.callback_query(F.data.startswith("create_vless|"), TariffCreateState.vless, IsAdminFilter())
async def select_vless_creation(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, flag = callback.data.split("|", 1)
vless_flag = flag == "1"
data = await state.get_data()
new_tariff = await create_tariff(
@@ -224,12 +259,13 @@ async def process_tariff_device_limit(message: Message, state: FSMContext, sessi
"duration_days": data["duration_days"],
"price_rub": data["price_rub"],
"traffic_limit": data["traffic_limit"],
"device_limit": device_limit if device_limit > 0 else None,
"device_limit": data.get("device_limit"),
"vless": vless_flag,
},
)
await state.set_state(TariffCreateState.confirm_more)
await message.answer(
await callback.message.edit_text(
f"✅ Тариф <b>{new_tariff.name}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
"➕ Хотите добавить ещё один тариф в эту группу?",
reply_markup=InlineKeyboardMarkup(
@@ -276,6 +312,7 @@ async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
"discounts": "🔻 Скидки",
"discounts_max": "🔻 Макс. скидки",
"gifts": "🎁 Подарки",
"trial": "🚀 Пробный период",
}
text = "<b>📋 Выберите тарифную группу:</b>\n\n"
@@ -289,22 +326,49 @@ async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
await callback.message.edit_text(text, reply_markup=build_tariff_groups_kb(groups))
def tariff_to_dict(tariff: Tariff) -> dict:
@router.callback_query(AdminTariffCallback.filter(F.action == "arrange"), IsAdminFilter())
async def show_tariff_arrangement_menu(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(
select(distinct(Tariff.group_code)).where(Tariff.group_code.isnot(None)).order_by(Tariff.group_code)
)
groups = [row[0] for row in result.fetchall()]
if not groups:
await callback.message.edit_text("❌ Нет доступных групп тарифов.")
return
await callback.message.edit_text(
"🔢 <b>Управление расположением тарифов</b>\n\n"
"📋 <b>Как это работает:</b>\n"
"• Тарифы отображаются в порядке их расположения\n"
"• Меньший номер = выше в списке\n"
"• Новые тарифы добавляются в конец списка\n"
"• ⬆️ поднимает тариф выше (номер уменьшается)\n"
"• ⬇️ опускает тариф ниже (номер увеличивается)\n"
"• Подгруппы сортируются по общей сумме тарифов внутри\n\n"
"Выберите группу для управления расположением:",
reply_markup=build_tariff_arrangement_groups_kb(groups),
)
def tariff_to_dict(tariff) -> dict:
if isinstance(tariff, dict):
return tariff
return {
"id": tariff.id,
"name": tariff.name,
"price_rub": tariff.price_rub,
"group_code": tariff.group_code,
"subgroup_title": tariff.subgroup_title,
"sort_order": tariff.sort_order,
}
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter())
async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
group_code = callback_data.action.split("|", 1)[1]
group_code = callback_data.action.split("|")[1]
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
tariffs = result.scalars().all()
tariffs = await get_tariffs(session, group_code=group_code)
if not tariffs:
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
@@ -318,9 +382,58 @@ async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTar
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("arrange_group|")), IsAdminFilter())
async def show_tariffs_arrangement(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
group_code = callback_data.action.split("|")[1]
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"]
if not tariffs:
await callback.message.edit_text("❌ В этой группе пока нет активных тарифов.")
return
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.get("subgroup_title")].append(t)
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
moscow_tz = pytz.timezone("Europe/Moscow")
now = datetime.now(moscow_tz)
current_time = now.strftime("%d.%m.%y %H:%M:%S МСК")
text = f"🔢 <b>Итоговая сортировка тарифов в группе: {group_code}</b>\n\n"
if grouped_tariffs.get(None):
text += "<b>📋 Основные тарифы:</b>\n"
for t in grouped_tariffs[None]:
sort_order = t.get("sort_order", 1)
text += f"{t.get('name')} <code>[позиция: {sort_order}]</code>\n"
text += "\n"
if sorted_subgroups:
text += "<b>📁 Подгруппы:</b>\n"
for subgroup in sorted_subgroups:
subgroup_weight = subgroup_weights.get(subgroup, 999999)
text += f"• <b>{subgroup}</b> <code>[вес группы: {subgroup_weight}]</code>\n"
for t in grouped_tariffs[subgroup]:
sort_order = t.get("sort_order", 1)
text += f"{t.get('name')} <code>[позиция: {sort_order}]</code>\n"
text += "\n"
text += f"\n{current_time}"
await callback.message.edit_text(
text,
reply_markup=build_tariffs_arrangement_kb(group_code, tariffs),
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter())
async def view_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|", 1)[1])
tariff_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
@@ -335,7 +448,7 @@ async def view_tariff(callback: CallbackQuery, callback_data: AdminTariffCallbac
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter())
async def confirm_tariff_deletion(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|", 1)[1])
tariff_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
@@ -470,12 +583,35 @@ async def ask_new_value(callback: CallbackQuery, state: FSMContext):
await state.update_data(field=field)
await state.set_state(TariffEditState.editing_value)
if field == "vless":
data = await state.get_data()
tariff_id = int(data["tariff_id"])
await callback.message.edit_text(
"🔗 Установить флаг VLESS:",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да (VLESS)", callback_data=f"set_vless|{tariff_id}|1"),
InlineKeyboardButton(text="❌ Нет", callback_data=f"set_vless|{tariff_id}|0"),
],
[
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack(),
)
],
]
),
)
return
field_names = {
"name": "название тарифа",
"duration_days": "длительность в днях",
"price_rub": "цену в рублях",
"traffic_limit": "лимит трафика в ГБ (0 — безлимит)",
"device_limit": "лимит устройств (0 — безлимит)",
"vless": "VLESS (да/нет)",
}
await callback.message.edit_text(
@@ -484,6 +620,28 @@ async def ask_new_value(callback: CallbackQuery, state: FSMContext):
)
@router.callback_query(F.data.startswith("set_vless|"), TariffEditState.editing_value, IsAdminFilter())
async def set_vless_flag(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, tariff_id_str, flag = callback.data.split("|", 2)
tariff_id = int(tariff_id_str)
vless_flag = flag == "1"
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
await state.clear()
return
tariff.vless = vless_flag
tariff.updated_at = datetime.utcnow()
await session.commit()
await state.clear()
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.message(TariffEditState.editing_value, IsAdminFilter())
async def apply_edit(message: Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
@@ -565,6 +723,8 @@ async def start_tariff_creation_existing_group(
def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
traffic_text = f"{tariff.traffic_limit} ГБ" if tariff.traffic_limit else "Безлимит"
device_text = f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
sort_order = getattr(tariff, "sort_order", 1)
vless_text = "Да" if getattr(tariff, "vless", False) else "Нет"
text = (
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
@@ -573,22 +733,20 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
f"💰 Стоимость: <b>{tariff.price_rub}₽</b>\n"
f"📦 Трафик: <b>{traffic_text}</b>\n"
f"📱 Устройств: <b>{device_text}</b>\n"
f"🔗 VLESS: <b>{vless_text}</b>\n"
f"🔢 Позиция: <b>{sort_order}</b>\n"
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
)
return text, build_single_tariff_kb(tariff.id)
return text, build_single_tariff_kb(tariff.id, tariff.group_code)
@router.callback_query(F.data.startswith("start_subgrouping|"), IsAdminFilter())
async def start_subgrouping(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
group_code = callback.data.split("|", 1)[1]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, (Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == "")))
.order_by(Tariff.id)
)
tariffs = result.scalars().all()
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if not t.get("subgroup_title") or t.get("subgroup_title") == ""]
if not tariffs:
await callback.message.edit_text(
@@ -610,7 +768,7 @@ async def start_subgrouping(callback: CallbackQuery, state: FSMContext, session:
builder = InlineKeyboardBuilder()
for tariff in tariffs:
builder.row(InlineKeyboardButton(text=f"{tariff.name}", callback_data=f"sub_select|{tariff.id}"))
builder.row(InlineKeyboardButton(text=f"{tariff.get('name')}", callback_data=f"sub_select|{tariff.get('id')}"))
builder.row(
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
@@ -636,18 +794,16 @@ async def toggle_tariff_subgroup_selection(callback: CallbackQuery, state: FSMCo
await state.update_data(selected_tariff_ids=list(selected))
group_code = data["group_code"]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, (Tariff.subgroup_title.is_(None) | (Tariff.subgroup_title == "")))
.order_by(Tariff.id)
)
tariffs = result.scalars().all()
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if not t.get("subgroup_title") or t.get("subgroup_title") == ""]
builder = InlineKeyboardBuilder()
for tariff in tariffs:
is_selected = tariff.id in selected
is_selected = tariff.get("id") in selected
prefix = "" if is_selected else ""
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.name}", callback_data=f"sub_select|{tariff.id}"))
builder.row(
InlineKeyboardButton(text=f"{prefix}{tariff.get('name')}", callback_data=f"sub_select|{tariff.get('id')}")
)
builder.row(
InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
@@ -730,12 +886,8 @@ async def view_subgroup_tariffs(callback: CallbackQuery, session: AsyncSession):
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
.order_by(Tariff.id)
)
tariffs = result.scalars().all()
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if t.get("subgroup_title") == subgroup_title]
if not tariffs:
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
@@ -936,17 +1088,14 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
result = await session.execute(
select(Tariff)
.where(
Tariff.group_code == group_code,
or_(Tariff.subgroup_title == subgroup_title, Tariff.subgroup_title.is_(None), Tariff.subgroup_title == ""),
)
.order_by(Tariff.id)
)
all_tariffs_to_show = result.scalars().all()
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
all_tariffs_to_show = [
t
for t in all_tariffs_to_show
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
]
subgroup_tariff_ids = {t.id for t in all_tariffs_to_show if t.subgroup_title == subgroup_title}
subgroup_tariff_ids = {t.get("id") for t in all_tariffs_to_show if t.get("subgroup_title") == subgroup_title}
if not all_tariffs_to_show:
await callback.message.edit_text(
@@ -969,9 +1118,13 @@ async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext
builder = InlineKeyboardBuilder()
for tariff in all_tariffs_to_show:
is_in_subgroup = tariff.id in subgroup_tariff_ids
is_in_subgroup = tariff.get("id") in subgroup_tariff_ids
prefix = "" if is_in_subgroup else ""
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.name}", callback_data=f"edit_sub_toggle|{tariff.id}"))
builder.row(
InlineKeyboardButton(
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
)
)
builder.row(
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
@@ -1003,21 +1156,22 @@ async def toggle_tariff_in_subgroup_edit(callback: CallbackQuery, state: FSMCont
group_code = data["group_code"]
subgroup_hash = data["subgroup_hash"]
result = await session.execute(
select(Tariff)
.where(
Tariff.group_code == group_code,
or_(Tariff.subgroup_title == subgroup_title, Tariff.subgroup_title.is_(None), Tariff.subgroup_title == ""),
)
.order_by(Tariff.id)
)
all_tariffs_to_show = result.scalars().all()
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
all_tariffs_to_show = [
t
for t in all_tariffs_to_show
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
]
builder = InlineKeyboardBuilder()
for tariff in all_tariffs_to_show:
is_selected = tariff.id in selected_ids
is_selected = tariff.get("id") in selected_ids
prefix = "" if is_selected else ""
builder.row(InlineKeyboardButton(text=f"{prefix}{tariff.name}", callback_data=f"edit_sub_toggle|{tariff.id}"))
builder.row(
InlineKeyboardButton(
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
)
)
builder.row(
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
@@ -1109,3 +1263,81 @@ async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMConte
]
),
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("move_up|")), IsAdminFilter())
async def move_tariff_up(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|")[1])
success = await db_move_tariff_up(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.answer("❌ Тариф не найден", show_alert=True)
return
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
await callback.answer("✅ Тариф перемещен выше (-1)")
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("move_down|")), IsAdminFilter())
async def move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|")[1])
success = await db_move_tariff_down(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.answer("❌ Тариф не найден", show_alert=True)
return
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
await callback.answer("✅ Тариф перемещен ниже (+1)")
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("quick_move_up|")), IsAdminFilter())
async def quick_move_tariff_up(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
parts = callback_data.action.split("|")
tariff_id = int(parts[1])
group_code = parts[2]
success = await db_move_tariff_up(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
await callback.answer("✅ Тариф перемещен выше (-1)")
new_callback_data = AdminTariffCallback(action=f"arrange_group|{group_code}")
await show_tariffs_arrangement(callback, new_callback_data, session)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("quick_move_down|")), IsAdminFilter())
async def quick_move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
parts = callback_data.action.split("|")
tariff_id = int(parts[1])
group_code = parts[2]
success = await db_move_tariff_down(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
await callback.answer("✅ Тариф перемещен ниже (+1)")
new_callback_data = AdminTariffCallback(action=f"arrange_group|{group_code}")
await show_tariffs_arrangement(callback, new_callback_data, session)
+8 -3
View File
@@ -3,14 +3,16 @@ from datetime import datetime, timezone
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import or_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import HWID_RESET_BUTTON
from database import get_clusters
from database.models import Key, Server, Tariff
from database.models import Key, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from ..panel.keyboard import build_admin_back_btn
@@ -30,7 +32,7 @@ class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
edit: bool = False
def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = False) -> InlineKeyboardMarkup:
async def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = False) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
current_time = datetime.now(tz=timezone.utc)
@@ -90,6 +92,9 @@ def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = False) -
),
)
hook_buttons = await run_hooks("admin_user_edit", tg_id=tg_id, is_banned=is_banned)
builder = insert_hook_buttons(builder, hook_buttons)
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
builder.row(build_admin_back_btn())
+89 -51
View File
@@ -11,6 +11,7 @@ from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from aiogram.utils.formatting import BlockQuote, Bold, Text
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -33,7 +34,7 @@ from database import (
)
from database.models import Key, ManualBan, Payment, Referral, Server, Tariff, User
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
from handlers.keys.operations import (
create_key_on_cluster,
delete_key_from_cluster,
get_user_traffic,
@@ -456,14 +457,16 @@ async def handle_balance_add(
if amount is not None:
amount = int(amount)
old_balance = await get_balance(session, tg_id)
if amount >= 0:
await update_balance(session, tg_id, amount)
new_balance = old_balance + amount
else:
current_balance = await get_balance(session, tg_id)
new_balance = max(0, current_balance + amount)
new_balance = max(0, old_balance + amount)
await set_user_balance(session, tg_id, new_balance)
await handle_balance_change(callback_query, callback_data, session)
if old_balance != new_balance:
await handle_balance_change(callback_query, callback_data, session)
return
await state.update_data(tg_id=tg_id, op_type="add")
@@ -548,32 +551,35 @@ async def handle_key_edit(
update: bool = False,
):
email = callback_data.data
key_details = await get_key_details(session, email)
result = await session.execute(select(Key).where(Key.email == email))
key_obj: Key | None = result.scalar_one_or_none()
if not key_details:
if not key_obj:
await callback_query.message.edit_text(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(callback_data.tg_id),
)
return
key_value = key_details.get("key") or key_details.get("remnawave_link") or ""
alias = key_details.get("alias")
utc_tz = pytz.utc
created_at_raw = key_details.get("created_at")
if created_at_raw:
created_at_dt = datetime.fromtimestamp(int(created_at_raw) / 1000, tz=utc_tz).astimezone(MOSCOW_TZ)
key_value = key_obj.key or key_obj.remnawave_link or ""
alias_part = f" (<i>{key_obj.alias}</i>)" if key_obj.alias else ""
if key_obj.created_at:
created_at_dt = datetime.fromtimestamp(int(key_obj.created_at) / 1000) + timedelta(hours=3)
created_at = created_at_dt.strftime("%d %B %Y года %H:%M")
else:
created_at = ""
expiry_date = key_details.get("expiry_date") or ""
if key_obj.expiry_time:
expiry_dt = datetime.fromtimestamp(int(key_obj.expiry_time) / 1000)
expiry_date = expiry_dt.strftime("%d %B %Y года %H:%M")
else:
expiry_date = ""
tariff_name = ""
subgroup_title = ""
if key_details.get("tariff_id"):
result = await session.execute(
select(Tariff.name, Tariff.subgroup_title).where(Tariff.id == key_details["tariff_id"])
)
if key_obj.tariff_id:
result = await session.execute(select(Tariff.name, Tariff.subgroup_title).where(Tariff.id == key_obj.tariff_id))
row = result.first()
if row:
tariff_name = row[0]
@@ -582,20 +588,18 @@ async def handle_key_edit(
text = (
"<b>🔑 Информация о подписке</b>\n\n"
"<blockquote>"
f"🔗 <b>Ключ:</b> <code>{key_value}</code>\n"
f"🔗 <b>Ключ{alias_part}:</b> <code>{key_value}</code>\n"
f"📆 <b>Создан:</b> {created_at} (МСК)\n"
f"⏰ <b>Истекает:</b> {expiry_date} (МСК)\n"
f"🌐 <b>Кластер:</b> {key_details.get('cluster_name', '')}\n"
f"🆔 <b>ID клиента:</b> {key_details.get('tg_id', '')}\n"
f"🌐 <b>Кластер:</b> {key_obj.server_id or ''}\n"
f"🆔 <b>ID клиента:</b> {key_obj.tg_id or ''}\n"
f"📁 <b>Группа:</b> {subgroup_title}\n"
f"📦 <b>Тариф:</b> {tariff_name}\n"
"</blockquote>"
)
if alias:
text += f"🏷️ <b>Имя подписки:</b> {alias}\n"
text += "</blockquote>"
if not update or not callback_data.edit:
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_details, email))
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_obj.__dict__, email))
else:
await callback_query.message.edit_text(
text=text,
@@ -696,6 +700,13 @@ async def handle_user_renew_confirm(
stmt = update(Key).where(Key.tg_id == tg_id, Key.email == email).values(tariff_id=tariff_id)
await session.execute(stmt)
await session.commit()
await update_subscription(
tg_id=tg_id,
email=email,
session=session
)
await state.clear()
callback_data = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
@@ -1106,12 +1117,13 @@ async def handle_delete_user_confirm(
await callback_query.message.edit_text(
text=f"🗑️ Пользователь с ID {tg_id} был удален.",
reply_markup=build_editor_kb(callback_data.tg_id),
reply_markup=build_admin_back_kb(),
)
except Exception as e:
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова.",
reply_markup=build_admin_back_kb(),
)
@@ -1148,15 +1160,22 @@ async def process_user_search(
result_ref_by = await session.execute(stmt_ref_by)
referrer_tg_id = result_ref_by.scalar_one_or_none()
referrer_text = ""
referrer_text = None
if referrer_tg_id:
stmt_referrer = select(User.username).where(User.tg_id == referrer_tg_id)
result_referrer = await session.execute(stmt_referrer)
ref_username = result_referrer.scalar_one_or_none()
if ref_username:
referrer_text = f"\n🤝 Пригласил: <b>@{ref_username}</b>"
referrer_text = f"🤝 Пригласил: @{ref_username} ({referrer_tg_id})"
else:
referrer_text = f"\n🤝 Пригласил: <b>{referrer_tg_id}</b>"
referrer_text = f"🤝 Пригласил: {referrer_tg_id}"
stmt = select(func.count(Payment.id), func.coalesce(func.sum(Payment.amount), 0)).where(
Payment.status == "success", Payment.tg_id == tg_id
)
result = await session.execute(stmt)
topups_amount, topups_sum = result.one_or_none() or (0, 0)
stmt_keys = select(Key).where(Key.tg_id == tg_id)
result_keys = await session.execute(stmt_keys)
key_records = result_keys.scalars().all()
@@ -1171,27 +1190,32 @@ async def process_user_search(
user_obj = await session.get(User, tg_id)
full_name = user_obj.first_name if user_obj else None
text = (
f"<b>📊 Информация о пользователе</b>\n\n"
f"<blockquote>"
f"🆔 ID: <b>{tg_id}</b>\n"
f"📄 Логин: <b>@{username}</b>{f' ({full_name})' if full_name else ''}\n"
f"📅 Дата регистрации: <b>{created_at_str}</b>\n"
f"🏃 Дата активности: <b>{updated_at_str}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>{referrer_text}"
f"</blockquote>"
body = Text(
f"🆔 ID: {tg_id}\n",
f"📄 Логин: @{username}" if username else "📄 Логин: —",
f"{f' ({full_name})' if full_name else ''}\n",
f"📅 Дата регистрации: {created_at_str}\n",
f"🏃 Дата активности: {updated_at_str}\n",
f"💰 Баланс: {balance} Р.\n",
f"💳 Пополнения: {topups_sum} Р. ({topups_amount} шт.)\n",
f"👥 Количество рефералов: {referral_count}\n",
)
kb = build_user_edit_kb(tg_id, key_records, is_banned=is_banned)
if referrer_text:
body += Text(referrer_text, "\n")
text_builder = Text(Bold("📊 Информация о пользователе"), "\n\n", BlockQuote(body))
text = text_builder.as_html()
kb = await build_user_edit_kb(tg_id, key_records, is_banned=is_banned)
if edit:
try:
await message.edit_text(text=text, reply_markup=kb)
await message.edit_text(text=text, reply_markup=kb, disable_web_page_preview=True)
except TelegramBadRequest:
pass
else:
await message.answer(text=text, reply_markup=kb)
await message.answer(text=text, reply_markup=kb, disable_web_page_preview=True)
async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession) -> Exception | None:
@@ -1206,14 +1230,18 @@ async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession
traffic_limit = 0
device_limit = None
key_subgroup = None
if tariff_id:
result = await session.execute(
select(Tariff.traffic_limit, Tariff.device_limit).where(Tariff.id == tariff_id, Tariff.is_active.is_(True))
select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where(
Tariff.id == tariff_id, Tariff.is_active.is_(True)
)
)
tariff = result.first()
if tariff:
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
device_limit = int(tariff[1]) if tariff[1] is not None else 0
key_subgroup = tariff[2]
servers = await get_servers(session=session)
@@ -1238,6 +1266,8 @@ async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession
session=session,
hwid_device_limit=device_limit,
reset_traffic=False,
target_subgroup=key_subgroup,
old_subgroup=key_subgroup,
)
await update_key_expiry(session, client_id, expiry_time)
@@ -1295,24 +1325,32 @@ async def confirm_restore_trials(callback_query: types.CallbackQuery):
await callback_query.message.edit_text(
text="⚠ Вы уверены, что хотите восстановить пробники для пользователей? \n\n"
"Только для тех, у кого нет активной подписки!",
"Только для тех, у кого нет подписок (активных или истекших)!",
reply_markup=builder.as_markup(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "confirm_restore_trials"), IsAdminFilter())
async def restore_trials(callback_query: types.CallbackQuery, session: AsyncSession):
active_keys_subq = select(Key.tg_id).where(Key.expiry_time > func.extract("epoch", func.now()) * 1000).subquery()
stmt = update(User).where(~User.tg_id.in_(select(active_keys_subq.c.tg_id))).where(User.trial != 0).values(trial=0)
users_result = await session.execute(select(User.tg_id).where(User.trial == 1))
users_with_trial_used = [row[0] for row in users_result.all()]
await session.execute(stmt)
await session.commit()
users_to_reset = []
for tg_id in users_with_trial_used:
has_keys = await session.execute(select(Key.tg_id).where(Key.tg_id == tg_id).limit(1))
if not has_keys.scalar():
users_to_reset.append(tg_id)
if users_to_reset:
stmt = update(User).where(User.tg_id.in_(users_to_reset)).values(trial=0)
await session.execute(stmt)
await session.commit()
builder = InlineKeyboardBuilder()
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text="✅ Пробники успешно восстановлены для пользователей без активных подписок.",
text=f"✅ Пробники восстановлены для {len(users_to_reset)} пользователей без подписок.",
reply_markup=builder.as_markup(),
)
@@ -1486,7 +1524,7 @@ async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMCo
duration_days = tariff["duration_days"]
client_id = str(uuid.uuid4())
email = generate_random_email()
email = await generate_random_email(session=session)
expiry = datetime.now(tz=timezone.utc) + timedelta(days=duration_days)
expiry_ms = int(expiry.timestamp() * 1000)
+13 -6
View File
@@ -10,7 +10,7 @@ CANCEL = "❌ Отмена"
# Профиль
ADD_SUB = " Добавить новую подписку"
ADD_SUB = "Купить новую подписку"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💵 Баланс"
INVITE = "👥 Пригласить"
@@ -35,8 +35,10 @@ STARS = "⭐ Оплата Звездами"
ROBOKASSA = "⭐ RoboKassa"
DISCOUNT_TARIFF = "🔥 Получить скидку"
MAX_DISCOUNT_TARIFF = "⚡ Получить максимальную скидку"
DONAT_BUTTON = "💰 Поддержать проект"
# Кнопки подписки на канал
SUB_CHANELL = "📢 Подписаться"
SUB_CHANELL_DONE = "✅ Я подписался"
@@ -57,7 +59,7 @@ DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
IMPORT_IOS = "🍏 Подключить"
IMPORT_ANDROID = "🤖 Подключить"
PC_BUTTON = "💻 Компьютеры"
TV_BUTTON = "📺 Андроид TV"
TV_BUTTON = "📺 Подключить Андроид TV"
CONNECT_PHONE = "📱 Подключить телефон"
CONNECT_DEVICE = "📲 Подключить устройство"
CONNECT_WINDOWS_BUTTON = "💻 Подключить"
@@ -65,8 +67,6 @@ CONNECT_MACOS_BUTTON = "🍏 Подключить"
ALIAS = "✏️"
UNFREEZE = "🟢 Разморозить подписку"
FREEZE = "🛑 Заморозить подписку"
RENEW = "⏳ Продлить"
RENEW_FULL = "⏳ Продлить подписку"
DELETE = "❌ Удалить"
CHANGE_LOCATION = "🌍 Сменить локацию"
QR = "📷 Показать QR-код"
@@ -78,11 +78,13 @@ PC_MACOS = "🍏 macOS"
TV = "📺 Телевизор"
ROUTER = "📶 Роутер"
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
RENEW_KEY = "Купить VPN 🔄 Продлить VPN"
RENEW_KEY = " Продлить подписку"
RENEW_KEY_NOTIFICATION = "🔄 Продлить подписку"
CHANGE_TARIFF = "🔄 Сменить тариф"
TV_CONTINUE = "▶ Продолжить"
TV_INSTRUCTIONS = "📖 Полная инструкция"
HWID_BUTTON = "♻️ Сбросить привязку"
ROUTER_BUTTON = "Подключить роутер"
# Кнопки касс
@@ -91,11 +93,16 @@ PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
STARS_BOT = "🤖 Бот для покупки звезд"
WATA = "💳 WATA"
WATA_RU = "🇷🇺 WATA: Карты РФ"
WATA_SBP = "🏦 WATA: СБП"
WATA_INT = "🌍 WATA: Международные карты"
KASSAI_CARDS = "💳 KassaAI: Карты РФ"
KASSAI_SBP = "🏦 KassaAI: СБП"
KASSAI = "💳 KassaAI"
TRIBUTE = "💳 Tribute"
RUB_CURRENCY = "₽ Рубли (RUB)"
USD_CURRENCY = "$ USD / Cryptowallet"
# Кнопки Heleket
HELEKET_CRYPTO = "Heleket: Crypto"
HELEKET = "Heleket Crypto"
+12 -3
View File
@@ -28,7 +28,8 @@ from database import (
update_key_expiry,
)
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.keys.operations import renew_key_in_cluster
from handlers.payments.currency_rates import format_for_user
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPONS_DAYS_MESSAGE,
@@ -69,7 +70,7 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
await state.set_state(CouponActivationState.waiting_for_coupon_code)
@router.message(CouponActivationState.waiting_for_coupon_code)
@router.message(CouponActivationState.waiting_for_coupon_code, F.text)
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
coupon_code = message.text.strip()
await activate_coupon(message, state, session, coupon_code=coupon_code)
@@ -98,6 +99,7 @@ async def activate_coupon(
return
user = user_data or message.from_user or message.chat
language_code = user.get("language_code") if isinstance(user, dict) else getattr(user, "language_code", None)
user_id = user["tg_id"] if isinstance(user, dict) else user.id
usage = await check_coupon_usage(session, coupon.id, user_id)
@@ -127,7 +129,8 @@ async def activate_coupon(
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, user_id)
await add_payment(session, tg_id=user_id, amount=coupon.amount, payment_system="coupon")
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей.")
amount_txt = await format_for_user(session, user_id, coupon.amount, language_code)
await message.answer(f"✅ Купон активирован, на баланс начислено {amount_txt}.")
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
@@ -222,6 +225,10 @@ async def handle_key_extension(
total_gb = int(tariff["traffic_limit"]) if tariff and tariff.get("traffic_limit") else 0
device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else 0
key_subgroup = None
if tariff:
key_subgroup = tariff.get("subgroup_title")
await renew_key_in_cluster(
cluster_id=key.server_id,
email=key.email,
@@ -231,6 +238,8 @@ async def handle_key_extension(
session=session,
hwid_device_limit=device_limit,
reset_traffic=False,
target_subgroup=key_subgroup,
old_subgroup=key_subgroup,
)
await update_key_expiry(session, client_id, new_expiry)
await update_coupon_usage_count(session, coupon.id)
+2 -2
View File
@@ -10,7 +10,7 @@ from aiogram.types import (
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RUB_TO_XTR
from config import RUB_TO_XTR, STARS_BOT_URL
from handlers.buttons import BACK, MAIN_MENU
from logger import logger
@@ -31,7 +31,7 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url=STARS_BOT_URL))
builder.row(
InlineKeyboardButton(
text="💰 Ввести сумму доната",
+14
View File
@@ -3,8 +3,10 @@ from aiogram.types import InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import SUPPORT_CHAT_URL
from database import async_session_maker
from handlers.buttons import MAIN_MENU, SUPPORT
from handlers.texts import FALLBACK_MESSAGE
from hooks.hooks import run_hooks
fallback_router = Router()
@@ -12,6 +14,18 @@ fallback_router = Router()
@fallback_router.message(F.text)
async def handle_unhandled_messages(message: Message):
async with async_session_maker() as session:
await run_hooks(
"user_message",
user_id=message.from_user.id,
message_text=message.text,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
session=session,
message=message,
)
keyboard = InlineKeyboardBuilder()
keyboard.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
keyboard.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
+68 -16
View File
@@ -1,4 +1,5 @@
import os
import urllib.parse
from typing import Any
@@ -12,8 +13,9 @@ from config import (
DOWNLOAD_MACOS,
DOWNLOAD_PC,
SUPPORT_CHAT_URL,
WEBHOOK_HOST,
)
from database import get_key_details
from database import get_subscription_link
from handlers.buttons import (
BACK,
CONNECT_MACOS_BUTTON,
@@ -33,6 +35,7 @@ from handlers.texts import (
INSTRUCTION_MACOS,
INSTRUCTION_PC,
KEY_MESSAGE,
ROUTER_MESSAGE,
SUBSCRIPTION_DETAILS_TEXT,
)
from handlers.utils import edit_or_send_message
@@ -67,8 +70,8 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
@router.callback_query(F.data.startswith("connect_pc|"))
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
if not record:
key_link = await get_subscription_link(session, key_name)
if not key_link:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -82,7 +85,7 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}"))
builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
@@ -95,14 +98,25 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("windows_menu|"))
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
key_link = await get_subscription_link(session, key_name)
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_message_text = KEY_MESSAGE.format(key_link)
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}"))
if "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
windows_url = f"{WEBHOOK_HOST}/?url={processed_link}"
else:
processed_link = key_link
windows_url = f"{CONNECT_WINDOWS}{processed_link}"
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=windows_url))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -117,14 +131,25 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("macos_menu|"))
async def process_macos_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
key_link = await get_subscription_link(session, key_name)
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_message_text = KEY_MESSAGE.format(key_link)
instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}"))
if "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
macos_url = f"{WEBHOOK_HOST}/?url={processed_link}"
else:
processed_link = key_link
macos_url = f"{CONNECT_MACOS}{processed_link}"
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=macos_url))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -142,7 +167,7 @@ async def process_connect_tv(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -157,9 +182,8 @@ async def process_connect_tv(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("continue_tv|"))
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
subscription_link = record.get("key") or record.get("remnawave_link")
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
key_link = await get_subscription_link(session, key_name)
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
@@ -171,3 +195,31 @@ async def process_continue_tv(callback_query: CallbackQuery, session: Any):
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_router|"))
async def process_connect_router(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
key_link = await get_subscription_link(session, key_name)
if not key_link:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="❌ Ключ не найден.",
reply_markup=builder.as_markup(),
media_path=None,
)
return
message_text = ROUTER_MESSAGE.format(subscription_link=key_link)
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=message_text,
reply_markup=builder.as_markup(),
media_path=None,
)
+61 -28
View File
@@ -1,4 +1,5 @@
import os
import urllib.parse
from io import BytesIO
@@ -11,13 +12,14 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
APP_URL,
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
INSTRUCTIONS_BUTTON,
)
from database.models import Key
from database import Key, get_subscription_link
from handlers.buttons import (
ANDROID,
BACK,
@@ -38,6 +40,8 @@ from handlers.texts import (
SUBSCRIPTION_DESCRIPTION,
)
from handlers.utils import edit_or_send_message
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
@@ -45,7 +49,7 @@ router = Router()
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
async def handle_connect_device(callback_query: CallbackQuery, session: AsyncSession):
try:
key_name = callback_query.data.split("|")[1]
@@ -54,13 +58,27 @@ async def handle_connect_device(callback_query: CallbackQuery):
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}"))
try:
hook_builder = InlineKeyboardBuilder()
hook_builder.attach(builder)
hook_commands = await run_hooks(
"connect_device_menu", chat_id=callback_query.from_user.id, admin=False, session=session
)
if hook_commands:
hook_builder = insert_hook_buttons(hook_builder, hook_commands)
final_markup = hook_builder.as_markup()
except Exception as e:
logger.warning(f"[CONNECT_DEVICE] Ошибка при применении хуков: {e}")
final_markup = builder.as_markup()
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
reply_markup=final_markup,
media_path=None,
)
except Exception as e:
@@ -73,17 +91,12 @@ async def process_callback_connect_phone(callback_query: CallbackQuery, session:
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
row = result.scalar_one_or_none()
if not row:
key_link = await get_subscription_link(session, email)
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = row
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
logger.error(f"Ошибка при получении ссылки для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
@@ -94,10 +107,19 @@ async def process_callback_connect_phone(callback_query: CallbackQuery, session:
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 key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
crypto_url = f"{APP_URL}/?url={processed_link}"
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=crypto_url),
InlineKeyboardButton(text=IMPORT_ANDROID, url=crypto_url),
)
else:
processed_link = key_link
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{processed_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{processed_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
@@ -115,15 +137,12 @@ async def process_callback_connect_ios(callback_query: CallbackQuery, session: A
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
key_link = await get_subscription_link(session, email)
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
logger.error(f"Ошибка при получении ссылки для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
@@ -131,7 +150,15 @@ async def process_callback_connect_ios(callback_query: CallbackQuery, session: A
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 key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
ios_url = f"{APP_URL}/?url={processed_link}"
else:
processed_link = key_link
ios_url = f"{CONNECT_IOS}{processed_link}"
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=ios_url))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
@@ -150,15 +177,12 @@ async def process_callback_connect_android(callback_query: CallbackQuery, sessio
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
key_link = await get_subscription_link(session, email)
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
logger.error(f"Ошибка при получении ссылки для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
@@ -166,7 +190,15 @@ async def process_callback_connect_android(callback_query: CallbackQuery, sessio
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 key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe="")
android_url = f"{APP_URL}/?url={processed_link}"
else:
processed_link = key_link
android_url = f"{CONNECT_ANDROID}{processed_link}"
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=android_url))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
@@ -220,6 +252,7 @@ async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSessio
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
disable_cache=True,
)
os.remove(qr_path)
+4 -6
View File
@@ -5,9 +5,7 @@ from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import text
from config import TRIAL_CONFIG
from database import (
get_key_details,
get_servers,
@@ -16,7 +14,7 @@ from database import (
mark_key_as_unfrozen,
)
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
from handlers.keys.operations import renew_key_in_cluster, toggle_client_on_cluster
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
@@ -89,9 +87,9 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
tariff = await get_tariff_by_id(session, record["tariff_id"]) if record.get("tariff_id") else None
if not tariff:
logger.info("[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения.")
total_gb = TRIAL_CONFIG["traffic_limit_gb"]
hwid_limit = TRIAL_CONFIG["hwid_limit"]
logger.info("[Unfreeze] Тариф не найден — применяем дефолтные значения.")
total_gb = 0
hwid_limit = 0
else:
total_gb = int(tariff.get("traffic_limit") or 0)
hwid_limit = int(tariff.get("device_limit") or 0)
+84 -61
View File
@@ -15,7 +15,7 @@ from aiogram.types import (
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import CONNECT_PHONE_BUTTON, SUPPORT_CHAT_URL, TRIAL_CONFIG
from config import CONNECT_PHONE_BUTTON, REMNAWAVE_WEBAPP, SUPPORT_CHAT_URL
from database import (
get_key_details,
get_tariff_by_id,
@@ -29,19 +29,20 @@ from handlers.buttons import (
MAIN_MENU,
MY_SUB,
PC_BUTTON,
ROUTER_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_key_on_cluster
from handlers.keys.operations import create_key_on_cluster
from handlers.texts import key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
@@ -68,7 +69,7 @@ async def key_cluster_mode(
safe_to_edit = True
while True:
key_name = generate_random_email()
key_name = await generate_random_email(session=session)
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
@@ -84,10 +85,7 @@ async def key_cluster_mode(
device_limit = 0
traffic_limit_gb = 0
if is_trial:
device_limit = TRIAL_CONFIG.get("hwid_limit", 0)
traffic_limit_gb = TRIAL_CONFIG.get("traffic_limit_gb", 100)
elif plan:
if plan:
tariff = await get_tariff_by_id(session, plan)
if tariff:
if tariff.get("device_limit") is not None:
@@ -95,21 +93,28 @@ async def key_cluster_mode(
if tariff.get("traffic_limit") is not None:
traffic_limit_gb = int(tariff["traffic_limit"])
try:
least_loaded_cluster = await get_least_loaded_cluster(session)
except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}")
error_message = str(e)
forced_cluster_results = await run_hooks(
"cluster_override", tg_id=tg_id, state_data=data, session=session, plan=plan
)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
if forced_cluster_results and forced_cluster_results[0]:
least_loaded_cluster = forced_cluster_results[0]
else:
try:
least_loaded_cluster = await get_least_loaded_cluster(session)
except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}")
error_message = str(e)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
await create_key_on_cluster(
cluster_id=least_loaded_cluster,
@@ -143,7 +148,6 @@ async def key_cluster_mode(
tariff = await get_tariff_by_id(session, data["tariff_id"])
if tariff:
await update_balance(session, tg_id, -tariff["price_rub"])
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
@@ -159,22 +163,57 @@ async def key_cluster_mode(
await bot.send_message(chat_id=tg_id, text=error_message)
return
vless_enabled = False
try:
if plan:
ti = await get_tariff_by_id(session, plan)
vless_enabled = bool(ti.get("vless")) if ti else False
elif key_record.get("tariff_id"):
ti = await get_tariff_by_id(session, key_record["tariff_id"])
vless_enabled = bool(ti.get("vless")) if ti else False
except Exception:
vless_enabled = False
builder = InlineKeyboardBuilder()
if await is_full_remnawave_cluster(least_loaded_cluster, session):
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
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}"),
)
if vless_enabled:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
if await is_full_remnawave_cluster(least_loaded_cluster, session):
if REMNAWAVE_WEBAPP and final_link:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
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=MY_SUB, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
try:
intercept_results = await run_hooks(
"intercept_key_creation_message", chat_id=tg_id, session=session, target_message=message_or_query
)
if intercept_results and intercept_results[0]:
return
except Exception as e:
logger.warning(f"[INTERCEPT_KEY_CREATION] Ошибка при применении хуков: {e}")
try:
hook_commands = await run_hooks(
"key_creation_complete", chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name
)
if hook_commands:
builder = insert_hook_buttons(builder, hook_commands)
except Exception as e:
logger.warning(f"[KEY_CREATION_COMPLETE] Ошибка при применении хуков: {e}")
expiry_time_local = expiry_time.astimezone(moscow_tz)
expiry_time_local - datetime.now(moscow_tz)
@@ -182,30 +221,16 @@ async def key_cluster_mode(
if plan:
tariff_info = await get_tariff_by_id(session, plan)
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
)
else:
tariff_duration = tariff_info["name"]
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
tariff_duration = tariff_info["name"]
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
subgroup_title=subgroup_title,
)
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
subgroup_title=subgroup_title,
)
default_media_path = "img/pic.jpg"
if safe_to_edit:
@@ -216,11 +241,9 @@ async def key_cluster_mode(
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
await bot.send_message(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
text=key_message_text,
reply_markup=builder.as_markup(),
)
+258 -148
View File
@@ -20,36 +20,49 @@ from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
PUBLIC_LINK,
HAPP_CRYPTOLINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
REMNAWAVE_WEBAPP,
SUPPORT_CHAT_URL,
TRIAL_CONFIG,
)
from database import (
add_user,
check_server_name_by_cluster,
check_user_exists,
filter_cluster_by_subgroup,
get_key_details,
get_tariff_by_id,
get_trial,
update_balance,
update_trial,
)
from database.models import Key, Server, Tariff
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, MY_SUB, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_client_on_server
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
MY_SUB,
PC_BUTTON,
ROUTER_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.operations import create_client_on_server
from handlers.keys.operations.aggregated_links import make_aggregated_link
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance
from panels._3xui import delete_client, get_xui_instance
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
router = Router()
@@ -79,30 +92,41 @@ async def key_country_mode(
target_message = message_or_query
safe_to_edit = True
try:
least_loaded_cluster = await get_least_loaded_cluster(session)
except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}")
text = str(e)
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
data = await state.get_data() if state else {}
result = await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
).where(Server.cluster_name == least_loaded_cluster)
forced_cluster_results = await run_hooks(
"cluster_override", tg_id=tg_id, state_data=data, session=session, plan=plan
)
servers = result.mappings().all()
if forced_cluster_results and forced_cluster_results[0]:
least_loaded_cluster = forced_cluster_results[0]
else:
try:
least_loaded_cluster = await get_least_loaded_cluster(session)
except ValueError as e:
text = str(e)
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
subgroup_title = None
if plan:
tariff = await get_tariff_by_id(session, plan)
if tariff:
subgroup_title = tariff.get("subgroup_title")
q = select(
Server.id,
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
).where(Server.cluster_name == least_loaded_cluster)
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
if not servers:
logger.error(f"❌ Нет серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
@@ -110,16 +134,25 @@ async def key_country_mode(
await bot.send_message(chat_id=tg_id, text=text)
return
if subgroup_title:
servers = await filter_cluster_by_subgroup(session, servers, subgroup_title, least_loaded_cluster)
if not servers:
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
available_servers = []
tasks = [asyncio.create_task(check_server_availability(server, session)) for server in servers]
tasks = [asyncio.create_task(check_server_availability(dict(server), session)) for server in servers]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
for server, result_ok in zip(servers, results, strict=False):
if result_ok is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.warning(f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
@@ -127,8 +160,6 @@ async def key_country_mode(
await bot.send_message(chat_id=tg_id, text=text)
return
logger.info(f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for server_name in available_servers:
@@ -170,7 +201,6 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(session, current_server)
@@ -180,53 +210,60 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
cluster_name = cluster_info["cluster_name"]
servers = (
(
await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
key_tariff_id = record.get("tariff_id")
subgroup_title = None
if key_tariff_id:
res = await session.execute(select(Tariff.subgroup_title).where(Tariff.id == key_tariff_id))
subgroup_title = res.scalar_one_or_none()
q = (
select(
Server.id,
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.mappings()
.all()
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
if subgroup_title:
servers = await filter_cluster_by_subgroup(session, servers, subgroup_title.strip(), cluster_name)
if not servers:
await callback_query.answer("❌ Доступных серверов в этой подгруппе нет", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
"panel_type": server["panel_type"],
"enabled": server.get("enabled", True),
"max_keys": server.get("max_keys"),
}
task = asyncio.create_task(check_server_availability(server_info, session))
tasks.append(task)
tasks = [
asyncio.create_task(
check_server_availability(
{
"server_name": s["server_name"],
"api_url": s["api_url"],
"panel_type": s["panel_type"],
"enabled": s.get("enabled", True),
"max_keys": s.get("max_keys"),
},
session,
)
)
for s in servers
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
for server, result_ok in zip(servers, results, strict=False):
if result_ok 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}"
@@ -246,12 +283,6 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, 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("❌ Некорректные данные. Попробуйте снова.")
@@ -264,23 +295,41 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any,
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,
)
fsm_data = await state.get_data()
if fsm_data.get("creating_key"):
try:
await callback_query.answer("⏳ Уже обрабатываю…")
except Exception:
pass
return
await state.update_data(creating_key=True)
try:
await callback_query.answer("Обрабатываю…")
if callback_query.message:
await callback_query.message.edit_reply_markup(reply_markup=None)
except Exception:
pass
try:
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
await finalize_key_creation(
tg_id,
expiry_time,
selected_country,
state,
session,
callback_query,
old_key_name,
)
finally:
fsm_data = await state.get_data()
if fsm_data.get("creating_key"):
await state.update_data(creating_key=False)
async def finalize_key_creation(
@@ -305,7 +354,6 @@ async def finalize_key_creation(
language_code=from_user.language_code,
is_bot=from_user.is_bot,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
expiry_time = expiry_time.astimezone(moscow_tz)
@@ -314,7 +362,6 @@ async def finalize_key_creation(
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"]
@@ -322,7 +369,7 @@ async def finalize_key_creation(
tariff_id = old_key_details.get("tariff_id") or tariff_id
else:
while True:
key_name = generate_random_email()
key_name = await generate_random_email(session=session)
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
@@ -335,10 +382,7 @@ async def finalize_key_creation(
data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False)
if is_trial:
traffic_limit_bytes = int(TRIAL_CONFIG.get("traffic_limit_gb", 100)) * 1024**3
device_limit = TRIAL_CONFIG.get("hwid_limit", 0)
elif data.get("tariff_id") or tariff_id:
if data.get("tariff_id") or tariff_id:
tariff_id = data.get("tariff_id") or tariff_id
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
@@ -347,6 +391,10 @@ async def finalize_key_creation(
traffic_limit_bytes = int(tariff.traffic_limit) * 1024**3
if tariff.device_limit is not None:
device_limit = int(tariff.device_limit)
else:
tariff = None
need_vless_key = bool(getattr(tariff, "vless", False)) if tariff else False
public_link = None
remnawave_link = None
@@ -358,12 +406,12 @@ async def finalize_key_creation(
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден")
panel_type = server_info.panel_type.lower()
cluster_info = await check_server_name_by_cluster(session, server_info.server_name)
if not cluster_info:
raise ValueError(f"Кластер для сервера {server_info.server_name} не найден")
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
cluster_name = cluster_info["cluster_name"]
is_full_remnawave = await is_full_remnawave_cluster(cluster_name, session)
if old_key_name:
old_server_id = old_key_details["server_id"]
@@ -379,9 +427,9 @@ async def finalize_key_creation(
update(Key).where(Key.tg_id == tg_id, Key.email == email).values(key=None)
)
elif old_server_info.panel_type.lower() == "remnawave":
remna = RemnawaveAPI(old_server_info.api_url)
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
remna_del = RemnawaveAPI(old_server_info.api_url)
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna_del.delete_user(client_id)
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
@@ -390,6 +438,8 @@ async def finalize_key_creation(
except Exception as e:
logger.warning(f"[Delete] Ошибка при удалении клиента: {e}")
panel_type = server_info.panel_type.lower()
if panel_type == "remnawave" or is_full_remnawave:
remna = RemnawaveAPI(server_info.api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
@@ -401,7 +451,8 @@ async def finalize_key_creation(
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": [server_info.inbound_id],
"activeInternalSquads": [server_info.inbound_id],
"uuid": client_id,
}
if traffic_limit_bytes:
user_data["trafficLimitBytes"] = traffic_limit_bytes
@@ -412,8 +463,36 @@ async def finalize_key_creation(
if not result:
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
client_id = result.get("uuid")
remnawave_link = result.get("subscriptionUrl")
client_id = result.get("uuid") or result.get("id") or client_id
remnawave_link = None
if need_vless_key:
try:
vless_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
except Exception:
vless_link = None
if vless_link:
remnawave_link = vless_link
if not remnawave_link:
try:
sub = await remna.get_subscription_by_username(email)
except Exception:
sub = None
if sub:
if need_vless_key and not remnawave_link:
links = sub.get("links") or []
remnawave_link = next(
(l for l in links if isinstance(l, str) and l.lower().startswith("vless://")), None
)
if not remnawave_link:
if HAPP_CRYPTOLINK:
happ = sub.get("happ") or {}
remnawave_link = happ.get("cryptoLink") or happ.get("link")
if not remnawave_link:
remnawave_link = sub.get("subscriptionUrl")
if old_key_name:
await session.execute(
@@ -438,15 +517,40 @@ async def finalize_key_creation(
plan=tariff_id,
is_trial=is_trial,
)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
subgroup_code = tariff.subgroup_title if tariff and tariff.subgroup_title else None
cluster_all = [
{
"server_name": server_info.server_name,
"api_url": server_info.api_url,
"panel_type": server_info.panel_type,
"inbound_id": getattr(server_info, "inbound_id", None),
"enabled": True,
"max_keys": getattr(server_info, "max_keys", None),
}
]
link_to_show = await make_aggregated_link(
session=session,
cluster_all=cluster_all,
cluster_id=cluster_name,
email=email,
client_id=client_id,
tg_id=tg_id,
subgroup_code=subgroup_code,
remna_link_override=remnawave_link,
plan=tariff_id,
)
public_link = link_to_show
if old_key_name:
update_data = {"server_id": selected_country}
if panel_type == "3x-ui":
update_data = {"server_id": selected_country, "key": None, "remnawave_link": None}
if public_link and public_link.startswith("vless://"):
update_data["key"] = public_link
elif panel_type == "remnawave":
elif public_link and public_link.startswith("http"):
update_data["key"] = public_link
if remnawave_link:
update_data["remnawave_link"] = remnawave_link
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.email == email).values(**update_data))
else:
@@ -456,18 +560,16 @@ async def finalize_key_creation(
email=email,
created_at=created_at,
expiry_time=expiry_timestamp,
key=public_link,
key=public_link if public_link else None,
remnawave_link=remnawave_link,
server_id=selected_country,
tariff_id=tariff_id,
)
session.add(new_key)
if is_trial:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
await update_trial(session, tg_id, 1)
if tariff_id:
result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id))
row = result.scalar_one_or_none()
@@ -482,15 +584,24 @@ async def finalize_key_creation(
return
builder = InlineKeyboardBuilder()
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if (panel_type == "remnawave" or is_full_remnawave) and (public_link or remnawave_link):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=public_link or remnawave_link),
)
)
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
is_full_remnawave = await is_full_remnawave_cluster(cluster_name, session)
is_vless = bool(public_link and public_link.lower().startswith("vless://")) or bool(need_vless_key)
final_link = public_link or remnawave_link
webapp_url = (
final_link
if isinstance(final_link, str) and final_link.strip().lower().startswith(("http://", "https://"))
else None
)
if panel_type == "remnawave" or is_full_remnawave:
if is_vless:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
else:
if REMNAWAVE_WEBAPP and webapp_url:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=webapp_url)))
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
@@ -504,37 +615,36 @@ async def finalize_key_creation(
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
tariff_info = None
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff_info = result.scalar_one_or_none()
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
try:
intercept_results = await run_hooks(
"intercept_key_creation_message", chat_id=tg_id, session=session, target_message=callback_query
)
else:
tariff_duration = tariff_info["name"] if tariff_info else None
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
if intercept_results and intercept_results[0]:
return
except Exception as e:
logger.warning(f"[INTERCEPT_KEY_CREATION] Ошибка при применении хуков: {e}")
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
subgroup_title=subgroup_title,
try:
hook_commands = await run_hooks(
"key_creation_complete", chat_id=tg_id, admin=False, session=session, email=email, key_name=key_name
)
if hook_commands:
builder = insert_hook_buttons(builder, hook_commands)
except Exception as e:
logger.warning(f"[KEY_CREATION_COMPLETE] Ошибка при применении хуков: {e}")
t = tariff.name if tariff else ""
subgroup_title = tariff.subgroup_title if tariff and tariff.subgroup_title else ""
traffic = tariff.traffic_limit if tariff and tariff.traffic_limit else 0
devices = tariff.device_limit if tariff and tariff.device_limit else 0
key_message_text = key_message_success(
public_link or remnawave_link or "Ссылка не найдена",
tariff_name=t,
traffic_limit=traffic,
device_limit=devices,
subgroup_title=subgroup_title,
)
await edit_or_send_message(
target_message=callback_query.message,
+179 -46
View File
@@ -10,11 +10,10 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
DISCOUNT_ACTIVE_HOURS,
NOTIFY_EXTRA_DAYS,
TRIAL_CONFIG,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
@@ -22,26 +21,29 @@ from config import (
from database import (
add_user,
check_user_exists,
create_temporary_data,
get_balance,
get_tariff_by_id,
get_tariffs_for_cluster,
get_trial,
)
from database.models import Admin
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from database.notifications import check_hot_lead_discount
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.admin.panel.keyboard import AdminPanelCallback
from handlers.buttons import MAIN_MENU, PAYMENT
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.payments.currency_rates import format_for_user
from handlers.payments.fast_payment_flow import try_fast_payment_flow
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNT_OFFER_MESSAGE,
DISCOUNT_OFFER_STEP2,
DISCOUNT_OFFER_STEP3,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message, get_least_loaded_cluster
from handlers.utils import edit_or_send_message, format_discount_time_left, get_least_loaded_cluster
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
from .key_cluster_mode import key_cluster_mode
@@ -90,7 +92,23 @@ async def handle_key_creation(
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
base_days = TRIAL_CONFIG["duration_days"]
trial_tariffs = await get_tariffs(session, group_code="trial")
if not trial_tariffs:
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
),
text="❌ Пробная подписка временно недоступна.",
reply_markup=InlineKeyboardBuilder()
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
.as_markup(),
)
return
trial_tariff = trial_tariffs[0]
base_days = trial_tariff["duration_days"]
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
total_days = base_days + extra_days
expiry_time = current_time + timedelta(days=total_days)
@@ -105,8 +123,8 @@ async def handle_key_creation(
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
await state.update_data(is_trial=True, plan=trial_tariff["id"])
await create_key(tg_id, expiry_time, state, session, message_or_query, plan=trial_tariff["id"])
return
try:
@@ -126,6 +144,51 @@ async def handle_key_creation(
tariffs = await get_tariffs_for_cluster(session, cluster_name)
language_code = (
message_or_query.from_user.language_code
if not isinstance(message_or_query, CallbackQuery)
else message_or_query.from_user.language_code
)
discount_info = None
subgroup_weights = {}
if tariffs:
group_code = tariffs[0].get("group_code")
if group_code:
from database.notifications import check_hot_lead_discount
discount_info = await check_hot_lead_discount(session, tg_id)
if discount_info and discount_info.get("available"):
group_code = discount_info["tariff_group"]
await state.update_data(discount_info=discount_info)
else:
await state.update_data(discount_info=None)
try:
hook_results = await run_hooks(
"purchase_tariff_group_override",
chat_id=tg_id,
admin=False,
session=session,
original_group=group_code,
)
for hook_result in hook_results:
if hook_result.get("override_group"):
group_code = hook_result["override_group"]
logger.info(f"[PURCHASE] Тарифная группа переопределена хуком: {group_code}")
if hook_result.get("discount_info"):
await state.update_data(discount_info=hook_result["discount_info"])
break
except Exception as e:
logger.warning(f"[PURCHASE] Ошибка при применении хуков переопределения группы: {e}")
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"]
if not tariffs:
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
is_admin = result.scalar_one_or_none() is not None
@@ -179,14 +242,20 @@ async def handle_key_creation(
builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []):
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
text=f"{t['name']}{price_txt}",
callback_data=f"select_tariff_plan|{t['id']}",
)
)
for subgroup in sorted(k for k in grouped_tariffs if k):
sorted_subgroups = sorted(
[k for k in grouped_tariffs if k],
key=lambda x: (subgroup_weights.get(x, 999999) if subgroup_weights else 999999, x),
)
for subgroup in sorted_subgroups:
subgroup_hash = create_subgroup_hash(subgroup, group_code)
builder.row(
InlineKeyboardButton(
@@ -195,13 +264,28 @@ async def handle_key_creation(
)
)
tariff_menu_buttons = await run_hooks(
"tariff_menu", group_code=group_code, cluster_name=cluster_name, tg_id=tg_id, session=session
)
builder = insert_hook_buttons(builder, tariff_menu_buttons)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
discount_message = ""
if discount_info and discount_info.get("available"):
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
expires_at = discount_info["expires_at"]
time_left = format_discount_time_left(
expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS
)
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
text=SELECT_TARIFF_PLAN_MSG + discount_message,
reply_markup=builder.as_markup(),
)
@@ -229,7 +313,13 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
return
tariffs = await get_tariffs_for_cluster(session, cluster_name)
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup]
filtered = []
if tariffs:
group_code = tariffs[0].get("group_code")
if group_code:
tariffs = await get_tariffs(session, group_code=group_code)
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
if not filtered:
await edit_or_send_message(
@@ -239,11 +329,15 @@ async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMConte
)
return
tg_id = callback.from_user.id
language_code = callback.from_user.language_code
builder = InlineKeyboardBuilder()
for t in filtered:
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
text=f"{t['name']}{price_txt}",
callback_data=f"select_tariff_plan|{t['id']}",
)
)
@@ -276,45 +370,84 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
await edit_or_send_message(
target_message=callback_query.message,
text="❌ Указанный тариф не найден.",
)
await callback_query.answer()
return
discount_info = await check_hot_lead_discount(session, tg_id)
if tariff.get("group_code") in ["discounts", "discounts_max"]:
if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
reply_markup=builder.as_markup(),
)
await callback_query.answer()
return
try:
hook_results = await run_hooks(
"check_discount_validity",
chat_id=tg_id,
admin=False,
session=session,
tariff_group=tariff.get("group_code"),
)
for hook_result in hook_results:
if not hook_result.get("valid", True):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=hook_result.get("message", "❌ Скидка недоступна. Пожалуйста, выберите тариф заново."),
reply_markup=builder.as_markup(),
)
await callback_query.answer()
return
except Exception as e:
logger.warning(f"[PURCHASE] Ошибка при проверке скидок через хуки: {e}")
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
balance = await get_balance(session, tg_id)
price_rub = tariff["price_rub"]
if balance < price_rub:
required_amount = ceil(price_rub - balance)
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"tariff_id": tariff_id,
"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(message=callback_query, session=session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(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(),
if USE_NEW_PAYMENT_FLOW:
handled = await try_fast_payment_flow(
callback_query,
session,
state,
tg_id=tg_id,
temp_key="waiting_for_payment",
temp_payload={
"tariff_id": tariff_id,
"duration_days": duration_days,
"required_amount": required_amount,
},
required_amount=required_amount,
)
if handled:
return
language_code = getattr(callback_query.from_user, "language_code", None)
required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code)
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_text),
reply_markup=builder.as_markup(),
)
return
builder = InlineKeyboardBuilder()
@@ -324,6 +457,7 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
await callback_query.answer()
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(tariff_id=tariff_id)
@@ -351,7 +485,6 @@ async def create_key(
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
if USE_COUNTRY_SELECTION:
await key_country_mode(
+54 -18
View File
@@ -1,15 +1,18 @@
from datetime import datetime, timedelta
from aiogram import F, Router
from aiogram.types import CallbackQuery
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from database import get_tariffs
from database import get_keys, get_tariffs
from database.models import Notification
from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION
from handlers.notifications.notify_kb import build_tariffs_keyboard
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
from handlers.utils import format_discount_time_left
from logger import logger
from .key_create import select_tariff_plan
@@ -39,15 +42,32 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
return
tariffs = await get_tariffs(session=session, group_code="discounts")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
keys = await get_keys(session, tg_id)
await callback.message.edit_text(
DISCOUNT_TARIFF,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
if keys and len(keys) > 0:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
await callback.message.edit_text(
f"🎯 <b>ЭКСКЛЮЗИВНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
f"💎 <b>Специальные тарифы</b> — доступные только для вас!\n"
f"🚀 <b>Получите максимум возможностей</b> по выгодной цене!\n"
f"</blockquote>\n"
f"⏰ <b>Предложение действует всего: {format_discount_time_left(expires_at, DISCOUNT_ACTIVE_HOURS)} — не упустите свой шанс!</b>",
reply_markup=builder.as_markup(),
)
else:
tariffs = await get_tariffs(session=session, group_code="discounts")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
@router.callback_query(F.data.startswith("discount_tariff|"))
@@ -89,12 +109,28 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
return
tariffs = await get_tariffs(session, group_code="discounts_max")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
keys = await get_keys(session, tg_id)
await callback.message.edit_text(
DISCOUNT_TARIFF_MAX,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
if keys and len(keys) > 0:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback.message.edit_text(
f"🎯 <b>УНИКАЛЬНОЕ ФИНАЛЬНОЕ ПРЕДЛОЖЕНИЕ!</b>\n\n<blockquote>"
f"💎 <b>Доступ к тарифам с МАКСИМАЛЬНОЙ выгодой</b> — только для вас!\n"
f"🚀 <b>Уникальные условия</b> — получите максимум преимуществ по минимальной цене!\n"
f"</blockquote>\n"
f"⏰ <b>Время ограничено: {format_discount_time_left(last_time, DISCOUNT_ACTIVE_HOURS)} — не упустите шанс!</b>",
reply_markup=builder.as_markup(),
)
else:
tariffs = await get_tariffs(session, group_code="discounts_max")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF_MAX,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
+276 -82
View File
@@ -13,10 +13,9 @@ from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import USE_NEW_PAYMENT_FLOW
from config import DISCOUNT_ACTIVE_HOURS, RENEW_BUTTON_BEFORE_DAYS, USE_NEW_PAYMENT_FLOW
from database import (
check_tariff_exists,
create_temporary_data,
get_balance,
get_key_by_server,
get_key_details,
@@ -26,20 +25,24 @@ from database import (
update_key_expiry,
)
from database.models import Key, Server
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from database.notifications import check_hot_lead_discount
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.keys.operations import renew_key_in_cluster
from handlers.payments.currency_rates import format_for_user
from handlers.payments.fast_payment_flow import try_fast_payment_flow
from handlers.texts import (
DISCOUNT_OFFER_MESSAGE,
DISCOUNT_OFFER_STEP2,
DISCOUNT_OFFER_STEP3,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
get_renewal_message,
)
from handlers.utils import edit_or_send_message, format_days, format_months, get_russian_month
from handlers.utils import edit_or_send_message, format_discount_time_left, get_russian_month
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
@@ -63,6 +66,31 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
server_id = record["server_id"]
tariff_id = record.get("tariff_id")
expiry_utc = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=pytz.UTC)
available_from_utc = expiry_utc - timedelta(days=RENEW_BUTTON_BEFORE_DAYS)
now_utc = datetime.utcnow().replace(tzinfo=pytz.UTC)
if now_utc < available_from_utc:
dt_msk = available_from_utc.astimezone(moscow_tz).strftime("%d.%m.%Y %H:%M")
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
try:
hook_commands = await run_hooks(
"process_callback_renew_key", callback_query=callback_query, state=state, session=session
)
if hook_commands:
kb = insert_hook_buttons(kb, hook_commands)
except Exception as e:
logger.warning(f"[RENEW] Ошибка при применении хуков: {e}")
await edit_or_send_message(
target_message=callback_query.message,
text=f"Продление доступно с {dt_msk}",
reply_markup=kb.as_markup(),
)
return
await state.update_data(renew_key_name=key_name, renew_client_id=client_id)
logger.info(f"[RENEW] Получение тарифов для server_id={server_id}")
@@ -92,11 +120,43 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
if tariff_id:
if await check_tariff_exists(session, tariff_id):
current_tariff = await get_tariff_by_id(session, tariff_id)
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts"]:
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
try:
hook_results = await run_hooks(
"renewal_forbidden_groups", chat_id=tg_id, admin=False, session=session
)
for hook_result in hook_results:
additional_groups = hook_result.get("additional_groups", [])
forbidden_groups.extend(additional_groups)
except Exception as e:
logger.warning(f"[RENEW] Ошибка при получении дополнительных групп: {e}")
if current_tariff["group_code"] not in forbidden_groups:
group_code = current_tariff["group_code"]
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if t["is_active"]]
discount_info = await check_hot_lead_discount(session, tg_id)
if discount_info.get("available"):
group_code = discount_info["tariff_group"]
try:
hook_results = await run_hooks(
"purchase_tariff_group_override", chat_id=tg_id, admin=False, session=session, original_group=group_code
)
for hook_result in hook_results:
if hook_result.get("override_group"):
group_code = hook_result["override_group"]
logger.info(f"[RENEW] Тарифная группа переопределена хуком для продления: {group_code}")
break
except Exception as e:
logger.warning(f"[RENEW] Ошибка при применении хуков переопределения группы: {e}")
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"]
if not tariffs:
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
return
@@ -108,15 +168,20 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
builder = InlineKeyboardBuilder()
language_code = getattr(callback_query.from_user, "language_code", None)
for t in grouped_tariffs.get(None, []):
price_text = await format_for_user(session, tg_id, t["price_rub"], language_code)
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
text=f"{t['name']}{price_text}",
callback_data=f"renew_plan|{t['id']}",
)
)
for subgroup in sorted(k for k in grouped_tariffs if k):
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
for subgroup in sorted_subgroups:
subgroup_hash = create_subgroup_hash(subgroup, group_code)
builder.row(
InlineKeyboardButton(
@@ -125,18 +190,45 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="renew_menu"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
balance = await get_balance(session, tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
try:
hook_builder = InlineKeyboardBuilder()
hook_builder.attach(builder)
hook_commands = await run_hooks("renew_tariffs", chat_id=tg_id, admin=False, session=session)
if hook_commands:
hook_builder = insert_hook_buttons(hook_builder, hook_commands)
final_markup = hook_builder.as_markup()
except Exception as e:
logger.warning(f"[RENEW] Ошибка при применении хуков: {e}")
final_markup = builder.as_markup()
balance_rub = await get_balance(session, tg_id) or 0
balance = await format_for_user(session, tg_id, balance_rub, language_code)
discount_message = ""
if discount_info.get("available"):
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
expires_at = discount_info["expires_at"]
time_left = format_discount_time_left(
expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS
)
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
response_message = (
PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
+ discount_message
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
reply_markup=final_markup,
)
except Exception as e:
@@ -185,6 +277,25 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
group_code = row[0]
tg_id = callback.from_user.id
language_code = callback.from_user.language_code
discount_info = await check_hot_lead_discount(session, tg_id)
if discount_info.get("available"):
group_code = discount_info["tariff_group"]
try:
hook_results = await run_hooks(
"purchase_tariff_group_override", chat_id=tg_id, admin=False, session=session, original_group=group_code
)
for hook_result in hook_results:
if hook_result.get("override_group"):
group_code = hook_result["override_group"]
logger.info(f"[RENEW_SUBGROUP] Тарифная группа переопределена хуком: {group_code}")
break
except Exception as e:
logger.warning(f"[RENEW_SUBGROUP] Ошибка при применении хуков переопределения группы: {e}")
subgroup = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup:
await callback.message.answer("❌ Подгруппа не найдена.")
@@ -203,9 +314,10 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
builder = InlineKeyboardBuilder()
for t in filtered:
price_txt = await format_for_user(session, tg_id, t.get("price_rub", 0), language_code)
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
text=f"{t['name']}{price_txt}",
callback_data=f"renew_plan|{t['id']}",
)
)
@@ -213,10 +325,34 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"renew_key|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
try:
hook_builder = InlineKeyboardBuilder()
hook_builder.attach(builder)
hook_commands = await run_hooks(
"renew_tariffs", chat_id=callback.from_user.id, admin=False, session=session
)
if hook_commands:
hook_builder = insert_hook_buttons(hook_builder, hook_commands)
final_markup = hook_builder.as_markup()
except Exception as e:
logger.warning(f"[RENEW_SUBGROUP] Ошибка при применении хуков: {e}")
final_markup = builder.as_markup()
discount_message = ""
if discount_info.get("available"):
offer_text = DISCOUNT_OFFER_STEP2 if discount_info["type"] == "hot_lead_step_2" else DISCOUNT_OFFER_STEP3
expires_at = discount_info["expires_at"]
time_left = format_discount_time_left(
expires_at - timedelta(hours=DISCOUNT_ACTIVE_HOURS), DISCOUNT_ACTIVE_HOURS
)
discount_message = DISCOUNT_OFFER_MESSAGE.format(offer_text=offer_text, time_left=time_left)
await edit_or_send_message(
target_message=callback.message,
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
reply_markup=builder.as_markup(),
text=f"<b>{subgroup}</b>\n\nВыберите тариф:{discount_message}",
reply_markup=final_markup,
)
except Exception as e:
@@ -244,6 +380,17 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
await callback_query.message.answer("❌ Тариф не найден или отключён.")
return
discount_info = await check_hot_lead_discount(session, tg_id)
if tariff.get("group_code") in ["discounts", "discounts_max"]:
if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback_query.message.answer(
"❌ Скидка недоступна или истекла. Пожалуйста, выберите тариф заново.",
reply_markup=builder.as_markup(),
)
return
duration_days = tariff["duration_days"]
cost = tariff["price_rub"]
total_gb = tariff["traffic_limit"] or 0
@@ -265,42 +412,42 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
balance = round(await get_balance(session, tg_id), 2)
cost = round(cost, 2)
if balance < cost:
required_amount = ceil(cost - balance)
logger.info(f"[RENEW] Недостаточно средств: {required_amount}")
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"tariff_id": tariff_id,
"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":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(message=callback_query, session=session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(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_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
if USE_NEW_PAYMENT_FLOW:
handled = await try_fast_payment_flow(
callback_query,
session,
state,
tg_id=tg_id,
temp_key="waiting_for_renewal_payment",
temp_payload={
"tariff_id": tariff_id,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
required_amount=required_amount,
)
if handled:
return
language_code = getattr(callback_query.from_user, "language_code", None)
required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code)
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_text),
reply_markup=builder.as_markup(),
)
return
logger.info(f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней")
@@ -345,16 +492,27 @@ async def complete_key_renewal(
try:
logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)")
waiting_message = None
wait_text = "⏳ Подождите. Идет продление подписки…"
try:
if callback_query:
await edit_or_send_message(
target_message=callback_query.message,
text=wait_text,
reply_markup=None,
)
else:
waiting_message = await bot.send_message(tg_id, wait_text)
except Exception as e:
logger.warning(f"[Renew] Не удалось показать экран ожидания: {e}")
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)),
@@ -368,45 +526,81 @@ async def complete_key_renewal(
subgroup_title=tariff.get("subgroup_title", ""),
)
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
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())
key_info = await get_key_details(session, email)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
return
current_subgroup = None
try:
current_tariff_id = key_info.get("tariff_id")
if current_tariff_id:
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
if current_tariff:
current_subgroup = current_tariff.get("subgroup_title")
except Exception as e:
logger.warning(f"[Renew] Не удалось определить текущую подгруппу: {e}")
target_subgroup = tariff.get("subgroup_title")
old_subgroup = current_subgroup
server_or_cluster = key_info["server_id"]
cluster_id = await resolve_cluster_name(session, server_or_cluster)
if not cluster_id:
logger.error(f"[Error] Кластер для {server_or_cluster} не найден.")
return
await renew_key_in_cluster(
cluster_id,
email,
client_id,
new_expiry_time,
total_gb,
session,
hwid_device_limit=tariff["device_limit"],
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
session=session,
hwid_device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
reset_traffic=True,
target_subgroup=target_subgroup,
old_subgroup=old_subgroup,
plan=tariff_id,
)
await update_key_expiry(session, client_id, new_expiry_time)
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
key_row = await get_key_details(session, email)
effective_client_id = key_row["client_id"] if key_row else client_id
await update_key_expiry(session, effective_client_id, new_expiry_time)
await session.execute(update(Key).where(Key.email == email).values(tariff_id=tariff_id))
await update_balance(session, tg_id, -cost)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
try:
hook_commands = await run_hooks(
"renewal_complete", chat_id=tg_id, admin=False, session=session, email=email, client_id=client_id
)
if hook_commands:
builder = insert_hook_buttons(builder, hook_commands)
except Exception as e:
logger.warning(f"[RENEWAL_COMPLETE] Ошибка при применении хуков: {e}")
try:
if callback_query:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
elif waiting_message:
await edit_or_send_message(
target_message=waiting_message,
text=response_message,
reply_markup=builder.as_markup(),
)
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
except Exception as e:
logger.error(f"[Error] Ошибка при выводе финального сообщения: {e}")
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
except Exception as e:
-993
View File
@@ -1,993 +0,0 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE, TRIAL_CONFIG
from database import delete_notification, get_servers, get_tariff_by_id, store_key
from database.models import Key, Server, Tariff
from handlers.utils import check_server_key_limit, get_least_loaded_cluster
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import (
ClientConfig,
add_client,
delete_client,
extend_client_key,
get_client_traffic,
get_xui_instance,
toggle_client,
)
async def create_key_on_cluster(
cluster_id: str,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
plan: int = None,
session: AsyncSession = None,
remnawave_link: str = None,
hwid_limit: int = None,
traffic_limit_bytes: int = None,
is_trial: bool = False,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
server_id_to_store = cluster_id
if not cluster:
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
enabled_servers = [s for s in cluster if s.get("enabled", True)]
if not enabled_servers:
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
return
if plan is not None and traffic_limit_bytes is None:
tariff = await get_tariff_by_id(session, plan)
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
traffic_limit_bytes = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None
if hwid_limit is None and tariff.get("device_limit") is not None:
hwid_limit = int(tariff["device_limit"])
remnawave_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, session)
]
xui_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, session)
]
if not remnawave_servers and not xui_servers:
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
return
semaphore = asyncio.Semaphore(2)
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:
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes * 1024 * 1024 * 1024
if short_uuid:
user_data["shortUuid"] = short_uuid
if hwid_limit is not None:
user_data["hwidDeviceLimit"] = hwid_limit
logger.info(f"[Key Creation] Данные для создания клиента в Remnawave: {user_data}")
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
logger.info(f"[Debug] 3x-ui servers для кластера {cluster_id}: {[s['server_name'] for s in xui_servers]}")
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,
session=session,
is_trial=is_trial,
)
else:
await asyncio.gather(
*[
create_client_on_server(
server,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
session=session,
is_trial=is_trial,
)
for server in xui_servers
],
return_exceptions=True,
)
if (remnawave_created and remnawave_client_id) or xui_servers:
await store_key(
session=session,
tg_id=tg_id,
client_id=final_client_id,
email=email,
expiry_time=expiry_timestamp,
key=public_link,
server_id=server_id_to_store,
remnawave_link=remnawave_key,
tariff_id=plan,
)
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
raise e
async def create_client_on_server(
server_info: dict,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
semaphore: asyncio.Semaphore,
plan: int = None,
session=None,
is_trial: bool = False,
):
"""
Создает клиента на указанном 3x-ui сервере с лимитом по тарифу или триалу.
"""
logger.info(
f"[Client] Вход в create_client_on_server: сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}"
)
async with semaphore:
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"[Client] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
return
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
total_gb_value = 0
device_limit_value = 0
if is_trial:
total_gb_value = TRIAL_CONFIG.get("traffic_limit_gb", 0)
device_limit_value = TRIAL_CONFIG.get("hwid_limit")
logger.info(f"[Trial] Используются параметры триала: {total_gb_value} GB, {device_limit_value} устройств")
elif plan is not None:
tariff = await get_tariff_by_id(session, plan)
logger.info(f"[Tariff Debug] Получен тариф: {tariff}")
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
try:
logger.info(
f"[Client] Вызов add_client: email={email}, client_id={client_id}, GB={total_gb_value}, Devices={device_limit_value}"
)
traffic_limit_bytes = total_gb_value * 1024 * 1024 * 1024
await add_client(
xui,
ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=traffic_limit_bytes,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
),
)
logger.info(f"[Client] Клиент успешно добавлен на сервер {server_name}")
except Exception as e:
logger.error(f"[Client Error] Не удалось создать клиента на {server_name}: {e}")
if SUPERNODE:
await asyncio.sleep(0.7)
async def renew_key_in_cluster(
cluster_id: str,
email: str,
client_id: str,
new_expiry_time: int,
total_gb: int,
session: AsyncSession,
hwid_device_limit: int = 0,
reset_traffic: bool = True,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
result = await session.execute(select(Key.tg_id, Key.server_id).where(Key.client_id == client_id).limit(1))
row = result.first()
if not row:
logger.error(f"Не найден пользователь с client_id={client_id} в таблице keys.")
return False
tg_id, server_id = row
result = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
tariff_group_row = result.scalar_one_or_none()
if tariff_group_row:
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == tariff_group_row, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
tariff = result.scalar_one_or_none()
if tariff and tariff.device_limit is not None:
hwid_device_limit = int(tariff.device_limit)
remnawave_inbound_ids = []
tasks = []
for server_info in cluster:
if server_info.get("panel_type", "3x-ui").lower() == "remnawave":
inbound_id = server_info.get("inbound_id")
if inbound_id:
remnawave_inbound_ids.append(inbound_id)
if remnawave_inbound_ids:
remnawave_server = next(
(
s
for s in cluster
if s.get("panel_type", "").lower() == "remnawave" and s.get("inbound_id") in remnawave_inbound_ids
),
None,
)
if remnawave_server:
remna = RemnawaveAPI(remnawave_server["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
traffic_limit_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
updated = await remna.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=remnawave_inbound_ids,
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_device_limit,
)
if updated:
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
if reset_traffic:
await remna.reset_user_traffic(client_id)
else:
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}, пробуем создать")
result = await session.execute(
select(Key.remnawave_link, Key.key).where(Key.client_id == client_id)
)
row = result.one_or_none()
remnawave_link = row[0] if row else None
row[1] if row else None
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeUserInbounds": remnawave_inbound_ids,
}
if remnawave_link and "/" in remnawave_link:
user_data["shortUuid"] = remnawave_link.rstrip("/").split("/")[-1]
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if hwid_device_limit is not None:
user_data["hwidDeviceLimit"] = hwid_device_limit
result = await remna.create_user(user_data)
if result:
new_client_id = result.get("uuid")
new_remnawave_link = result.get("subscriptionUrl")
logger.info(f"Пользователь Remnawave {client_id} успешно создан")
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(client_id=new_client_id, remnawave_link=new_remnawave_link)
)
await session.commit()
else:
logger.error(f"Не удалось создать пользователя Remnawave {client_id}")
else:
logger.error("Не удалось войти в Remnawave API")
for server_info in cluster:
if server_info.get("panel_type", "3x-ui").lower() != "3x-ui":
continue
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
traffic_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
async def update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name):
updated = await extend_client_key(
xui=xui,
inbound_id=int(inbound_id),
email=unique_email,
new_expiry_time=new_expiry_time,
client_id=client_id,
total_gb=traffic_bytes,
sub_id=sub_id,
tg_id=tg_id,
limit_ip=hwid_device_limit,
)
if not updated:
logger.warning(f"Не удалось обновить клиента {unique_email}, пробуем создать")
config = ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=hwid_device_limit if hwid_device_limit is not None else 0,
total_gb=traffic_bytes,
expiry_time=new_expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
await add_client(xui, config)
tasks.append(update_or_create_client(xui, inbound_id, unique_email, sub_id, server_name))
await asyncio.gather(*tasks, return_exceptions=True)
notification_prefixes = ["key_24h", "key_10h", "key_expired", "renew"]
for notif in notification_prefixes:
notification_id = f"{email}_{notif}"
await delete_notification(session, tg_id, notification_id)
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
except Exception as e:
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise
async def delete_key_from_cluster(cluster_id: str, email: str, client_id: str, session: AsyncSession):
"""Удаление ключа с серверов в кластере или с конкретного сервера"""
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
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 == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"[Remnawave] Не удалось войти на сервер {server_name}")
continue
success = await remna.delete_user(client_id)
if success:
logger.info(f"[Remnawave] Клиент {client_id} успешно удалён с {server_name}")
else:
logger.warning(f"[Remnawave] Не удалось удалить клиента {client_id} с {server_name}")
elif panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[3x-ui] INBOUND_ID отсутствует на сервере {server_name}. Пропуск.")
continue
await delete_client(
xui,
inbound_id=int(inbound_id),
email=email,
client_id=client_id,
)
logger.info(f"[3x-ui] Клиент {client_id} удалён с сервера {server_name}")
else:
logger.warning(f"[Unknown] Неизвестный тип панели '{panel_type}' для сервера {server_name}")
except Exception as e:
logger.error(f"❌ Ошибка при удалении ключа {client_id} из кластера/сервера {cluster_id}: {e}")
raise
async def update_key_on_cluster(
tg_id: int,
client_id: str,
email: str,
expiry_time: int,
cluster_id: str,
session: AsyncSession,
traffic_limit: int = None,
device_limit: int = None,
remnawave_link: str = None,
):
"""
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
Работает с панелями 3x-ui и Remnawave. Возвращает кортеж: (новый client_id, remnawave ссылка или None).
"""
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
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"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
group_code = remnawave_servers[0].get("tariff_group")
if not group_code:
raise ValueError("У Remnawave-сервера отсутствует tariff_group")
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
result.scalar_one_or_none()
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
logger.info(f"[Update] Извлечен short_uuid из ссылки: {short_uuid}")
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
if traffic_limit is not None:
user_data["trafficLimitBytes"] = traffic_limit * 1024**3
if device_limit is not None:
user_data["hwidDeviceLimit"] = device_limit
if short_uuid:
user_data["shortUuid"] = short_uuid
logger.info(f"[Update] Добавлен short_uuid в user_data: {short_uuid}")
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
else:
logger.error("[Update] Ошибка создания Remnawave клиента")
else:
logger.error("[Update] Не удалось авторизоваться в Remnawave")
if not remnawave_client_id:
logger.warning(f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}")
remnawave_client_id = client_id
tasks = []
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 = await get_xui_instance(server_info["api_url"])
sub_id = email
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
group_code = server_info.get("tariff_group")
if not group_code:
raise ValueError(f"У сервера {server_name} отсутствует tariff_group")
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
result.scalar_one_or_none()
total_gb_bytes = int(traffic_limit * 1024**3) if traffic_limit is not None else 0
device_limit_value = device_limit if device_limit is not None else 0
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=total_gb_bytes,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
tasks.append(add_client(xui, config))
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"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise
async def update_subscription(
tg_id: int,
email: str,
session: AsyncSession,
cluster_override: str = None,
country_override: str = None,
remnawave_link: str = None,
) -> None:
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email))
record = result.scalar_one_or_none()
if not record:
raise ValueError(f"The key {email} does not exist in database")
expiry_time = record.expiry_time
client_id = record.client_id
old_cluster_id = record.server_id
tariff_id = record.tariff_id
alias = record.alias
remnawave_link = remnawave_link or record.remnawave_link
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
traffic_limit = None
device_limit = None
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None
device_limit = int(tariff.device_limit) if tariff.device_limit is not None else 0
else:
logger.warning(f"[LOG] update_subscription: тариф с id={tariff_id} не найден!")
else:
logger.warning("[LOG] update_subscription: tariff_id отсутствует!")
await delete_key_from_cluster(old_cluster_id, email, client_id, session=session)
await session.execute(delete(Key).where(Key.tg_id == tg_id, Key.email == email))
await session.commit()
if country_override or cluster_override:
new_cluster_id = country_override or cluster_override
else:
try:
new_cluster_id = await get_least_loaded_cluster(session)
except ValueError:
logger.warning("[Update] Нет доступных кластеров, оставляем на старом")
new_cluster_id = old_cluster_id
new_client_id, remnawave_key = await update_key_on_cluster(
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_time=expiry_time,
cluster_id=new_cluster_id,
session=session,
traffic_limit=traffic_limit,
device_limit=device_limit,
remnawave_link=remnawave_link,
)
servers = await get_servers(session)
cluster_servers = servers.get(new_cluster_id)
if cluster_servers is None:
for server_list in servers.values():
for server_info in server_list:
if server_info.get("server_name", "").lower() == new_cluster_id.lower():
cluster_servers = [server_info]
break
if cluster_servers:
break
else:
cluster_servers = []
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(
session=session,
tg_id=tg_id,
client_id=new_client_id,
email=email,
expiry_time=expiry_time,
key=final_key_link,
remnawave_link=remnawave_key,
server_id=new_cluster_id,
tariff_id=tariff_id,
alias=alias,
)
async def get_user_traffic(session: AsyncSession, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
"""
result = await session.execute(select(Key.client_id, Key.server_id).where(Key.tg_id == tg_id, Key.email == email))
rows = result.all()
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
server_ids = {row.server_id for row in rows}
server_id = list(server_ids)[0]
result = await session.execute(
select(Server)
.where(Server.enabled.is_(True))
.where(
Server.server_name.in_(server_ids) | Server.cluster_name.in_(server_ids)
)
)
server_rows = result.scalars().all()
if not server_rows:
logger.error(f"Не найдено серверов для: {server_ids}")
return {
"status": "error",
"message": f"Серверы не найдены: {', '.join(server_ids)}",
}
servers_map = {
s.server_name: {
"server_name": s.server_name,
"cluster_name": s.cluster_name,
"api_url": s.api_url,
"panel_type": s.panel_type,
}
for s in server_rows
}
user_traffic_data = {}
tasks = []
remnawave_client_id = None
remnawave_checked = False
remnawave_api_url = None
async def fetch_traffic(server_info: dict, client_id: str) -> tuple[str, Any]:
server_name = server_info["server_name"]
api_url = server_info["api_url"]
panel_type = server_info.get("panel_type", "3x-ui").lower()
try:
if panel_type == "3x-ui":
xui = await get_xui_instance(api_url)
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server_name, round(used_gb, 2)
else:
return server_name, "Ошибка получения трафика"
else:
return server_name, f"Неизвестная панель: {panel_type}"
except Exception as e:
return server_name, f"Ошибка: {e}"
for row in rows:
client_id = row.client_id
server_id = row.server_id
matched_servers = [
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
]
for server_info in matched_servers:
panel_type = server_info.get("panel_type", "3x-ui").lower()
if panel_type == "remnawave" and not remnawave_checked:
remnawave_client_id = client_id
remnawave_api_url = server_info["api_url"]
remnawave_checked = True
elif panel_type == "3x-ui":
tasks.append(fetch_traffic(server_info, client_id))
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in results:
user_traffic_data[server] = result
if remnawave_client_id and remnawave_api_url:
try:
remna = RemnawaveAPI(remnawave_api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
user_traffic_data["Remnawave (общий)"] = "Не удалось авторизоваться"
else:
user_data = await remna.get_user_by_uuid(remnawave_client_id)
if not user_data:
user_traffic_data["Remnawave (общий)"] = "Клиент не найден"
else:
used_bytes = user_data.get("usedTrafficBytes", 0)
used_gb = round(used_bytes / 1073741824, 2)
user_traffic_data["Remnawave (общий)"] = used_gb
except Exception as e:
user_traffic_data["Remnawave (общий)"] = f"Ошибка: {e}"
return {"status": "success", "traffic": user_traffic_data}
async def toggle_client_on_cluster(
cluster_id: str,
email: str,
client_id: str,
enable: bool = True,
session: AsyncSession = None,
) -> dict[str, Any]:
try:
if session is None:
raise ValueError("[Cluster Toggle] Не передан объект сессии для toggle_client_on_cluster")
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем '{cluster_id}' не найден.")
results = {}
tasks = []
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":
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[3x-ui] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
results[server_name] = False
continue
xui = await get_xui_instance(server_info["api_url"])
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(toggle_client(xui, int(inbound_id), unique_email, client_id, enable))
elif panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"[Remnawave] Авторизация не удалась на сервере {server_name}")
results[server_name] = False
continue
func = remna.enable_user if enable else remna.disable_user
tasks.append(func(client_id))
else:
logger.warning(
f"[Cluster Toggle] Неизвестный тип панели '{panel_type}' на сервере {server_name}. Пропуск."
)
results[server_name] = False
task_results = await asyncio.gather(*tasks, return_exceptions=True)
for server_info, result in zip(cluster, task_results, strict=False):
server_name = server_info.get("server_name", "unknown")
if isinstance(result, Exception):
logger.error(f"[Cluster Toggle] Ошибка на сервере {server_name}: {result}")
results[server_name] = False
else:
results[server_name] = result
status = "включен" if enable else "отключен"
logger.info(f"[Cluster Toggle] Клиент {email} {status} на серверах кластера {cluster_id}")
logger.info(f"[Cluster Toggle DEBUG] Результаты: {results}")
return {
"status": "success" if any(results.values()) else "error",
"results": results,
}
except Exception as e:
logger.error(f"[Cluster Toggle] Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
return {"status": "error", "error": str(e)}
async def reset_traffic_in_cluster(cluster_id: str, email: str, session: AsyncSession) -> None:
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
remnawave_done = False
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:
result = await session.execute(
select(Key.client_id).where(Key.email == email, Key.server_id == cluster_id).limit(1)
)
row = result.first()
if not row:
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row[0]
remna = RemnawaveAPI(api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
remnawave_done = True
continue
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(api_url)
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"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+100 -118
View File
@@ -1,3 +1,4 @@
import asyncio
import html
import os
import re
@@ -18,16 +19,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
HAPP_CRYPTOLINK,
HWID_RESET_BUTTON,
QRCODE,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
REMNAWAVE_WEBAPP,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
)
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
from database.models import Key
from handlers.buttons import (
ADD_SUB,
ALIAS,
BACK,
CHANGE_LOCATION,
@@ -39,7 +42,8 @@ from handlers.buttons import (
MAIN_MENU,
PC_BUTTON,
QR,
RENEW_SUB,
RENEW_KEY,
ROUTER_BUTTON,
TV_BUTTON,
UNFREEZE,
)
@@ -50,7 +54,6 @@ from handlers.texts import (
KEYS_HEADER,
NO_SUBSCRIPTIONS_MSG,
RENAME_KEY_PROMPT,
SELECT_SUBS,
key_message,
)
from handlers.utils import (
@@ -61,7 +64,10 @@ from handlers.utils import (
get_russian_month,
is_full_remnawave_cluster,
)
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
from panels.remnawave import RemnawaveAPI
router = Router()
@@ -83,7 +89,14 @@ async def process_callback_or_message_view_keys(callback_query_or_message: Messa
try:
records = await get_keys(session, tg_id)
inline_keyboard, response_message = build_keys_response(records)
if records and len(records) == 1:
key_name = records[0].email
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(target_message, session, key_name, image_path)
return
inline_keyboard, response_message = await build_keys_response(records, session)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
@@ -97,7 +110,7 @@ async def process_callback_or_message_view_keys(callback_query_or_message: Messa
await target_message.answer(text=error_message)
def build_keys_response(records):
async def build_keys_response(records, session):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
@@ -120,7 +133,18 @@ def build_keys_response(records):
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
is_vless = False
if hasattr(record, "tariff_id") and record.tariff_id:
try:
tariff = await get_tariff_by_id(session, record.tariff_id)
if tariff and tariff.get("vless"):
is_vless = True
except:
pass
icon = "📶" if is_vless else "🔑"
key_button = InlineKeyboardButton(text=f"{icon} {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)
@@ -155,7 +179,6 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == "cancel_and_back_to_view_keys")
async def cancel_and_back(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
await callback.answer()
await process_callback_or_message_view_keys(callback, session)
@@ -199,16 +222,12 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
async def render_key_info(message: Message, session: Any, key_name: str, image_path: str):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
record = await get_key_details(session, key_name)
if not record:
await message.answer("<b>Информация о подписке не найдена.</b>")
return
is_frozen = record["is_frozen"]
record["email"]
client_id = record.get("client_id")
remnawave_link = record.get("remnawave_link")
key = record.get("key")
@@ -231,7 +250,8 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
expiry_time = record["expiry_time"]
server_name = record["server_id"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
time_left = expiry_date - datetime.utcnow()
now = datetime.utcnow()
time_left = expiry_date - now
if time_left.total_seconds() <= 0:
days_left_message = DAYS_LEFT_MESSAGE
@@ -248,35 +268,45 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
)
is_full_task = asyncio.create_task(is_full_remnawave_cluster(server_name, session))
tariff_task = (
asyncio.create_task(get_tariff_by_id(session, record["tariff_id"])) if record.get("tariff_id") else None
)
is_full_remnawave = await is_full_task
tariff = await tariff_task if tariff_task else None
hwid_count = 0
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
remna_used_gb = None
if is_full_remnawave and client_id:
try:
servers = await get_servers(session)
remna_server = next(
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
None,
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None
)
if remna_server:
api = RemnawaveAPI(remna_server["api_url"])
if await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
devices = await api.get_user_hwid_devices(client_id)
hwid_count = len(devices or [])
user_data = await api.get_user_by_uuid(client_id)
if user_data:
used_bytes = user_data.get("usedTrafficBytes", 0)
remna_used_gb = round(used_bytes / 1073741824, 1)
except Exception as e:
logger.error(f"Ошибка при получении HWID для {client_id}: {e}")
logger.error(f"Ошибка при получении данных Remnawave для {client_id}: {e}")
tariff_name = ""
traffic_limit = 0
device_limit = 0
subgroup_title = ""
tariff = None
if record.get("tariff_id"):
tariff = await get_tariff_by_id(session, record["tariff_id"])
if tariff:
tariff_name = tariff["name"]
traffic_limit = tariff.get("traffic_limit", 0)
device_limit = tariff.get("device_limit", 0)
subgroup_title = tariff.get("subgroup_title", "")
vless_enabled = False
if tariff:
tariff_name = tariff["name"]
traffic_limit = tariff.get("traffic_limit", 0)
device_limit = tariff.get("device_limit", 0)
subgroup_title = tariff.get("subgroup_title", "")
vless_enabled = bool(tariff.get("vless"))
tariff_duration = tariff_name
@@ -291,44 +321,43 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
traffic_limit=traffic_limit,
device_limit=device_limit,
subgroup_title=subgroup_title,
is_remnawave=is_full_remnawave,
remna_used_gb=remna_used_gb,
)
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text=RENEW_SUB,
callback_data=f"update_subscription|{key_name}",
)
)
if is_full_remnawave and final_link:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"))
if is_full_remnawave and final_link and REMNAWAVE_WEBAPP and not HAPP_CRYPTOLINK:
if vless_enabled:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"))
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}"),
)
if vless_enabled:
builder.row(InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
else:
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 vless_enabled:
builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}"))
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=f"renew_key|{key_name}"))
if HWID_RESET_BUTTON and hwid_count > 0:
builder.row(
InlineKeyboardButton(
text=HWID_BUTTON,
callback_data=f"reset_hwid|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=HWID_BUTTON, callback_data=f"reset_hwid|{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=DELETE, callback_data=f"delete_key|{key_name}"),
)
builder.row(InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"))
if USE_COUNTRY_SELECTION:
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
@@ -336,8 +365,9 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
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"))
module_buttons = await run_hooks("view_key_menu", key_name=key_name, session=session)
builder = insert_hook_buttons(builder, module_buttons)
await edit_or_send_message(
target_message=message,
@@ -349,11 +379,12 @@ async def render_key_info(message: Message, session: Any, key_name: str, image_p
@router.callback_query(F.data.startswith("reset_hwid|"))
async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
record_task = asyncio.create_task(get_key_details(session, key_name))
servers_task = asyncio.create_task(get_servers(session=session))
record = await record_task
if not record:
await callback_query.answer("❌ Ключ не найден.", show_alert=True)
return
@@ -363,11 +394,8 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
await callback_query.answer("❌ У ключа отсутствует client_id.", show_alert=True)
return
servers = await get_servers(session=session)
remna_server = next(
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
None,
)
servers = await servers_task
remna_server = next((srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"), None)
if not remna_server:
await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True)
return
@@ -387,63 +415,17 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
deleted += 1
await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
hook_result = await run_hooks(
"after_hwid_reset", chat_id=callback_query.from_user.id, admin=False, session=session, key_name=key_name
)
if hook_result and any("redirect_to_profile" in str(result) for result in hook_result):
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if callback_query.message.text:
await callback_query.message.edit_text("✅ Устройства сброшены", reply_markup=kb.as_markup())
else:
await callback_query.message.edit_caption(caption="✅ Устройства сброшены", reply_markup=kb.as_markup())
return
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
@router.callback_query(F.data == "renew_menu")
@router.callback_query(F.data == "extend")
@router.message(F.text == "/extend")
async def process_renew_menu(callback_query_or_message: CallbackQuery | Message, session: Any):
try:
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
tg_id = callback_query_or_message.from_user.id
else:
target_message = callback_query_or_message
tg_id = callback_query_or_message.from_user.id
records = await get_keys(session, tg_id)
servers_dict = await get_servers(session)
all_server_names = set()
for servers in servers_dict.values():
for s in servers:
all_server_names.add(s["server_name"])
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
for record in records:
if getattr(record, "is_frozen", False):
continue
alias = record.alias
email = record.email
expiry_time = record.expiry_time
server_id = record.server_id
key_display = alias.strip() if alias else email
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
now = datetime.now(moscow_tz)
days_left = (expiry_date_full - now).days
if (expiry_date_full - now).total_seconds() <= 0:
days_text = "🔴 Истекла"
else:
days_text = format_days(days_left)
else:
days_text = "истекла"
server_info = f" ({server_id})" if server_id in all_server_names else ""
btn_text = f"🔑 {key_display} (⏳{days_text}) {server_info}"
builder.row(InlineKeyboardButton(text=btn_text, callback_data=f"renew_key|{email}"))
text = SELECT_SUBS
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
image_path = os.path.join("img", "pic_view.jpg")
await edit_or_send_message(
target_message=target_message,
text=text,
reply_markup=builder.as_markup(),
media_path=image_path,
)
except Exception as e:
error_message = f"Ошибка при получении подписок для продления: {e}"
await target_message.answer(text=error_message)
+2 -4
View File
@@ -1,14 +1,12 @@
import asyncio
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_key, get_key_details, get_servers
from database import delete_key, get_key_details
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import delete_key_from_cluster, update_subscription
from handlers.keys.key_view import process_callback_view_key
from handlers.keys.operations import delete_key_from_cluster, update_subscription
from handlers.texts import DELETE_KEY_CONFIRM_MSG, KEY_DELETED_MSG_SIMPLE
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
+21
View File
@@ -0,0 +1,21 @@
# handlers/keys/operations/__init__.py
from .creation import create_client_on_server, create_key_on_cluster
from .deletion import delete_key_from_cluster
from .renewal import renew_key_in_cluster
from .toggles import toggle_client_on_cluster
from .traffic import get_user_traffic, reset_traffic_in_cluster
from .update import update_key_on_cluster, update_subscription
__all__ = [
"create_key_on_cluster",
"create_client_on_server",
"renew_key_in_cluster",
"update_key_on_cluster",
"update_subscription",
"delete_key_from_cluster",
"get_user_traffic",
"reset_traffic_in_cluster",
"toggle_client_on_cluster",
]
@@ -0,0 +1,184 @@
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from config import HAPP_CRYPTOLINK, LEGACY_LINKS, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import filter_cluster_by_subgroup, get_key_details, get_tariff_by_id
from logger import logger
from panels._3xui import get_vless_link_for_client, get_xui_instance
from panels.remnawave import RemnawaveAPI
from servers import extract_host
from .utils import is_plan_vless, score_vless_url, split_by_panel
async def _is_vless_tariff(session: AsyncSession, email: str) -> bool:
kd = await get_key_details(session, email)
if not kd or not kd.get("tariff_id"):
return False
tariff = await get_tariff_by_id(session, int(kd["tariff_id"]))
if not tariff:
return False
return is_plan_vless(tariff)
async def _try_build_remna_vless(servers: list, email: str) -> tuple[str | None, str | None]:
si = servers[0]
remna = RemnawaveAPI(si["api_url"])
ok = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not ok:
logger.warning("[Remnawave] login failed")
return None, None
data = await remna.get_subscription_by_username(email)
if not data:
logger.warning("[Remnawave] by-username empty")
return None, None
links = data.get("links") or []
best = None
if links:
best = max(links, key=score_vless_url)
if score_vless_url(best) < 0:
best = None
happ_link = None
try:
happ = data.get("happ") or {}
if isinstance(happ, dict):
happ_link = happ.get("cryptoLink") or happ.get("link")
except Exception:
pass
if HAPP_CRYPTOLINK and happ_link:
return best, happ_link
sub_url = data.get("subscriptionUrl")
return best, sub_url
async def _try_build_3xui_vless(servers: list, email: str) -> str | None:
async def one(si: dict) -> str | None:
name = si.get("server_name", "unknown")
inbound_id = si.get("inbound_id")
if not inbound_id:
return None
login_email = f"{email}_{name.lower()}" if SUPERNODE else email
try:
xui = await get_xui_instance(si["api_url"])
except Exception as e:
logger.warning(f"[{name}] 3x-ui недоступен для VLESS: {e}")
return None
try:
inbound = await xui.inbound.get_by_id(int(inbound_id))
if not inbound:
return None
port = getattr(inbound, "port", None)
host = extract_host(si.get("subscription_url") or si.get("api_url"))
return await get_vless_link_for_client(
xui=xui,
inbound_id=int(inbound_id),
email=login_email,
external_host=host,
port=int(port) if port else None,
remark=email,
)
except Exception as e:
logger.warning(f"[{name}] ошибка VLESS: {e}")
return None
results = await asyncio.gather(*[one(s) for s in servers], return_exceptions=True)
return next((r for r in results if isinstance(r, str) and r), None)
async def make_aggregated_link(
session: AsyncSession,
cluster_all: list,
cluster_id: str,
email: str,
client_id: str,
tg_id: int,
subgroup_code: str | None = None,
remna_link_override: str | None = None,
plan=None,
) -> str | None:
servers = (
await filter_cluster_by_subgroup(session, cluster_all, subgroup_code, cluster_id)
if subgroup_code
else cluster_all
)
if not servers:
logger.info("[agg_link] servers=0 after DB filter")
return None
xui, remna = split_by_panel(servers)
logger.debug(f"[agg_link] subgroup='{subgroup_code}' xui={len(xui)} remna={len(remna)}")
if plan is None:
vless_needed = await _is_vless_tariff(session, email)
elif isinstance(plan, int):
tr = await get_tariff_by_id(session, plan)
vless_needed = is_plan_vless(tr)
else:
vless_needed = is_plan_vless(plan)
base = PUBLIC_LINK.rstrip("/")
if vless_needed:
if LEGACY_LINKS:
if xui:
xui_link = await _try_build_3xui_vless(xui, email)
if xui_link:
logger.info("[agg_link] LEGACY choose 3x-ui VLESS")
return xui_link
logger.info("[agg_link] LEGACY fallback base")
return f"{base}/{email}/{tg_id}"
if xui:
xui_link = await _try_build_3xui_vless(xui, email)
if xui_link:
logger.info("[agg_link] choose 3x-ui VLESS")
return xui_link
if remna:
best_vless, sub_url = await _try_build_remna_vless(remna, email)
if best_vless:
logger.info("[agg_link] choose Remnawave VLESS")
return best_vless
if remna_link_override and remna_link_override.lower().startswith("vless://"):
logger.info("[agg_link] choose override Remnawave VLESS")
return remna_link_override
kd = await get_key_details(session, email)
stored = kd.get("remnawave_link") if kd else None
if stored and str(stored).lower().startswith("vless://"):
logger.info("[agg_link] choose stored Remnawave VLESS")
return stored
if sub_url:
logger.info("[agg_link] choose Remnawave subscriptionUrl")
return sub_url
logger.info("[agg_link] fallback base link")
return f"{base}/{email}/{tg_id}"
if remna and not xui:
if LEGACY_LINKS:
logger.info("[agg_link] LEGACY non-vless -> base link")
return f"{base}/{email}/{tg_id}"
best_vless, sub_url = await _try_build_remna_vless(remna, email)
if remna_link_override and (
remna_link_override.lower().startswith("vless://") or
remna_link_override.startswith("http") or
remna_link_override.startswith("happ://")
):
logger.info("[agg_link] choose override Remnawave (non-vless)")
return remna_link_override
kd = await get_key_details(session, email)
stored = kd.get("remnawave_link") if kd else None
if stored:
logger.info("[agg_link] choose stored Remnawave (non-vless)")
return stored
if sub_url:
logger.info("[agg_link] choose Remnawave subscriptionUrl (non-vless)")
return sub_url
if best_vless:
logger.info("[agg_link] fallback Remnawave VLESS (non-vless)")
return best_vless
return f"{base}/{email}/{tg_id}"
+303
View File
@@ -0,0 +1,303 @@
import asyncio
from datetime import datetime
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from config import HAPP_CRYPTOLINK, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import get_servers, get_tariff_by_id, store_key
from database.models import User
from handlers.utils import ALLOWED_GROUP_CODES, check_server_key_limit
from logger import (
CLOGGER as logger,
PANEL_REMNA,
PANEL_XUI,
)
from panels._3xui import ClientConfig, add_client, get_xui_instance
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
from .aggregated_links import make_aggregated_link
async def create_key_on_cluster(
cluster_id: str,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
plan: int = None,
session: AsyncSession = None,
remnawave_link: str = None,
hwid_limit: int = None,
traffic_limit_bytes: int = None,
is_trial: bool = False,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
server_id_to_store = cluster_id
if not cluster:
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
enabled_servers = [s for s in cluster if s.get("enabled", True)]
if not enabled_servers:
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
return
tariff = None
subgroup_title = None
need_vless_key = False
if plan is not None:
tariff = await get_tariff_by_id(session, plan)
if not tariff:
raise ValueError(f"Тариф с id={plan} не найден.")
if traffic_limit_bytes is None:
traffic_limit_bytes = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else None
if hwid_limit is None:
hwid_limit = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
subgroup_title = tariff.get("subgroup_title")
need_vless_key = bool(tariff.get("vless"))
if subgroup_title:
subgroup_servers = [s for s in enabled_servers if subgroup_title in s.get("tariff_subgroups", [])]
if subgroup_servers:
enabled_servers = subgroup_servers
else:
logger.warning(
f"[Key Creation] В кластере {cluster_id} не найдено серверов для подгруппы '{subgroup_title}'. Использую весь кластер."
)
special = None
if is_trial:
special = "trial"
elif tariff:
gc = (tariff.get("group_code") or "").lower()
if gc in ALLOWED_GROUP_CODES:
special = gc
if special:
bound_servers = [s for s in enabled_servers if special in (s.get("special_groups") or [])]
if bound_servers:
enabled_servers = bound_servers
else:
logger.info(
f"[Key Creation] В кластере {cluster_id} нет серверов со спецгруппой '{special}'. Использую весь кластер."
)
remnawave_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, session)
]
xui_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, session)
]
if not remnawave_servers and not xui_servers:
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
return
semaphore = asyncio.Semaphore(2)
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(f"{PANEL_REMNA} Не удалось войти в 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 inbound_ids:
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeInternalSquads": inbound_ids,
"uuid": client_id,
}
if traffic_limit_bytes and traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes * 1024 * 1024 * 1024
if short_uuid:
user_data["shortUuid"] = short_uuid
user_data["hwidDeviceLimit"] = hwid_limit
logger.debug(f"{PANEL_REMNA} Данные для создания клиента: {user_data}")
result = await remna.create_user(user_data)
if result:
remnawave_created = True
remnawave_client_id = result.get("uuid")
link_vless = None
if need_vless_key:
try:
link_vless = await get_vless_link_for_remnawave_by_username(remna, email, email)
except Exception as e:
logger.error(f"{PANEL_REMNA} Ошибка сборки VLESS: {e}")
remnawave_key = link_vless or (
result["happ"]["cryptoLink"] if HAPP_CRYPTOLINK else result.get("subscriptionUrl")
)
logger.info(f"{PANEL_REMNA} Пользователь создан: {result}")
else:
logger.warning(f"{PANEL_REMNA} Нет inbound_id у серверов")
final_client_id = remnawave_client_id or client_id
logger.debug(f"{PANEL_XUI} 3x-ui servers для кластера {cluster_id}: {[s['server_name'] for s in xui_servers]}")
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,
session=session,
is_trial=is_trial,
)
else:
await asyncio.gather(
*[
create_client_on_server(
server,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
session=session,
is_trial=is_trial,
)
for server in xui_servers
],
return_exceptions=True,
)
cluster_all = enabled_servers
subgroup_code = subgroup_title if subgroup_title else None
public_link = await make_aggregated_link(
session=session,
cluster_all=cluster_all,
cluster_id=server_id_to_store,
email=email,
client_id=final_client_id,
tg_id=tg_id,
subgroup_code=subgroup_code,
remna_link_override=remnawave_key,
plan=plan,
)
if not public_link:
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
if (remnawave_created and remnawave_client_id) or xui_servers:
await store_key(
session=session,
tg_id=tg_id,
client_id=final_client_id,
email=email,
expiry_time=expiry_timestamp,
key=public_link,
server_id=server_id_to_store,
remnawave_link=remnawave_key,
tariff_id=plan,
)
await session.execute(update(User).where(User.tg_id == tg_id, User.trial.in_([0, -1])).values(trial=1))
await session.commit()
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
raise e
async def create_client_on_server(
server_info: dict,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
semaphore: asyncio.Semaphore,
plan: int = None,
session=None,
is_trial: bool = False,
):
logger.debug(
f"{PANEL_XUI} [Client] Вход в create_client_on_server: сервер={server_info.get('server_name')}, план={plan}, is_trial={is_trial}"
)
async with semaphore:
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"{PANEL_XUI} [Client] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
return
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
total_gb_value = 0
device_limit_value = 0
if plan is not None:
tariff = await get_tariff_by_id(session, plan)
logger.debug(f"{PANEL_XUI} [Tariff Debug] Получен тариф: {tariff}")
if not tariff:
raise ValueError(f"{PANEL_XUI} Тариф с id={plan} не найден.")
total_gb_value = int(tariff["traffic_limit"]) if tariff["traffic_limit"] else 0
device_limit_value = int(tariff["device_limit"]) if tariff.get("device_limit") is not None else 0
try:
logger.debug(
f"{PANEL_XUI} [Client] Вызов add_client: email={email}, client_id={client_id}, GB={total_gb_value}, Devices={device_limit_value}"
)
traffic_limit_bytes = total_gb_value * 1024 * 1024 * 1024
await add_client(
xui,
ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=traffic_limit_bytes,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
),
)
logger.info(f"{PANEL_XUI} [Client] Клиент успешно добавлен на сервер {server_name}")
except Exception as e:
logger.error(f"{PANEL_XUI} [Client Error] Не удалось создать клиента на {server_name}: {e}")
if SUPERNODE:
await asyncio.sleep(0.7)
+93
View File
@@ -0,0 +1,93 @@
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database import get_servers
from logger import (
CLOGGER as logger,
PANEL_REMNA,
PANEL_XUI,
)
from panels._3xui import delete_client, get_xui_instance
from panels.remnawave import RemnawaveAPI
from .utils import unique_by_api_url
async def delete_key_from_cluster(cluster_id: str, email: str, client_id: str, session: AsyncSession):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
remna_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"]
await asyncio.gather(
delete_on_3xui(xui_servers, email, client_id),
delete_on_remnawave(remna_servers, client_id),
return_exceptions=True,
)
except Exception as e:
logger.error(f"Ошибка при удалении ключа {client_id} из кластера/сервера {cluster_id}: {e}")
raise
async def delete_on_3xui(servers: list, email: str, client_id: str):
tasks = []
for s in servers:
name = s.get("server_name", "unknown")
inbound_id = s.get("inbound_id")
if not inbound_id:
logger.warning(f"{PANEL_XUI} [{name}] INBOUND_ID отсутствует при удалении")
continue
try:
xui = await get_xui_instance(s["api_url"])
except Exception as e:
logger.warning(f"{PANEL_XUI} [{name}] недоступна панель 3x-ui при удалении: {e}")
continue
tasks.append(
delete_client(
xui=xui,
inbound_id=int(inbound_id),
email=email,
client_id=client_id,
)
)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def delete_on_remnawave(servers: list, client_id: str) -> bool:
servers = unique_by_api_url(servers)
for s in servers:
name = s.get("server_name", "remna")
api = RemnawaveAPI(s.get("api_url"))
ok = await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not ok:
logger.warning(f"{PANEL_REMNA} [{name}] Авторизация не удалась")
continue
try:
done = await api.delete_user(client_id)
if done:
logger.info(f"{PANEL_REMNA} [{name}] Клиент {client_id} удалён")
return True
except Exception as e:
msg = str(e).lower()
if "not found" in msg or "не найден" in msg or "404" in msg:
logger.info(f"{PANEL_REMNA} [{name}] Клиент {client_id} не найден")
else:
logger.warning(f"{PANEL_REMNA} [{name}] Ошибка удаления клиента {client_id}: {e}")
return False
+295
View File
@@ -0,0 +1,295 @@
import asyncio
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import (
delete_notification,
filter_cluster_by_subgroup,
get_key_details,
get_servers,
resolve_device_limit_from_group,
update_key_expiry,
update_key_link,
)
from logger import (
CLOGGER as logger,
PANEL_REMNA,
PANEL_XUI,
)
from panels._3xui import extend_client_key, get_xui_instance
from panels.remnawave import RemnawaveAPI
from .aggregated_links import make_aggregated_link
from .subgroup_migration import migrate_between_subgroups
async def resolve_cluster(session: AsyncSession, cluster_id: str):
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if cluster:
return cluster
found = []
for _key, server_list in servers.items():
for s in server_list:
if s.get("server_name", "").lower() == cluster_id.lower():
found.append(s)
if found:
return found
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
async def renew_on_remnawave(
cluster: list,
client_id: str,
email: str,
tg_id: int,
new_expiry_time: int,
total_gb: int,
hwid_device_limit: int,
session: AsyncSession,
reset_traffic: bool,
target_server_name: str | None = None,
) -> bool:
remnawave_nodes = [
s for s in cluster if str(s.get("panel_type", "3x-ui")).lower() == "remnawave" and s.get("inbound_id")
]
if not remnawave_nodes:
return False
if target_server_name:
remnawave_nodes = [s for s in remnawave_nodes if s.get("server_name") == target_server_name] or remnawave_nodes[
:1
]
remna = RemnawaveAPI(remnawave_nodes[0]["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"{PANEL_REMNA} Не удалось войти в Remnawave API")
return False
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
traffic_limit_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
active_inbounds = [s["inbound_id"] for s in remnawave_nodes]
updated = await remna.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=active_inbounds,
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_device_limit,
)
if updated:
if reset_traffic:
try:
await remna.reset_user_traffic(client_id)
except Exception as e:
logger.warning(f"{PANEL_REMNA} reset_user_traffic: {e}")
logger.info(f"{PANEL_REMNA} Подписка {client_id} успешно продлена")
return True
logger.debug(f"{PANEL_REMNA} Не удалось продлить {client_id}. Автосоздание отключено.")
return False
async def renew_on_3xui(
cluster: list,
email: str,
client_id: str,
new_expiry_time: int,
total_gb: int,
hwid_device_limit: int,
tg_id: int,
update_links: bool = False,
target_server_name: str | None = None,
):
tasks = []
for server_info in cluster:
if target_server_name and server_info.get("server_name") != target_server_name:
continue
if str(server_info.get("panel_type", "3x-ui")).lower() != "3x-ui":
continue
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"{PANEL_XUI} INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id_val = email
else:
unique_email = email
sub_id_val = unique_email
traffic_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0
async def process_server(si, inbound, uniq, sub, name):
try:
xui = await get_xui_instance(si["api_url"])
except Exception as e:
logger.warning(f"{PANEL_XUI} [{name}] API недоступен: {e}")
return name, False, f"api_unavailable: {e}"
try:
updated = await extend_client_key(
xui=xui,
inbound_id=int(inbound),
email=uniq,
new_expiry_time=new_expiry_time,
client_id=client_id,
total_gb=traffic_bytes,
sub_id=sub,
tg_id=tg_id,
limit_ip=hwid_device_limit,
)
except Exception as e:
logger.warning(f"{PANEL_XUI} [{name}] ошибка продления: {e}")
updated = False
if updated:
return name, True, None
logger.debug(f"{PANEL_XUI} [{name}] не удалось обновить {uniq}. Автосоздание отключено.")
return name, False, "no_autocreate"
tasks.append(process_server(server_info, inbound_id, unique_email, sub_id_val, server_name))
results = await asyncio.gather(*tasks, return_exceptions=True)
failed = []
succeeded = []
for r in results:
if isinstance(r, Exception):
failed.append(("unknown", f"task_exception: {r}"))
continue
name, ok, err = r
if ok:
succeeded.append(name)
else:
failed.append((name, err or "unknown_error"))
if succeeded:
logger.info(f"{PANEL_XUI} продлено на: {', '.join(succeeded)}")
if failed:
logger.debug(f"{PANEL_XUI} не продлено на: " + ", ".join([f"{n} ({e})" for n, e in failed]))
return succeeded, failed
async def renew_key_in_cluster(
cluster_id: str,
email: str,
client_id: str,
new_expiry_time: int,
total_gb: int,
session: AsyncSession,
hwid_device_limit: int = 0,
reset_traffic: bool = True,
target_subgroup: str | None = None,
old_subgroup: str | None = None,
plan=None,
):
try:
servers_map = await get_servers(session)
kd = await get_key_details(session, email)
if not kd or kd.get("client_id") != client_id:
logger.error(f"Не найден ключ по email={email} и client_id={client_id}")
return False
tg_id = int(kd["tg_id"])
server_id = kd["server_id"]
single_server = None
if servers_map.get(server_id):
cluster = servers_map[server_id]
else:
for _k, sl in servers_map.items():
for s in sl:
if s.get("server_name") == server_id:
single_server = s
break
if single_server:
break
cluster = (
[single_server]
if single_server
else servers_map.get(cluster_id) or await resolve_cluster(session, cluster_id)
)
dl = await resolve_device_limit_from_group(session, server_id)
if dl is not None:
hwid_device_limit = dl
if (target_subgroup or "") != (old_subgroup or "") and not single_server:
new_client_id, remna_link = await migrate_between_subgroups(
session=session,
cluster_all=cluster,
cluster_id=cluster_id,
email=email,
client_id=client_id,
tg_id=tg_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
reset_traffic=reset_traffic,
old_subgroup=old_subgroup,
target_subgroup=target_subgroup,
)
await update_key_expiry(session, new_client_id or client_id, new_expiry_time)
for prefix in ["key_24h", "key_10h", "key_expired", "renew"]:
await delete_notification(session, tg_id, f"{email}_{prefix}")
try:
key_link = await make_aggregated_link(
session=session,
cluster_all=cluster,
cluster_id=cluster_id,
email=email,
client_id=new_client_id or client_id,
tg_id=tg_id,
subgroup_code=target_subgroup,
remna_link_override=remna_link,
plan=plan,
)
if key_link:
await update_key_link(session, email, key_link)
except Exception as le:
logger.warning(f"[Link] ошибка генерации/сохранения после миграции: {le}")
return True
if single_server:
cluster_scope = [single_server]
else:
if target_subgroup:
target = await filter_cluster_by_subgroup(session, cluster, target_subgroup, cluster_id)
cluster_scope = target if target else cluster
else:
cluster_scope = cluster
remna_ok = await renew_on_remnawave(
cluster=cluster_scope,
client_id=client_id,
email=email,
tg_id=tg_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
session=session,
reset_traffic=reset_traffic,
target_server_name=server_id if single_server else None,
)
succeeded, _ = await renew_on_3xui(
cluster=cluster_scope,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
tg_id=tg_id,
update_links=False,
target_server_name=server_id if single_server else None,
)
if remna_ok or succeeded:
await update_key_expiry(session, client_id, new_expiry_time)
for prefix in ["key_24h", "key_10h", "key_expired", "renew"]:
await delete_notification(session, tg_id, f"{email}_{prefix}")
return True
return False
except Exception as e:
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise
@@ -0,0 +1,313 @@
import asyncio
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from config import HAPP_CRYPTOLINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import filter_cluster_by_subgroup, update_key_client_id
from logger import (
CLOGGER as logger,
PANEL_REMNA,
PANEL_XUI,
)
from panels._3xui import ClientConfig, add_client, extend_client_key, get_xui_instance
from panels.remnawave import RemnawaveAPI
from .deletion import delete_on_3xui, delete_on_remnawave
from .utils import bytes_from_gb, norm_name, split_by_panel
async def ensure_on_remnawave(
servers: list,
email: str,
client_id: str,
tg_id: int,
new_expiry_time: int,
total_gb: int,
hwid_device_limit: int,
reset_traffic: bool,
attempt_update_first: bool,
) -> tuple[str | None, str | None]:
if not servers:
return None, None
inbounds = [s.get("inbound_id") for s in servers if s.get("inbound_id")]
api = RemnawaveAPI(servers[0]["api_url"])
ok = await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not ok:
logger.error(f"{PANEL_REMNA} API недоступен при создании/обновлении")
return None, None
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
traffic_bytes = bytes_from_gb(total_gb)
async def do_update():
try:
updated = await api.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=inbounds,
traffic_limit_bytes=traffic_bytes,
hwid_device_limit=hwid_device_limit,
)
if updated:
if reset_traffic:
try:
await api.reset_user_traffic(client_id)
except Exception:
pass
return client_id, None
except Exception:
return None, None
return None, None
async def do_create():
try:
payload = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeInternalSquads": inbounds,
"uuid": client_id,
}
if traffic_bytes > 0:
payload["trafficLimitBytes"] = traffic_bytes
if hwid_device_limit is not None:
payload["hwidDeviceLimit"] = hwid_device_limit
created = await api.create_user(payload)
new_uuid = created.get("uuid") if isinstance(created, dict) else None
remna_link = None
if isinstance(created, dict):
if HAPP_CRYPTOLINK:
remna_link = (
created.get("happ", {}).get("cryptoLink") if isinstance(created.get("happ"), dict) else None
)
if not remna_link:
remna_link = created.get("subscriptionUrl")
return new_uuid, remna_link
except Exception as e:
logger.error(f"{PANEL_REMNA} создание не удалось: {e}")
return None, None
if attempt_update_first:
updated_id, link = await do_update()
if updated_id:
return updated_id, link
return await do_create()
created_id, link = await do_create()
if created_id:
return created_id, link
return await do_update()
async def ensure_on_3xui(
servers: list,
email: str,
client_id: str,
tg_id: int,
new_expiry_time: int,
total_gb: int,
hwid_device_limit: int,
attempt_update_first: bool,
):
tasks = []
traffic = bytes_from_gb(total_gb)
for s in servers:
name = s.get("server_name", "unknown")
inbound_id = s.get("inbound_id")
if not inbound_id:
logger.warning(f"{PANEL_XUI} [{name}] INBOUND_ID отсутствует")
continue
login_email = f"{email}_{name.lower()}" if SUPERNODE else email
sub_id = email if SUPERNODE else login_email
async def one(si, nm, inbound, login, sub):
try:
xui = await get_xui_instance(si["api_url"])
except Exception as e:
logger.error(f"{PANEL_XUI} [{nm}] API недоступен: {e}")
return
async def do_update():
try:
updated = await extend_client_key(
xui=xui,
inbound_id=int(inbound),
email=login,
new_expiry_time=new_expiry_time,
client_id=client_id,
total_gb=traffic,
sub_id=sub,
tg_id=tg_id,
limit_ip=hwid_device_limit,
)
return bool(updated)
except Exception as e:
logger.error(f"{PANEL_XUI} [{nm}] ошибка продления: {e}")
return False
async def do_create():
try:
cfg = ClientConfig(
client_id=client_id,
email=login,
tg_id=tg_id,
limit_ip=hwid_device_limit,
total_gb=traffic,
expiry_time=new_expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound),
sub_id=sub,
)
created = await add_client(xui, cfg)
if not created:
logger.error(f"{PANEL_XUI} [{nm}] add_client вернул False")
return bool(created)
except Exception as e:
logger.error(f"{PANEL_XUI} [{nm}] ошибка add_client: {e}")
return False
if attempt_update_first:
if await do_update():
return
await do_create()
else:
if await do_create():
return
await do_update()
tasks.append(one(s, name, inbound_id, login_email, sub_id))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def migrate_between_subgroups(
session: AsyncSession,
cluster_all: list,
cluster_id: str,
email: str,
client_id: str,
tg_id: int,
new_expiry_time: int,
total_gb: int,
hwid_device_limit: int,
reset_traffic: bool,
old_subgroup: str,
target_subgroup: str,
) -> tuple[str, str | None]:
target = await filter_cluster_by_subgroup(session, cluster_all, target_subgroup, cluster_id)
xui_tgt, remna_tgt = split_by_panel(target)
old_set = await filter_cluster_by_subgroup(session, cluster_all, old_subgroup, cluster_id)
was_on_remna_before = any(
s.get("enabled", True) and (s.get("panel_type", "").lower() == "remnawave") for s in old_set
)
was_on_xui_before = any(s.get("enabled", True) and (s.get("panel_type", "").lower() == "3x-ui") for s in old_set)
xui_target_names = {norm_name(s.get("server_name")) for s in xui_tgt}
remna_target_urls = {(s.get("api_url") or "").rstrip("/") for s in remna_tgt}
xui_old = [s for s in old_set if s.get("enabled", True) and (s.get("panel_type", "").lower() == "3x-ui")]
remna_old = [s for s in old_set if s.get("enabled", True) and (s.get("panel_type", "").lower() == "remnawave")]
xui_old_non = [s for s in xui_old if norm_name(s.get("server_name")) not in xui_target_names]
remna_old_non = [s for s in remna_old if (s.get("api_url") or "").rstrip("/") not in remna_target_urls]
if not target:
if xui_old:
await delete_on_3xui(xui_old, email, client_id)
if remna_old:
await delete_on_remnawave(remna_old, client_id)
return client_id, None
if xui_tgt and not remna_tgt:
await ensure_on_3xui(
servers=xui_tgt,
email=email,
client_id=client_id,
tg_id=tg_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
attempt_update_first=was_on_xui_before,
)
if xui_old_non:
await delete_on_3xui(xui_old_non, email, client_id)
if remna_old_non:
await delete_on_remnawave(remna_old_non, client_id)
return client_id, None
if remna_tgt and not xui_tgt:
if xui_old_non:
await delete_on_3xui(xui_old_non, email, client_id)
new_remna_id, remna_link = await ensure_on_remnawave(
servers=remna_tgt,
email=email,
client_id=client_id,
tg_id=tg_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
reset_traffic=reset_traffic,
attempt_update_first=was_on_remna_before,
)
if remna_old_non:
await delete_on_remnawave(remna_old_non, client_id)
if new_remna_id and new_remna_id != client_id:
await update_key_client_id(session, email, new_remna_id)
client_id = new_remna_id
return client_id, remna_link
if xui_old_non:
await delete_on_3xui(xui_old_non, email, client_id)
old_id = client_id
new_remna_id, remna_link = await ensure_on_remnawave(
servers=remna_tgt,
email=email,
client_id=client_id,
tg_id=tg_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
reset_traffic=reset_traffic,
attempt_update_first=was_on_remna_before,
)
if remna_old_non:
await delete_on_remnawave(remna_old_non, old_id)
if new_remna_id and new_remna_id != old_id:
await update_key_client_id(session, email, new_remna_id)
client_id = new_remna_id
await delete_on_3xui(xui_tgt, email, old_id)
await ensure_on_3xui(
servers=xui_tgt,
email=email,
client_id=client_id,
tg_id=tg_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
attempt_update_first=False,
)
return client_id, remna_link
await ensure_on_3xui(
servers=xui_tgt,
email=email,
client_id=client_id,
tg_id=tg_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
hwid_device_limit=hwid_device_limit,
attempt_update_first=was_on_xui_before,
)
return client_id, remna_link
+94
View File
@@ -0,0 +1,94 @@
import asyncio
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import get_servers
from logger import logger
from panels._3xui import get_xui_instance, toggle_client
from panels.remnawave import RemnawaveAPI
async def toggle_client_on_cluster(
cluster_id: str,
email: str,
client_id: str,
enable: bool = True,
session: AsyncSession = None,
) -> dict[str, Any]:
try:
if session is None:
raise ValueError("[Cluster Toggle] Не передан объект сессии для toggle_client_on_cluster")
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем '{cluster_id}' не найден.")
results = {}
tasks = []
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":
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"[3x-ui] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
results[server_name] = False
continue
xui = await get_xui_instance(server_info["api_url"])
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(toggle_client(xui, int(inbound_id), unique_email, client_id, enable))
elif panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"[Remnawave] Авторизация не удалась на сервере {server_name}")
results[server_name] = False
continue
func = remna.enable_user if enable else remna.disable_user
tasks.append(func(client_id))
else:
logger.warning(
f"[Cluster Toggle] Неизвестный тип панели '{panel_type}' на сервере {server_name}. Пропуск."
)
results[server_name] = False
task_results = await asyncio.gather(*tasks, return_exceptions=True)
for server_info, result in zip(cluster, task_results, strict=False):
server_name = server_info.get("server_name", "unknown")
if isinstance(result, Exception):
logger.error(f"[Cluster Toggle] Ошибка на сервере {server_name}: {result}")
results[server_name] = False
else:
results[server_name] = result
status = "включен" if enable else "отключен"
logger.info(f"[Cluster Toggle] Клиент {email} {status} на серверах кластера {cluster_id}")
logger.debug(f"[Cluster Toggle DEBUG] Результаты: {results}")
return {
"status": "success" if any(results.values()) else "error",
"results": results,
}
except Exception as e:
logger.error(f"[Cluster Toggle] Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
return {"status": "error", "error": str(e)}
+181
View File
@@ -0,0 +1,181 @@
import asyncio
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import get_servers
from database.models import Key, Server
from logger import logger
from panels._3xui import get_client_traffic, get_xui_instance
from panels.remnawave import RemnawaveAPI
async def get_user_traffic(session: AsyncSession, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
"""
result = await session.execute(select(Key.client_id, Key.server_id).where(Key.tg_id == tg_id, Key.email == email))
rows = result.all()
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
server_ids = {row.server_id for row in rows}
server_id = list(server_ids)[0]
result = await session.execute(
select(Server)
.where(Server.enabled.is_(True))
.where(Server.server_name.in_(server_ids) | Server.cluster_name.in_(server_ids))
)
server_rows = result.scalars().all()
if not server_rows:
logger.error(f"Не найдено серверов для: {server_ids}")
return {
"status": "error",
"message": f"Серверы не найдены: {', '.join(server_ids)}",
}
servers_map = {
s.server_name: {
"server_name": s.server_name,
"cluster_name": s.cluster_name,
"api_url": s.api_url,
"panel_type": s.panel_type,
}
for s in server_rows
}
user_traffic_data = {}
tasks = []
remnawave_client_id = None
remnawave_checked = False
remnawave_api_url = None
async def fetch_traffic(server_info: dict, client_id: str) -> tuple[str, Any]:
server_name = server_info["server_name"]
api_url = server_info["api_url"]
panel_type = server_info.get("panel_type", "3x-ui").lower()
try:
if panel_type == "3x-ui":
xui = await get_xui_instance(api_url)
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server_name, round(used_gb, 2)
else:
return server_name, "Ошибка получения трафика"
else:
return server_name, f"Неизвестная панель: {panel_type}"
except Exception as e:
return server_name, f"Ошибка: {e}"
for row in rows:
client_id = row.client_id
server_id = row.server_id
matched_servers = [
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
]
for server_info in matched_servers:
panel_type = server_info.get("panel_type", "3x-ui").lower()
if panel_type == "remnawave" and not remnawave_checked:
remnawave_client_id = client_id
remnawave_api_url = server_info["api_url"]
remnawave_checked = True
elif panel_type == "3x-ui":
tasks.append(fetch_traffic(server_info, client_id))
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in results:
user_traffic_data[server] = result
if remnawave_client_id and remnawave_api_url:
try:
remna = RemnawaveAPI(remnawave_api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
user_traffic_data["Remnawave (общий)"] = "Не удалось авторизоваться"
else:
user_data = await remna.get_user_by_uuid(remnawave_client_id)
if not user_data:
user_traffic_data["Remnawave (общий)"] = "Клиент не найден"
else:
used_bytes = user_data.get("usedTrafficBytes", 0)
used_gb = round(used_bytes / 1073741824, 2)
user_traffic_data["Remnawave (общий)"] = used_gb
except Exception as e:
user_traffic_data["Remnawave (общий)"] = f"Ошибка: {e}"
return {"status": "success", "traffic": user_traffic_data}
async def reset_traffic_in_cluster(cluster_id: str, email: str, session: AsyncSession) -> None:
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
remnawave_done = False
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:
result = await session.execute(
select(Key.client_id).where(Key.email == email, Key.server_id == cluster_id).limit(1)
)
row = result.first()
if not row:
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row[0]
remna = RemnawaveAPI(api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
remnawave_done = True
continue
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(api_url)
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"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+287
View File
@@ -0,0 +1,287 @@
import asyncio
from datetime import datetime, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, SUPERNODE
from database import filter_cluster_by_subgroup, get_servers, store_key
from database.models import Key, Tariff
from handlers.utils import get_least_loaded_cluster
from logger import (
CLOGGER as logger,
PANEL_REMNA,
PANEL_XUI,
)
from panels._3xui import ClientConfig, add_client, get_xui_instance
from panels.remnawave import RemnawaveAPI
from .aggregated_links import make_aggregated_link
from .deletion import delete_key_from_cluster
async def update_key_on_cluster(
tg_id: int,
client_id: str,
email: str,
expiry_time: int,
cluster_id: str,
session: AsyncSession,
traffic_limit: int = None,
device_limit: int = None,
remnawave_link: str = None,
subgroup_code: str | None = None,
):
try:
servers = await get_servers(session)
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
if subgroup_code:
filtered = await filter_cluster_by_subgroup(session, cluster, subgroup_code, cluster_id)
if not filtered:
logger.warning(f"[Update] Нет серверов для подгруппы {subgroup_code} в кластере {cluster_id}.")
return client_id, remnawave_link
cluster = filtered
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
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"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
group_code = remnawave_servers[0].get("tariff_group")
if not group_code:
raise ValueError("У Remnawave-сервера отсутствует tariff_group")
_ = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
logger.debug(f"{PANEL_REMNA} Извлечен short_uuid: {short_uuid}")
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeInternalSquads": inbound_ids,
"uuid": client_id,
}
if traffic_limit is not None:
user_data["trafficLimitBytes"] = traffic_limit * 1024**3
if device_limit is not None:
user_data["hwidDeviceLimit"] = device_limit
if short_uuid:
user_data["shortUuid"] = short_uuid
logger.debug(f"{PANEL_REMNA} Добавлен short_uuid: {short_uuid}")
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(f"{PANEL_REMNA} Клиент заново создан, uuid={remnawave_client_id}")
else:
logger.error(f"{PANEL_REMNA} Ошибка создания клиента")
else:
logger.error(f"{PANEL_REMNA} Не удалось авторизоваться")
if not remnawave_client_id:
logger.warning(f"{PANEL_REMNA} client_id не получен, используем исходный {client_id}")
remnawave_client_id = client_id
tasks = []
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"{PANEL_XUI} INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(server_info["api_url"])
sub_id = email
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
group_code = server_info.get("tariff_group")
if not group_code:
raise ValueError(f"У сервера {server_name} отсутствует tariff_group")
_ = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
total_gb_bytes = int(traffic_limit * 1024**3) if traffic_limit is not None else 0
device_limit_value = device_limit if device_limit is not None else 0
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=device_limit_value,
total_gb=total_gb_bytes,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
tasks.append(add_client(xui, config))
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"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise
async def update_subscription(
tg_id: int,
email: str,
session: AsyncSession,
cluster_override: str = None,
country_override: str = None,
remnawave_link: str = None,
) -> None:
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.email == email))
record = result.scalar_one_or_none()
if not record:
raise ValueError(f"The key {email} does not exist in database")
expiry_time = record.expiry_time
client_id = record.client_id
old_cluster_id = record.server_id
tariff_id = record.tariff_id
alias = record.alias
remnawave_link = remnawave_link or record.remnawave_link
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
tariff = None
subgroup_code = getattr(record, "subgroup_code", None)
if tariff_id:
q = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = q.scalar_one_or_none()
if tariff is None:
logger.warning(f"[LOG] update_subscription: тариф с id={tariff_id} не найден!")
else:
if not subgroup_code:
subgroup_code = getattr(tariff, "subgroup_code", None) or getattr(tariff, "subgroup_title", None)
else:
logger.warning("[LOG] update_subscription: tariff_id отсутствует!")
await delete_key_from_cluster(old_cluster_id, email, client_id, session=session)
await session.execute(delete(Key).where(Key.tg_id == tg_id, Key.email == email))
await session.commit()
if country_override or cluster_override:
new_cluster_id = country_override or cluster_override
else:
try:
new_cluster_id = await get_least_loaded_cluster(session)
except ValueError:
logger.warning("[Update] Нет доступных кластеров, оставляем на старом")
new_cluster_id = old_cluster_id
servers = await get_servers(session)
cluster_servers = servers.get(new_cluster_id)
if cluster_servers is None:
for server_list in servers.values():
for server_info in server_list:
if server_info.get("server_name", "").lower() == new_cluster_id.lower():
cluster_servers = [server_info]
break
if cluster_servers:
break
else:
cluster_servers = []
if subgroup_code:
prefiltered = await filter_cluster_by_subgroup(session, cluster_servers, subgroup_code, new_cluster_id)
if not prefiltered:
logger.warning(
f"[Update] Пересоздание пропущено: нет серверов под подгруппу {subgroup_code} в {new_cluster_id}."
)
return
cluster_servers = prefiltered
traffic_limit = None
device_limit = None
if tariff:
traffic_limit = int(tariff.traffic_limit) if tariff.traffic_limit is not None else None
device_limit = int(tariff.device_limit) if tariff.device_limit is not None else 0
new_client_id, remnawave_key = await update_key_on_cluster(
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_time=expiry_time,
cluster_id=new_cluster_id,
session=session,
traffic_limit=traffic_limit,
device_limit=device_limit,
remnawave_link=remnawave_link,
subgroup_code=subgroup_code,
)
aggregated = await make_aggregated_link(
session=session,
cluster_all=cluster_servers,
cluster_id=new_cluster_id,
email=email,
client_id=new_client_id,
tg_id=tg_id,
subgroup_code=subgroup_code,
remna_link_override=remnawave_key,
plan=tariff_id,
)
final_key_link = aggregated or public_link
await store_key(
session=session,
tg_id=tg_id,
client_id=new_client_id,
email=email,
expiry_time=expiry_time,
key=final_key_link,
remnawave_link=remnawave_key,
server_id=new_cluster_id,
tariff_id=tariff_id,
alias=alias,
)
+53
View File
@@ -0,0 +1,53 @@
def split_by_panel(servers: list) -> tuple[list, list]:
xui = []
remna = []
for s in servers:
pt = str(s.get("panel_type", "3x-ui")).lower()
if pt == "3x-ui":
xui.append(s)
elif pt == "remnawave":
remna.append(s)
return xui, remna
def bytes_from_gb(total_gb: int) -> int:
return total_gb * 1024 * 1024 * 1024 if total_gb else 0
def is_plan_vless(plan) -> bool:
if plan is None:
return False
if isinstance(plan, dict):
return bool(plan.get("vless"))
return bool(getattr(plan, "vless", False))
def score_vless_url(url: str) -> int:
u = url.lower()
if not u.startswith("vless://"):
return -1
s = 0
if "security=reality" in u and "type=tcp" in u:
s += 4
if "type=ws" in u and "security=tls" in u:
s += 3
if "security=tls" in u and "type=tcp" in u:
s += 2
if "type=ws" in u:
s += 1
return s
def norm_name(x: str | None) -> str:
return (x or "").strip().lower()
def unique_by_api_url(servers: list) -> list:
seen = set()
out = []
for s in servers or []:
url = (s.get("api_url") or "").rstrip("/")
if url and url not in seen:
seen.add(url)
out.append(s)
return out
+2
View File
@@ -160,6 +160,7 @@ def clean_subscription_line(line: str) -> str:
return line
try:
base, meta = line.split("#", 1)
meta = urllib.parse.unquote(meta).strip()
except ValueError:
return line
parts = meta.split("-")
@@ -202,6 +203,7 @@ def prepare_headers(
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"profile-web-page-url": f"https://t.me/{USERNAME_BOT}",
"subscription-userinfo": subscription_userinfo,
# "routing": "happ://routing/onadd/...",
}
elif "Hiddify" in user_agent:
parts = subscription_info.split(" - ")[0].split(": ")
+141 -98
View File
@@ -9,6 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from config import (
NOTIFICATION_TIME,
NOTIFY_10H_ENABLED,
NOTIFY_10H_HOURS,
NOTIFY_24H_ENABLED,
NOTIFY_24H_HOURS,
NOTIFY_DELETE_DELAY,
NOTIFY_DELETE_KEY,
NOTIFY_HOT_LEADS,
@@ -33,24 +37,26 @@ from database import (
update_key_expiry,
update_key_tariff,
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.keys.operations import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import (
build_change_tariff_kb,
build_notification_expired_kb,
build_notification_kb,
)
from handlers.texts import (
KEY_CANNOT_RENEW_CURRENT,
KEY_DELETED_MSG,
KEY_EXPIRED_DELAY_MSG,
KEY_EXPIRED_NO_DELAY_MSG,
KEY_EXPIRY_10H,
KEY_EXPIRY_24H,
KEY_EXPIRY,
get_renewal_message,
)
from handlers.utils import format_days, format_hours, format_minutes, format_months, get_russian_month
from handlers.utils import format_hours, format_minutes, get_russian_month
from hooks.hooks import run_hooks
from logger import logger
from .hot_leads_notifications import notify_hot_leads
from .notify_utils import send_messages_with_limit, send_notification
from .notify_utils import prepare_key_expiry_data, send_messages_with_limit, send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
@@ -69,11 +75,9 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
async with notification_lock:
try:
async with sessionmaker() as session:
logger.info("🔔 Запуск обработки уведомлений")
logger.info("Запуск обработки уведомлений")
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
try:
keys = await get_all_keys(session=session)
@@ -88,15 +92,23 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
except Exception as e:
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
try:
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
if NOTIFY_24H_ENABLED:
try:
threshold_24h = int(
(datetime.now(moscow_tz) + timedelta(hours=NOTIFY_24H_HOURS)).timestamp() * 1000
)
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
try:
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
if NOTIFY_10H_ENABLED:
try:
threshold_10h = int(
(datetime.now(moscow_tz) + timedelta(hours=NOTIFY_10H_HOURS)).timestamp() * 1000
)
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
try:
await handle_expired_keys(bot, session, current_time, keys)
@@ -108,6 +120,10 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
await notify_users_no_traffic(bot, session, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
try:
await run_hooks("periodic_notifications", bot=bot, session=session, keys=keys)
except Exception as e:
logger.error(f"Ошибка в хуках periodic_notifications: {e}")
if NOTIFY_HOT_LEADS:
try:
@@ -115,7 +131,7 @@ async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
except Exception as e:
logger.error(f"Ошибка в notify_hot_leads: {e}")
logger.info("Уведомления завершены")
logger.info("Уведомления завершены")
except Exception as e:
logger.error(f"Ошибка в periodic_notifications: {e}")
@@ -129,47 +145,37 @@ async def notify_24h_keys(
threshold_time_24h: int,
keys: list,
):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
"""
logger.info("Начало проверки подписок, истекающих через 24 часа.")
logger.info(f"Начало проверки подписок, истекающих через {NOTIFY_24H_HOURS} часов.")
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {NOTIFY_24H_HOURS} часов.")
tg_ids = [key["tg_id"] for key in expiring_keys]
tg_ids = [getattr(key, "tg_id", key["tg_id"]) for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
allowed = await check_notifications_bulk(session, "key_24h", NOTIFY_24H_HOURS, tg_ids=tg_ids, emails=emails)
users = await check_notifications_bulk(session, "key_24h", 24, tg_ids=tg_ids, emails=emails)
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
tg_id = getattr(key, "tg_id", key["tg_id"])
email = key.email or ""
if (tg_id, email) not in allowed_set:
continue
notification_id = f"{email}_key_24h"
can_notify = await check_notification_time(session, tg_id, notification_id, hours=24)
can_notify = await check_notification_time(session, tg_id, notification_id, hours=NOTIFY_24H_HOURS)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_data = await prepare_key_expiry_data(key, session, current_time)
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
notification_text = KEY_EXPIRY_24H.format(
notification_text = KEY_EXPIRY.format(
email=email,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
if NOTIFY_RENEW:
@@ -202,17 +208,18 @@ async def notify_24h_keys(
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
await add_notification(session, tg_id, msg["notification_id"])
if result:
await add_notification(session, tg_id, msg["notification_id"])
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
logger.info(f"Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {NOTIFY_24H_HOURS} часов.")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
logger.info(f"Обработка всех уведомлений за {NOTIFY_24H_HOURS} часов завершена.")
await asyncio.sleep(1)
@@ -223,43 +230,37 @@ async def notify_10h_keys(
threshold_time_10h: int,
keys: list,
):
logger.info("Начало проверки подписок, истекающих через 10 часов.")
logger.info(f"Начало проверки подписок, истекающих через {NOTIFY_10H_HOURS} часов.")
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через {NOTIFY_10H_HOURS} часов.")
tg_ids = [key.tg_id for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
allowed = await check_notifications_bulk(session, "key_10h", NOTIFY_10H_HOURS, tg_ids=tg_ids, emails=emails)
users = await check_notifications_bulk(session, "key_10h", 10, tg_ids=tg_ids, emails=emails)
allowed_set = {(u["tg_id"], u["email"]) for u in allowed}
messages = []
for key in expiring_keys:
tg_id = key.tg_id
email = key.email or ""
if (tg_id, email) not in allowed_set:
continue
notification_id = f"{email}_key_10h"
can_notify = await check_notification_time(session, tg_id, notification_id, hours=10)
can_notify = await check_notification_time(session, tg_id, notification_id, hours=NOTIFY_10H_HOURS)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_data = await prepare_key_expiry_data(key, session, current_time)
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
notification_text = KEY_EXPIRY_10H.format(
notification_text = KEY_EXPIRY.format(
email=email,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
if NOTIFY_RENEW:
@@ -292,17 +293,18 @@ async def notify_10h_keys(
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
await add_notification(session, tg_id, msg["notification_id"])
if result:
await add_notification(session, tg_id, msg["notification_id"])
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
logger.info(f"Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
f"Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через {NOTIFY_10H_HOURS} часов.")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
logger.info(f"Обработка всех уведомлений за {NOTIFY_10H_HOURS} часов завершена.")
await asyncio.sleep(1)
@@ -449,7 +451,7 @@ async def process_auto_renew_or_notify(
try:
can_renew = await check_notification_time(conn, tg_id, renew_notification_id, hours=24)
if not can_renew:
logger.info(
logger.debug(
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
)
return
@@ -466,39 +468,76 @@ async def process_auto_renew_or_notify(
selected_tariff = None
if not tariff_id:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
selected_tariff = None
else:
if await check_tariff_exists(conn, tariff_id):
current_tariff = await get_tariff_by_id(conn, tariff_id)
if current_tariff["group_code"] in ["discounts", "discounts_max", "gifts"]:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
try:
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn)
for hook_result in hook_results:
additional_groups = hook_result.get("additional_groups", [])
forbidden_groups.extend(additional_groups)
except Exception as e:
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
if current_tariff["group_code"] in forbidden_groups:
selected_tariff = None
elif balance >= current_tariff["price_rub"]:
selected_tariff = current_tariff
else:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
selected_tariff = None
if not selected_tariff:
keyboard = build_notification_kb(email)
expiry_data = await prepare_key_expiry_data(key, conn, int(datetime.now(moscow_tz).timestamp() * 1000))
use_change_tariff_kb = False
if tariff_id and await check_tariff_exists(conn, tariff_id):
current_tariff = await get_tariff_by_id(conn, tariff_id)
if current_tariff:
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
try:
hook_results = await run_hooks(
"renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn
)
for hook_result in hook_results:
additional_groups = hook_result.get("additional_groups", [])
forbidden_groups.extend(additional_groups)
except Exception as e:
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
if current_tariff["group_code"] in forbidden_groups:
use_change_tariff_kb = True
message_text = KEY_CANNOT_RENEW_CURRENT.format(
email=email,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
else:
use_change_tariff_kb = True
message_text = KEY_CANNOT_RENEW_CURRENT.format(
email=email,
hours_left_formatted=expiry_data["hours_left_formatted"],
formatted_expiry_date=expiry_data["formatted_expiry_date"],
tariff_name=expiry_data["tariff_name"],
tariff_details=expiry_data["tariff_details"],
)
if use_change_tariff_kb:
keyboard = build_change_tariff_kb(email)
else:
keyboard = build_notification_kb(email)
await add_notification(conn, tg_id, notification_id)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
text_to_send = message_text if "message_text" in locals() else standard_caption
await send_notification(bot, tg_id, standard_photo, text_to_send, keyboard)
return
client_id = key.client_id
@@ -527,6 +566,8 @@ async def process_auto_renew_or_notify(
f"Продление подписки {email} на {duration_days} дней для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
)
key_subgroup = selected_tariff.get("subgroup_title")
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
@@ -535,6 +576,8 @@ async def process_auto_renew_or_notify(
total_gb=total_gb,
hwid_device_limit=device_limit,
session=conn,
target_subgroup=key_subgroup,
old_subgroup=key_subgroup,
)
await update_balance(conn, tg_id, -renewal_cost)
await update_key_expiry(conn, client_id, int(new_expiry_time))
@@ -19,7 +19,7 @@ from logger import logger
async def notify_hot_leads(bot: Bot, session: AsyncSession):
logger.info("🚀 Запуск уведомлений для горячих лидов.")
logger.info("Запуск уведомлений для горячих лидов.")
try:
leads = await get_hot_leads(session)
@@ -51,7 +51,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
result = await send_notification(bot, tg_id, None, HOT_LEAD_MESSAGE, keyboard)
if result:
await add_notification(session, tg_id, "hot_lead_step_2")
logger.info(f"🔥 Шаг 2 — отправлено первое уведомление: {tg_id}")
logger.info(f"Шаг 2 — отправлено первое уведомление: {tg_id}")
notified += 1
continue
@@ -97,7 +97,7 @@ async def notify_hot_leads(bot: Bot, session: AsyncSession):
logger.info(f"⚡ Шаг 3 — отправлено финальное уведомление: {tg_id}")
notified += 1
logger.info(f"Уведомления завершены. Отправлено: {notified}")
logger.info(f"Уведомления завершены. Отправлено: {notified}")
except Exception as e:
logger.error(f"❌ Ошибка в notify_hot_leads: {e}")
+15 -1
View File
@@ -1,6 +1,6 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from handlers.buttons import DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY_NOTIFICATION
from handlers.buttons import CHANGE_TARIFF, DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY_NOTIFICATION
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
@@ -17,6 +17,20 @@ def build_notification_kb(email: str) -> InlineKeyboardMarkup:
return builder.as_markup()
def build_change_tariff_kb(email: str) -> InlineKeyboardMarkup:
"""
Формирует inline-клавиатуру для смены тарифа.
Кнопки: "🔄 Сменить тариф" (callback_data содержит email) и "👤 Личный кабинет".
"""
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.button(text=CHANGE_TARIFF, callback_data=f"renew_key|{email}")
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1)
return builder.as_markup()
def build_notification_expired_kb() -> InlineKeyboardMarkup:
"""
Формирует inline-клавиатуру для уведомлений после удаления или продления.
+44 -1
View File
@@ -1,7 +1,10 @@
import asyncio
import os
from datetime import datetime
import aiofiles
import pytz
from aiogram import Bot
from aiogram.exceptions import (
@@ -12,7 +15,8 @@ from aiogram.exceptions import (
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from database import create_blocked_user
from database import create_blocked_user, get_tariff_by_id
from handlers.utils import format_hours
from logger import logger
@@ -157,3 +161,42 @@ async def _send_text_notification(
except Exception as e:
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
return False
async def prepare_key_expiry_data(key, session: AsyncSession, current_time: int) -> dict:
moscow_tz = pytz.timezone("Europe/Moscow")
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
tariff_name = ""
tariff_details = ""
if getattr(key, "tariff_id", None):
tariff = await get_tariff_by_id(session, key.tariff_id)
if tariff:
tariff_name = tariff.get("name") or ""
traffic_limit = tariff.get("traffic_limit") or 0
device_limit = tariff.get("device_limit") or 0
subgroup_title = tariff.get("subgroup_title", "")
traffic_text = "безлимит" if traffic_limit <= 0 else f"{traffic_limit} ГБ"
devices_text = "безлимит" if device_limit <= 0 else str(device_limit)
lines = []
if subgroup_title:
lines.append(subgroup_title)
lines.append(f"Трафик: {traffic_text}")
lines.append(f"Устройств: {devices_text}")
tariff_details = "\n" + "\n".join(lines)
return {
"hours_left_formatted": hours_left_formatted,
"formatted_expiry_date": formatted_expiry_date,
"tariff_name": tariff_name,
"tariff_details": tariff_details,
}
+28 -12
View File
@@ -12,8 +12,8 @@ from config import (
NOTIFY_EXTRA_DAYS,
NOTIFY_INACTIVE,
NOTIFY_INACTIVE_TRAFFIC,
REMNAWAVE_WEBAPP,
SUPPORT_CHAT_URL,
TRIAL_CONFIG,
)
from database import (
add_notification,
@@ -21,9 +21,9 @@ from database import (
mark_trial_extended,
update_key_notified,
)
from database.models import Key
from database.tariffs import get_tariffs
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, TV_BUTTON
from handlers.keys.key_utils import get_user_traffic
from handlers.keys.operations import get_user_traffic
from handlers.notifications.notify_utils import send_messages_with_limit
from handlers.texts import (
TRIAL_INACTIVE_BONUS_MSG,
@@ -31,6 +31,8 @@ from handlers.texts import (
ZERO_TRAFFIC_MSG,
)
from handlers.utils import format_days, is_full_remnawave_cluster
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
from logger import logger
@@ -44,7 +46,12 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
messages = []
trial_days = TRIAL_CONFIG["duration_days"]
trial_tariffs = await get_tariffs(session, group_code="trial")
if not trial_tariffs:
logger.error("[Notifications] Триальный тариф не найден")
return
trial_days = trial_tariffs[0]["duration_days"]
for user in users:
tg_id = user["tg_id"]
@@ -95,7 +102,7 @@ async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
await add_notification(session, msg["tg_id"], msg["notification_id"])
sent_count += 1
logger.info(f"Отправлено {sent_count} уведомлений неактивным пользователям.")
logger.info("Проверка пользователей с неактивным пробным периодом завершена.")
logger.info("Проверка пользователей с неактивным пробным периодом завершена.")
async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time: int, keys: list):
@@ -137,12 +144,6 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
)
try:
await update_key_notified(session, tg_id, client_id)
except Exception as e:
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {e}")
continue
if total_traffic == 0:
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
builder = InlineKeyboardBuilder()
@@ -152,7 +153,7 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
is_full_remnawave = await is_full_remnawave_cluster(server_id, session)
final_link = key.key or key.remnawave_link
if is_full_remnawave and final_link:
if is_full_remnawave and final_link and REMNAWAVE_WEBAPP:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
else:
if CONNECT_PHONE_BUTTON:
@@ -169,6 +170,16 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
builder.row(InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
try:
hook_commands = await run_hooks(
"zero_traffic_notification", chat_id=tg_id, admin=False, session=session, email=email
)
if hook_commands:
builder = insert_hook_buttons(builder, hook_commands)
except Exception as e:
logger.warning(f"[ZERO_TRAFFIC_NOTIFICATION] Ошибка при применении хуков: {e}")
keyboard = builder.as_markup()
message = ZERO_TRAFFIC_MSG.format(email=email)
messages.append({
@@ -178,6 +189,11 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time:
"client_id": client_id,
})
try:
await update_key_notified(session, tg_id, client_id)
except Exception as e:
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {e}")
if messages:
results = await send_messages_with_limit(
bot,
-208
View File
@@ -1,208 +0,0 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CRYPTO_BOT_ENABLE,
DONATIONS_ENABLE,
FREEKASSA_ENABLE,
HELEKET_ENABLE,
KASSAI_ENABLE,
ROBOKASSA_ENABLE,
STARS_ENABLE,
WATA_INT_ENABLE,
WATA_RU_ENABLE,
WATA_SBP_ENABLE,
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
)
from database import get_last_payments
from database.models import User
from handlers.buttons import (
BALANCE_HISTORY,
COUPON,
CRYPTOBOT,
FREEKASSA,
HELEKET_CRYPTO,
KASSAI_CARDS,
KASSAI_SBP,
MAIN_MENU,
PAYMENT,
ROBOKASSA,
STARS,
WATA_INT,
WATA_RU,
WATA_SBP,
YOOKASSA,
YOOMONEY,
)
from handlers.payments.cryprobot_pay import process_callback_pay_cryptobot
from handlers.payments.freekassa_pay import process_callback_pay_freekassa
from handlers.payments.heleket import process_callback_pay_heleket
from handlers.payments.kassai import process_callback_pay_kassai
from handlers.payments.robokassa_pay import process_callback_pay_robokassa
from handlers.payments.stars_pay import process_callback_pay_stars
from handlers.payments.wata import process_callback_pay_wata
from handlers.payments.yookassa_pay import process_callback_pay_yookassa
from handlers.payments.yoomoney_pay import process_callback_pay_yoomoney
from handlers.texts import BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
from .utils import edit_or_send_message
router = Router()
@router.callback_query(F.data == "pay")
async def handle_pay(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
payment_handlers = []
if YOOKASSA_ENABLE:
payment_handlers.append(process_callback_pay_yookassa)
if YOOMONEY_ENABLE:
payment_handlers.append(process_callback_pay_yoomoney)
if KASSAI_ENABLE:
payment_handlers.append(process_callback_pay_kassai)
if HELEKET_ENABLE:
payment_handlers.append(process_callback_pay_heleket)
if WATA_RU_ENABLE or WATA_SBP_ENABLE or WATA_INT_ENABLE:
payment_handlers.append(process_callback_pay_wata)
if CRYPTO_BOT_ENABLE:
payment_handlers.append(process_callback_pay_cryptobot)
if STARS_ENABLE:
payment_handlers.append(process_callback_pay_stars)
if ROBOKASSA_ENABLE:
payment_handlers.append(process_callback_pay_robokassa)
if FREEKASSA_ENABLE:
payment_handlers.append(process_callback_pay_freekassa)
if len(payment_handlers) == 1:
await callback_query.answer()
return await payment_handlers[0](callback_query, state, session)
builder = InlineKeyboardBuilder()
if YOOKASSA_ENABLE:
builder.row(InlineKeyboardButton(text=YOOKASSA, callback_data="pay_yookassa"))
if YOOMONEY_ENABLE:
builder.row(InlineKeyboardButton(text=YOOMONEY, callback_data="pay_yoomoney"))
if KASSAI_ENABLE:
builder.row(InlineKeyboardButton(text=KASSAI_CARDS, callback_data="pay_kassai_cards"))
builder.row(InlineKeyboardButton(text=KASSAI_SBP, callback_data="pay_kassai_sbp"))
if HELEKET_ENABLE:
builder.row(InlineKeyboardButton(text=HELEKET_CRYPTO, callback_data="pay_heleket_crypto"))
if CRYPTO_BOT_ENABLE:
builder.row(InlineKeyboardButton(text=CRYPTOBOT, callback_data="pay_cryptobot"))
if STARS_ENABLE:
builder.row(InlineKeyboardButton(text=STARS, callback_data="pay_stars"))
if ROBOKASSA_ENABLE:
builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa"))
if FREEKASSA_ENABLE:
builder.row(InlineKeyboardButton(text=FREEKASSA, callback_data="pay_freekassa"))
if WATA_RU_ENABLE:
builder.row(InlineKeyboardButton(text=WATA_RU, callback_data="pay_wata_ru"))
if WATA_SBP_ENABLE:
builder.row(InlineKeyboardButton(text=WATA_SBP, callback_data="pay_wata_sbp"))
if WATA_INT_ENABLE:
builder.row(InlineKeyboardButton(text=WATA_INT, callback_data="pay_wata_int"))
if DONATIONS_ENABLE:
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=PAYMENT_METHODS_MSG,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "balance")
async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
stmt = select(User.balance).where(User.tg_id == callback_query.from_user.id)
result = await session.execute(stmt)
balance = result.scalar_one_or_none() or 0.0
balance = int(balance)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
image_path = os.path.join("img", "pay.jpg")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "balance_history")
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
records = await get_last_payments(session, callback_query.from_user.id)
if records:
history_text = "<b>💳 История операций:</b>\n\n<blockquote>"
for record in records:
amount = record["amount"]
payment_system = record["payment_system"]
status = record["status"]
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
history_text += f"Сумма: {amount}\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
history_text += "</blockquote>"
else:
history_text = "❌ У вас пока нет операций с балансом."
await edit_or_send_message(
target_message=callback_query.message,
text=history_text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "pay_wata_ru")
async def handle_pay_wata_ru(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="ru")
@router.callback_query(F.data == "pay_wata_sbp")
async def handle_pay_wata_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="sbp")
@router.callback_query(F.data == "pay_wata_int")
async def handle_pay_wata_int(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_wata(callback_query, state, session, cassa_name="int")
@router.callback_query(F.data == "pay_kassai_cards")
async def handle_pay_kassai_cards(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_kassai(callback_query, state, session, method_name="cards")
@router.callback_query(F.data == "pay_kassai_sbp")
async def handle_pay_kassai_sbp(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_kassai(callback_query, state, session, method_name="sbp")
@router.callback_query(F.data == "pay_heleket_crypto")
async def handle_pay_heleket_crypto(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_heleket(callback_query, state, session, method_name="crypto")
+28 -27
View File
@@ -2,46 +2,47 @@ __all__ = ("router",)
from aiogram import Router
from config import (
CRYPTO_BOT_ENABLE,
FREEKASSA_ENABLE,
KASSAI_ENABLE,
ROBOKASSA_ENABLE,
STARS_ENABLE,
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
HELEKET_ENABLE,
)
from config import PROVIDERS_ENABLED
from handlers.payments.providers import get_providers
from .cryprobot_pay import router as cryprobot_router
from .freekassa_pay import router as freekassa_router
from .cryptobot import router as cryptobot_router
from .fast_payment_flow import router as fast_payment_flow_router
from .freekassa.freekassa_pay import router as freekassa_router
from .gift import router as gift_router
from .kassai import router as kassai_router
from .robokassa_pay import router as robokassa_router
from .stars_pay import router as stars_router
from .yookassa_pay import router as yookassa_router
from .yoomoney_pay import router as yoomoney_router
from .wata import router as wata_router
from .heleket import router as heleket_router
from .kassai import router as kassai_router
from .pay import router as pay_router
from .robokassa import router as robokassa_router
from .stars import router as stars_router
from .tribute import router as tribute_router
from .wata.wata import router as wata_router
from .yookassa import router as yookassa_router
from .yoomoney import router as yoomoney_router
router = Router(name="payments_main_router")
if YOOKASSA_ENABLE:
PROVIDERS = get_providers(PROVIDERS_ENABLED)
if PROVIDERS.get("YOOKASSA", {}).get("enabled"):
router.include_router(yookassa_router)
if YOOMONEY_ENABLE:
if PROVIDERS.get("YOOMONEY", {}).get("enabled"):
router.include_router(yoomoney_router)
if ROBOKASSA_ENABLE:
if PROVIDERS.get("ROBOKASSA", {}).get("enabled"):
router.include_router(robokassa_router)
if FREEKASSA_ENABLE:
if PROVIDERS.get("FREEKASSA", {}).get("enabled"):
router.include_router(freekassa_router)
if CRYPTO_BOT_ENABLE:
router.include_router(cryprobot_router)
if STARS_ENABLE:
if PROVIDERS.get("CRYPTOBOT", {}).get("enabled"):
router.include_router(cryptobot_router)
if PROVIDERS.get("STARS", {}).get("enabled"):
router.include_router(stars_router)
if KASSAI_ENABLE:
if PROVIDERS.get("KASSAI_CARDS", {}).get("enabled") or PROVIDERS.get("KASSAI_SBP", {}).get("enabled"):
router.include_router(kassai_router)
if HELEKET_ENABLE:
if PROVIDERS.get("HELEKET", {}).get("enabled"):
router.include_router(heleket_router)
router.include_router(tribute_router)
router.include_router(wata_router)
router.include_router(gift_router)
router.include_router(pay_router)
router.include_router(fast_payment_flow_router)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+92
View File
@@ -0,0 +1,92 @@
from typing import Iterable, List, Any
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.texts import FAST_PAY_NOT_ENOUGH
from handlers.buttons import RUB_CURRENCY, USD_CURRENCY, STARS, MAIN_MENU
from config import TRIBUTE_LINK
from .currency_rates import format_for_user
def build_currency_choice_kb(
show_stars: bool,
*,
prefix: str = "choose_payment_currency",
show_tribute: bool | None = None,
) -> InlineKeyboardBuilder:
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text=RUB_CURRENCY, callback_data=f"{prefix}|RUB"))
kb.row(InlineKeyboardButton(text=USD_CURRENCY, callback_data=f"{prefix}|USD"))
trib = (TRIBUTE_LINK or "").strip()
trib_enabled = (bool(trib) if show_tribute is None else bool(show_tribute) and bool(trib))
if show_stars:
row = [InlineKeyboardButton(text=STARS, callback_data=f"{prefix}|STARS")]
if trib_enabled:
row.append(InlineKeyboardButton(text="TRIBUTE", url=trib))
kb.row(*row)
else:
if trib_enabled:
kb.row(InlineKeyboardButton(text="TRIBUTE", url=trib))
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
return kb
async def shortfall_lead_text(
session: Any,
tg_id: int,
required_amount: int | float | None,
language_code: str | None,
*,
force_currency: str | None = None,
) -> str:
if not isinstance(required_amount, (int, float)) or required_amount <= 0:
return "💳"
amount_txt = await format_for_user(
session, tg_id, float(required_amount), language_code, force_currency=force_currency
)
return FAST_PAY_NOT_ENOUGH.format(amount=amount_txt)
def filter_providers_by_currency(
currency: str,
providers: Iterable[str],
rub_providers: Iterable[str],
) -> List[str]:
rub_set = {p.upper() for p in rub_providers}
out: List[str] = []
for p in providers:
up = p.upper()
if currency == "RUB":
if up in rub_set or up == "WATA":
out.append(p)
elif currency == "USD":
if (up not in rub_set or up == "WATA") and up != "STARS":
out.append(p)
elif currency == "STARS":
if up == "STARS":
out.append(p)
else:
out.append(p)
return out
def currency_for_provider(up_provider: str, rub_providers: Iterable[str]) -> str | None:
if up_provider in {p.upper() for p in rub_providers}:
return "RUB"
if up_provider == "STARS":
return "STARS"
if up_provider == "WATA":
return None
return "USD"
def currency_label(code: str) -> str:
if code == "RUB":
return "RUB"
if code == "USD":
return "USD/Cryptowallet"
if code == "STARS":
return "Telegram Stars"
return code
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
import sqlalchemy as sa
from typing import Optional, Tuple
import time
from decimal import ROUND_HALF_UP, Decimal
import aiohttp
from config import MULTICURRENCY_ENABLE, FX_MARKUP, RUB_TO_USD
CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js"
CACHE_TTL = 60 * 30
cache: dict[str, tuple[float, Decimal]] = {}
def _q(x: Decimal, prec: int = 8) -> Decimal:
return x.quantize(Decimal(10) ** -prec, rounding=ROUND_HALF_UP)
def _round2(x: Decimal) -> Decimal:
return x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
async def to_rub(amount: float | Decimal, base: str, *, session: aiohttp.ClientSession | None = None) -> Decimal:
"""
Переводит сумму ИЗ валюты base В РУБЛИ.
Использует get_rub_rate(base): base_per_rub, т.е. СКОЛЬКО единиц base в 1 рубле.
RUB = amount / (base_per_rub).
"""
rate = await get_rub_rate(base, session=session)
return _q(Decimal(amount) / rate, prec=2)
async def get_rub_rate(quote: str, *, session: aiohttp.ClientSession | None = None) -> Decimal:
code = quote.upper()
if code == "RUB":
return Decimal("1")
if code == "USD" and RUB_TO_USD not in (False, None, 0):
rate = _q(Decimal("1") / Decimal(str(RUB_TO_USD)))
cache[code] = (time.time(), rate)
return rate
now = time.time()
cached = cache.get(code)
if cached and now - cached[0] < CACHE_TTL:
return cached[1]
owns = False
s = session
if s is None:
s = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
owns = True
try:
async with s.get(CBR_URL, headers={"Accept": "application/json"}) as resp:
resp.raise_for_status()
data = await resp.json(content_type=None)
finally:
if owns and not s.closed:
await s.close()
valutes = data.get("Valute") or {}
v = valutes.get(code)
if not v:
raise ValueError(f"Валюта {code} не найдена у ЦБ")
rub_per_unit = Decimal(str(v["Value"])) / Decimal(str(v.get("Nominal", 1)))
rate = _q(Decimal("1") / rub_per_unit)
if code != "RUB" and FX_MARKUP:
pct = Decimal(str(FX_MARKUP)) / Decimal("100")
rate = _q(rate * (Decimal("1") + pct))
cache[code] = (now, rate)
return rate
async def convert_from_rub(
amount_rub: Decimal | float,
to_ccy: str,
*,
session: aiohttp.ClientSession | None = None
) -> Decimal:
"""
Конвертирует сумму из RUB в валюту to_ccy, используя get_rub_rate(to_ccy).
"""
amt = Decimal(str(amount_rub))
ccy = to_ccy.upper()
if ccy == "RUB":
return _round2(amt)
rate = await get_rub_rate(ccy, session=session)
val = amt * rate
return _round2(val)
def pick_currency(
language_code: str | None,
user_currency: str | None = None,
force_currency: str | None = None
) -> str:
if not MULTICURRENCY_ENABLE:
return "RUB"
if force_currency in {"USD", "RUB"}:
return force_currency
if user_currency in {"USD", "RUB"}:
return user_currency
code = (language_code or "").split("-")[0].lower()
return "RUB" if code == "ru" else "USD"
def fmt_money(amount: Decimal, currency: str, language_code: str | None) -> str:
q = _round2(amount)
if currency == "USD":
s = f"{q:,.2f}"
if (language_code or "").startswith("ru"):
s = s.replace(",", " ")
return f"${s}"
s = f"{q:,.2f}".replace(",", " ")
return f"{s}"
async def display_price(
amount_rub: Decimal | float,
language_code: str | None,
*,
user_currency: str | None = None,
force_currency: str | None = None,
session: aiohttp.ClientSession | None = None,
) -> tuple[str, str, Decimal]:
if force_currency in {"USD", "RUB"}:
cur = force_currency
if cur == "RUB":
val = _round2(Decimal(str(amount_rub)))
else:
val = await convert_from_rub(Decimal(str(amount_rub)), "USD", session=session)
txt = fmt_money(val, cur, language_code)
return txt, cur, val
val = _round2(Decimal(str(amount_rub)))
txt = fmt_money(val, "RUB", language_code)
return txt, "RUB", val
async def money_for_user(
db_session,
tg_id: int,
amount_rub: float | int | Decimal,
language_code: Optional[str],
force_currency: Optional[str] = None,
) -> Tuple[str, str, Decimal]:
"""
Возвращает: (text, currency, value)
- text: строка для показа пользователю, например "$12.34" или "1 234.00 ₽"
- currency: "USD" или "RUB"
- value: Decimal в выбранной валюте
"""
row = await db_session.execute(
sa.text("select preferred_currency from users where tg_id = :id"),
{"id": tg_id},
)
user_currency = row.scalar()
txt, cur, val = await display_price(
amount_rub,
language_code,
user_currency=user_currency,
force_currency=force_currency,
session=None,
)
return txt, cur, val
async def format_for_user(
db_session,
tg_id: int,
amount_rub: float | int | Decimal,
language_code: Optional[str],
force_currency: Optional[str] = None,
) -> str:
text, _, _ = await money_for_user(
db_session,
tg_id,
amount_rub,
language_code,
force_currency=force_currency,
)
return text
+222
View File
@@ -0,0 +1,222 @@
from typing import Any
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import USE_NEW_PAYMENT_FLOW, MULTICURRENCY_ENABLE, PROVIDERS_ENABLED
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, FAST_PAY_CHOOSE_PROVIDER
from database.temporary_data import create_temporary_data
from handlers import buttons as btn
from handlers.utils import edit_or_send_message
from logger import logger
from handlers.payments.currency_flow import (
build_currency_choice_kb,
shortfall_lead_text,
currency_label,
)
from handlers.payments.providers import get_providers_with_hooks
router = Router()
async def _run_provider_flow(
provider: str,
callback_query: CallbackQuery,
session: Any,
state: FSMContext,
required_amount: int | None,
) -> bool:
import importlib
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
up = provider.upper()
cfg = (PROVIDERS.get(up) or {})
fast_name = cfg.get("fast")
if not fast_name:
return False
module_name_from_config = cfg.get("module")
if module_name_from_config:
module_name = f"handlers.payments.{module_name_from_config}.handlers"
else:
module_name = f"handlers.payments.{up.lower()}.handlers"
try:
module = importlib.import_module(module_name)
func = getattr(module, fast_name)
except Exception as e:
logger.error(f"[FAST_FLOW] Импорт {up}.{fast_name} из {module_name} не удался: {e}")
return False
try:
if up == "STARS":
try:
await callback_query.message.delete()
except Exception as e:
logger.warning(f"[FAST_FLOW] Не удалось удалить меню перед STARS: {e}")
await func(callback_query, session)
return True
except Exception as e:
logger.error(f"[FAST_FLOW] Ошибка при вызове {up}.{fast_name}(): {e}")
return False
async def try_fast_payment_flow(
callback_query: CallbackQuery,
session: Any,
state: FSMContext,
*,
tg_id: int,
temp_key: str,
temp_payload: dict,
required_amount: int | None = None,
) -> bool:
await create_temporary_data(session, tg_id, temp_key, temp_payload)
if not USE_NEW_PAYMENT_FLOW:
return False
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
providers = (
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
)
providers = [
p for p in providers
if (PROVIDERS.get(str(p).upper()) or {}).get("fast")
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
]
if not providers:
return False
if len(providers) == 1:
up = providers[0].upper()
cfg = PROVIDERS.get(up) or {}
currency = cfg.get("currency")
if currency:
await state.update_data(chosen_currency=currency)
if await _run_provider_flow(up, callback_query, session, state, required_amount):
return True
return False
if MULTICURRENCY_ENABLE:
show_stars = bool((PROVIDERS.get("STARS") or {}).get("enabled"))
show_tribute = bool((PROVIDERS.get("TRIBUTE") or {}).get("enabled"))
kb = build_currency_choice_kb(show_stars=show_stars, show_tribute=show_tribute)
lead = await shortfall_lead_text(
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
)
text = f"{lead}.\n\n{FAST_PAY_CHOOSE_CURRENCY}"
await state.update_data(
temp_key=temp_key,
temp_payload=temp_payload,
required_amount=required_amount,
fastflow_providers=providers,
)
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=kb.as_markup())
return True
kb = InlineKeyboardBuilder()
for p in providers:
up = p.upper()
btn_text = getattr(btn, up, up)
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{up}"))
kb.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
lead = await shortfall_lead_text(
session, tg_id, required_amount, getattr(callback_query.from_user, "language_code", None)
)
await state.update_data(temp_key=temp_key, temp_payload=temp_payload, required_amount=required_amount)
await edit_or_send_message(
target_message=callback_query.message,
text=f"{lead}.\n\n{FAST_PAY_CHOOSE_PROVIDER}",
reply_markup=kb.as_markup(),
)
return True
@router.callback_query(F.data.startswith("choose_payment_currency|"))
async def choose_payment_currency(callback_query: CallbackQuery, state: FSMContext, session: Any):
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
currency = callback_query.data.split("|")[1]
data = await state.get_data()
providers = data.get("fastflow_providers") or (
[USE_NEW_PAYMENT_FLOW] if isinstance(USE_NEW_PAYMENT_FLOW, str)
else [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
)
filtered = [
p.upper() for p in providers
if (PROVIDERS.get(str(p).upper()) or {}).get("currency") == currency
and (PROVIDERS.get(str(p).upper()) or {}).get("fast")
and (PROVIDERS.get(str(p).upper()) or {}).get("enabled", True)
]
await state.update_data(chosen_currency=currency)
if not filtered:
kb = InlineKeyboardBuilder().row(InlineKeyboardButton(text="← Назад", callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="Для выбранной валюты нет доступных касс. Выберите другую валюту или вернитесь в меню.",
reply_markup=kb.as_markup(),
)
return
if len(filtered) == 1:
only = filtered[0]
if await _run_provider_flow(only, callback_query, session, state, data.get("required_amount")):
return
await edit_or_send_message(
target_message=callback_query.message,
text="Этот способ временно недоступен.",
reply_markup=InlineKeyboardBuilder().row(
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
).as_markup(),
)
return
kb = InlineKeyboardBuilder()
for p in filtered:
btn_text = getattr(btn, p, p)
kb.row(InlineKeyboardButton(text=btn_text, callback_data=f"choose_payment_provider|{p}"))
kb.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
lead = await shortfall_lead_text(
session,
callback_query.from_user.id,
data.get("required_amount"),
getattr(callback_query.from_user, "language_code", None),
force_currency=currency,
)
text = f"{lead}.\n\nВалюта: {currency_label(currency)}\n{FAST_PAY_CHOOSE_PROVIDER}"
await edit_or_send_message(target_message=callback_query.message, text=text, reply_markup=kb.as_markup())
@router.callback_query(F.data.startswith("choose_payment_provider|"))
async def choose_payment_provider(callback_query: CallbackQuery, state: FSMContext, session: Any):
PROVIDERS = await get_providers_with_hooks(PROVIDERS_ENABLED)
provider = callback_query.data.split("|")[1].upper()
cfg = PROVIDERS.get(provider) or {}
if not cfg.get("fast") or not cfg.get("enabled", True):
await edit_or_send_message(
target_message=callback_query.message,
text="Этот способ временно недоступен.",
reply_markup=InlineKeyboardBuilder().row(
InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile")
).as_markup(),
)
return
currency = cfg.get("currency")
if currency:
await state.update_data(chosen_currency=currency)
data = await state.get_data()
await _run_provider_flow(provider, callback_query, session, state, data.get("required_amount"))

Some files were not shown because too many files have changed in this diff Show More