Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c7da4b9fd | |||
| ea6538a487 | |||
| f7d792963c | |||
| f6eeeb8ea0 | |||
| 738a72b628 | |||
| 784e38c345 | |||
| da44b4e041 | |||
| 12dd8f75fb | |||
| 057a71cfce | |||
| 3c25fb3015 | |||
| a573fdc4d4 | |||
| a6c34beff1 | |||
| a256a2f338 | |||
| e6313bf5a3 | |||
| 8fdd2dd3c4 | |||
| 7090daefc5 | |||
| d1e739c029 | |||
| 92c2a1f2c7 | |||
| 0ca56d0967 | |||
| 99bd6ce42f | |||
| d012d7a9c4 | |||
| 82fc4a9b33 | |||
| 5a769f8972 | |||
| a1e7db2ac5 | |||
| a1347dff04 | |||
| 5a4df05b7b | |||
| eca7009a55 | |||
| bdab2360c3 | |||
| 8d80360615 | |||
| 50c73821a6 | |||
| f1a5465e36 | |||
| 7938a0dc4f | |||
| 24c7adc73c | |||
| 42f1e81436 | |||
| 66900c284c | |||
| 4aa764a126 | |||
| 2e866103df | |||
| 3d7c76b113 | |||
| 15e9982530 | |||
| 199ea9f153 | |||
| 32a2ea8d13 | |||
| 5d73fd4960 | |||
| 8f3882819a | |||
| ebf1b22094 | |||
| b09bde32e4 | |||
| 23e01da0fd | |||
| d127624121 | |||
| d073e1a3bd | |||
| 706d81f4bc | |||
| f43954cd77 | |||
| dbb2395639 | |||
| e4641a0e86 | |||
| 12a615b5a8 | |||
| b829d32236 | |||
| 52714536e3 | |||
| ebdd7e124c | |||
| be74a7cadc | |||
| 7a533dd0da | |||
| b5d13beb96 | |||
| e01e7b4d1e | |||
| d1837bd414 | |||
| 04365dd335 | |||
| 1b6b2b2956 | |||
| 353545ce8d | |||
| 20f83d1a82 | |||
| c424c41048 | |||
| 81751093d9 | |||
| b68b539c68 | |||
| 17b9e92809 | |||
| 4b8bea65be | |||
| c1e26ca1ce | |||
| 425ba29e12 | |||
| 7cc6ddb4ef | |||
| ea7310bcb8 | |||
| 284d79dd67 | |||
| 412a31b938 | |||
| 1a6450284d | |||
| 5e6333d08e | |||
| 3a23883e46 | |||
| b444367887 | |||
| 752292cb4a | |||
| b0482cca1a | |||
| 4e1d7e51a9 | |||
| e19e00aa04 | |||
| 45041982d5 | |||
| c4bafc6c1d | |||
| 5baf44640d | |||
| d6b920ff4d | |||
| 6d9e1aba4a | |||
| 840e5e4c53 | |||
| e16580857f | |||
| fc3871fd8e | |||
| b1f0462aab | |||
| 417089cef4 | |||
| c08072a87b | |||
| a10316d215 | |||
| 4d3d23ac58 | |||
| 9074876e3d | |||
| 1487fb2858 | |||
| 16d031d90e | |||
| 1afa57bff4 | |||
| 05c591336b | |||
| cd9b92d036 | |||
| 344a24a669 | |||
| a5883e9057 | |||
| e292accccb | |||
| ed5426dd71 | |||
| 9b5ab83b1f | |||
| 4795107178 | |||
| 379944200b | |||
| cb8453f415 | |||
| 69f86fe821 | |||
| 099be68d29 | |||
| 7ab58d4461 | |||
| 9bb95e8189 | |||
| a257e61349 | |||
| 44d6fe87dd | |||
| b01eabdcda | |||
| 2a3f346434 | |||
| 8597eed42d | |||
| 070773d28d | |||
| e2a6712382 | |||
| 4ab4eedd04 | |||
| e2e73e130e | |||
| fa1317a5fb | |||
| cb0a155597 | |||
| 00f24acc4e | |||
| f5b4ab65ea | |||
| b61c3daca8 | |||
| 83d93a833d | |||
| b999e4aced | |||
| 214c9f0c65 | |||
| 31545a4225 | |||
| 1441a16474 | |||
| cec91b711f | |||
| f87da6db7a | |||
| f02a6358e9 | |||
| 20c1bd6a75 | |||
| 254620774e | |||
| 3078089044 | |||
| af79f511e5 | |||
| b20ec194c5 | |||
| adfb556259 | |||
| e9ff8d851e | |||
| ac258cda37 | |||
| 5fc4ce8930 | |||
| 3aa7b1edc5 | |||
| b59b3f0e53 | |||
| 290ef28a7f | |||
| 66bb7c8d33 | |||
| 8dde5acbbc | |||
| 760aab75cd | |||
| a6a77238f7 | |||
| f75b5e8312 |
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Issue
|
||||
about: Сообщить об ошибке, предложить улучшение или указать на уязвимость
|
||||
title: "[ISSUE] "
|
||||
labels: triage
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
### Тип
|
||||
- [ ] Баг
|
||||
- [ ] Фича
|
||||
- [ ] Безопасность
|
||||
|
||||
### Кратко
|
||||
Опишите суть задачи.
|
||||
|
||||
### Шаги воспроизведения (для багов/безопасности)
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Ожидаемое / Фактическое
|
||||
-
|
||||
|
||||
### Окружение
|
||||
- Ветка: main/dev
|
||||
- Коммит:
|
||||
- ОС:
|
||||
- Python:
|
||||
|
||||
### Дополнительно (для фич)
|
||||
Опишите мотивацию, альтернативы.
|
||||
|
||||
### Примечание по безопасности
|
||||
Не публикуйте PoC публично.
|
||||
Для сообщений об уязвимостях свяжитесь напрямую: [@solonet_sup](https://t.me/solonet_sup).
|
||||
@@ -0,0 +1,21 @@
|
||||
### Описание изменений
|
||||
Опишите, что изменилось и зачем.
|
||||
|
||||
### Ветка назначения
|
||||
- [ ] dev (основная ветка разработки)
|
||||
- [ ] main (только для критических исправлений безопасности)
|
||||
|
||||
### Тип изменений
|
||||
- [ ] Новая функциональность
|
||||
- [ ] Исправление ошибки
|
||||
- [ ] Рефакторинг
|
||||
- [ ] Документация
|
||||
- [ ] Другое
|
||||
|
||||
### Чек-лист
|
||||
- [ ] Изменения протестированы локально
|
||||
- [ ] Документация обновлена (если требуется)
|
||||
- [ ] Нет лишних изменений в коде
|
||||
|
||||
### Связанные задачи
|
||||
Укажите номер issue, если PR его закрывает (например: Closes #123).
|
||||
+3
-1
@@ -58,4 +58,6 @@ Dockerfile
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Общие правила
|
||||
|
||||
- Уважайте друг друга. Недопустимы оскорбления, дискриминация или токсичное поведение.
|
||||
- Обсуждения должны оставаться по теме и в конструктивной форме.
|
||||
- Личные и конфиденциальные данные публиковать запрещено.
|
||||
- Нарушения могут привести к предупреждению, ограничению участия или блокировке по усмотрению мейнтейнеров.
|
||||
|
||||
## Участники GitHub
|
||||
|
||||
- Могут предлагать изменения через **Issues** и **Pull Requests**.
|
||||
- Их вклад рассматривается и принимается по решению мейнтейнеров.
|
||||
- Дискуссии ограничены рамками кода и технических вопросов.
|
||||
|
||||
## Участники сообщества в Telegram
|
||||
|
||||
- Имеют приоритетное взаимодействие с разработчиком.
|
||||
- Могут обсуждать проект шире: идеи, улучшения, новые направления.
|
||||
- Влияние на развитие кода выше, чем у участников извне.
|
||||
- Общение остаётся в рамках уважения и конструктивности.
|
||||
|
||||
## Контакты
|
||||
|
||||
По вопросам поведения используйте Issues с меткой `conduct` или обращайтесь через сообщество в Telegram.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
Спасибо, что хотите внести вклад в развитие проекта!
|
||||
|
||||
## Как помочь
|
||||
|
||||
- Создавайте **Issues** для конкретных целей или описания найденных багов.
|
||||
- Отправляйте **Pull Requests** с улучшениями и исправлениями. Каждое предложение будет рассмотрено, и мы примем решение о включении.
|
||||
- Делайте **форки** репозитория и дорабатывайте код — лучшие практики мы обязательно объединим в общее дело.
|
||||
|
||||
## Стиль кода
|
||||
|
||||
- Соблюдайте стандарт **PEP8**.
|
||||
- Для автоматической проверки можно использовать **ruff**.
|
||||
|
||||
## Правила коммитов
|
||||
|
||||
- Сообщения должны быть осмысленными и отражать суть изменений.
|
||||
- Если в одном коммите несколько изменений, перечисляйте их через `/`.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public License © Vladless, 2024.
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
|
||||
---
|
||||
Ваш вклад помогает проекту расти и становиться лучше.
|
||||
@@ -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>
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Security Policy
|
||||
|
||||
## Поддерживаемые ветки
|
||||
- Рассматриваются только `main` (последняя стабильная) и `dev` (текущая разработка).
|
||||
- Другие ветки и теги не поддерживаются.
|
||||
|
||||
## Как сообщить об уязвимости
|
||||
- Не создавайте публичные Issues или Pull Requests.
|
||||
- Сообщите напрямую владельцу: [@solonet_sup](https://t.me/solonet_sup).
|
||||
- Также можно уведомить через сообщество в Telegram (доступно после вступления).
|
||||
- Укажите: ветку (`main`/`dev`), версию/коммит, шаги воспроизведения, ожидаемое и фактическое поведение, возможное влияние, логи или скриншоты.
|
||||
|
||||
## Процесс
|
||||
1. Подтверждаем получение отчёта.
|
||||
2. Анализируем и определяем критичность.
|
||||
3. Исправления выпускаются в `dev`.
|
||||
4. Если уязвимость критическая — фикс публикуется напрямую в `main`.
|
||||
5. После релиза уведомляем сообщество и при необходимости публикуем рекомендации по обновлению.
|
||||
|
||||
## Область действия
|
||||
- Бот (aiogram), веб-админка (flask), внутренние модули/плагины и API проекта.
|
||||
- Вне области: сторонние сервисы и библиотеки (платёжные провайдеры, Telegram, панели и т. п.). Для них обращайтесь к поставщику напрямую.
|
||||
|
||||
## Ответственное раскрытие
|
||||
- Просим не публиковать детали до выхода исправления.
|
||||
- Тесты проводите только на своих данных и аккаунтах.
|
||||
- Атаки на инфраструктуру или пользователей строго запрещены.
|
||||
|
||||
## Лучшие практики при отчёте
|
||||
- Минимальный PoC без риска для данных и доступности.
|
||||
- Чёткие и воспроизводимые шаги.
|
||||
- Возможные предложения по снижению риска до исправления.
|
||||
@@ -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:
|
||||
error_msg = str(bad_request).lower()
|
||||
if "chat not found" in error_msg:
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
|
||||
results.append(False)
|
||||
except Exception as retry_error:
|
||||
logger.error(f"❌ Ошибка повторной отправки пользователю {tg_id}: {retry_error}")
|
||||
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:
|
||||
error_msg = str(bad_request).lower()
|
||||
if "chat not found" in error_msg:
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
|
||||
results.append(False)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
|
||||
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)
|
||||
|
||||
|
||||
@@ -353,7 +355,7 @@ async def handle_expired_keys(
|
||||
subgroup_title=tariff.get("subgroup_title", ""),
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
@@ -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,80 @@ 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"],
|
||||
)
|
||||
|
||||
last_notification_time = await get_last_notification_time(conn, tg_id, notification_id)
|
||||
if last_notification_time is not None:
|
||||
return
|
||||
|
||||
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 +570,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 +580,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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user