v4.4 → v.5
This commit is contained in:
+3
-1
@@ -58,4 +58,6 @@ Dockerfile
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
@@ -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
|
||||
|
||||
@@ -1,87 +1,211 @@
|
||||
# 🚀 SoloBot
|
||||
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
|
||||
|
||||
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
|
||||

|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||
|
||||

|
||||
|
||||
<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 уже помог сотням пользователей в нашем сообществе:
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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,5 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
+18
-2
@@ -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
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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,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():
|
||||
|
||||
@@ -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("❌ Не удалось сбросить привязки групп.")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .module_handler import router
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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="🔥 Горячие лиды",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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="💰 Ввести сумму доната",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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(": ")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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-клавиатуру для уведомлений после удаления или продления.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .handlers import router
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user