97 Commits

Author SHA1 Message Date
Vladless 943027f17f back button in about vpn 2025-05-11 14:57:53 +03:00
Vladless ea0a3dc962 remnawave_link 2025-05-11 13:48:47 +03:00
Vladless 2d2a11e5b6 Merge branch 'base-web-endpoints' 2025-05-11 01:49:45 +03:00
Vladislav Lisitsyn 55fe1342cf Merge pull request #203 from Vladless/dev
fix gifts state
2025-05-11 01:28:18 +03:00
Vladislav Lisitsyn 105f798c26 Merge pull request #202 from Capybara-z/patch-5
reset state
2025-05-11 01:20:59 +03:00
Capybara-z 944295165d reset state
- сброс состояния после завершения всей логики.
2025-05-11 01:20:18 +03:00
Vladless 18be0049cc fix gifts state 2025-05-11 00:45:35 +03:00
Vladislav Lisitsyn 26a32b6f07 Merge pull request #201 from Vladless/dev
patch 4.2.1
2025-05-11 00:13:25 +03:00
Vladislav Lisitsyn f477094614 Merge branch 'main' into dev 2025-05-11 00:12:33 +03:00
Vladless 367331d113 add state.clear 2025-05-09 20:35:50 +03:00
Vladless 51d40323e5 user_data in check_subscription 2025-05-09 20:11:35 +03:00
Vladless 5ddf7f8242 ruff formatting 2025-05-09 01:34:11 +03:00
Vladless e4a73f9aaa set null recipient_gifts for delete user 2025-05-09 00:19:19 +03:00
Vladless b99c090a84 add web router 2025-05-08 23:23:58 +03:00
Vladislav Lisitsyn 15ad1a0f79 Update README.md 2025-05-08 23:14:42 +03:00
hteppl 344e4757ef oops 2025-05-08 17:19:00 +03:00
hteppl 55461aa50b Create base __init__ for web open api routes 2025-05-08 15:50:53 +03:00
Vladislav Lisitsyn 24928fe561 Update README.md 2025-05-08 03:12:58 +03:00
Vladislav Lisitsyn f16084b941 Update README.md 2025-05-08 03:04:47 +03:00
Vladislav Lisitsyn caeb531445 Update README.md 2025-05-08 02:57:33 +03:00
Vladislav Lisitsyn fefbf4da39 Update README.md 2025-05-08 02:36:49 +03:00
Vladless cd3c19b79d 4.2.1 patch 2025-05-08 00:51:51 +03:00
Vladless 6cd1dd8bc2 Refactored various modules, applied multiple improvements and fixed critical bugs 2025-05-08 00:50:18 +03:00
Vladislav Lisitsyn 3837f47bb6 Merge pull request #200 from Capybara-z/patch-4
fix sub in country mode
2025-05-07 22:07:34 +03:00
Capybara-z bff7350224 fix sub in country mode
- Исправлена выдачи подписки в режиме стран для Remnawawe
2025-05-07 22:02:45 +03:00
Vladless cd4c526da0 fix payment in UTM 2025-05-07 21:44:09 +03:00
Vladislav Lisitsyn 38e931446a Merge pull request #199 from Capybara-z/patch-3
fix update user
2025-05-07 21:30:02 +03:00
Capybara-z 55af3bde3d fix update user
- Испрлавена активность юзера и обновление его данных.
2025-05-07 21:28:17 +03:00
Vladislav Lisitsyn 6bca31f92c Merge pull request #198 from Capybara-z/patch-2
Fix notification
2025-05-07 20:42:32 +03:00
Capybara-z f8858e8561 Added toggle for country shuffle in subscription, revamped traffic handling
- Добавлена возможность вкл/выкл перемешки стран в подписке
- Переработан механизм получения трафика и его обработки
2025-05-07 20:32:57 +03:00
Capybara-z d3c729cdc2 Update notification 2025-05-05 21:40:06 +03:00
Capybara-z 4c954409cd Update notification 2025-05-05 21:39:20 +03:00
Capybara-z b2dbdb60b6 Update notification 2025-05-05 21:38:11 +03:00
Oleg Kraev a26d334653 Bump bot version to 4.2 2025-05-02 02:39:30 +03:00
Vladless ef1cf1fe26 Merge branch 'dev' 2025-05-01 21:26:14 +03:00
Vladless 1356170fb0 merdge 2025-05-01 21:24:20 +03:00
Vladislav Lisitsyn 998cf9a6e1 Merge pull request #195 from Capybara-z/patch-6
Fix for adding banned users
2025-05-01 21:08:17 +03:00
Capybara-z 85a6be831e Fix for adding banned users
- Вернул логику добавления забанных юзеров на уведолмения связанных  с триалом.
2025-05-01 21:05:02 +03:00
Vladislav Lisitsyn 9df5d8469d Merge pull request #194 from Vladless/dev
v4.2
2025-05-01 20:44:21 +03:00
Vladislav Lisitsyn 3133cc7799 Merge branch 'main' into dev 2025-05-01 20:44:10 +03:00
Vladislav Lisitsyn 7b894300b7 Merge pull request #193 from Capybara-z/patch-5
fix name month
2025-05-01 20:38:06 +03:00
Capybara-z 2621d29ef0 fix name month
- Отображение месяцев на русском.
2025-05-01 19:16:05 +03:00
Capybara-z 76ad4e5821 fix name month
- Отображение месяцев на русском.
2025-05-01 19:09:49 +03:00
Vladless 0056454375 reset traffic 2025-04-30 17:10:52 +03:00
Vladless 3c15589b03 update_cli_launcher for beta/release 2025-04-29 14:02:47 +03:00
Vladless 4849c899c2 fix show start menu once 2025-04-29 14:00:42 +03:00
Vladless 44b9986a45 CLI version 0.1.7 2025-04-27 20:55:19 +03:00
Vladless a9359e9dc0 update version 2025-04-26 23:30:24 +03:00
Vladless 76e730475d get gift button/api/minor fixes 2025-04-26 23:25:48 +03:00
Vladless 18d3b4246a add format day in csv/maintenance mode 2025-04-26 11:14:13 +03:00
Vladless 22121b9171 ruff formatting 2025-04-24 23:20:18 +03:00
Vladless ff498feb7e scalar total_gb 2025-04-24 22:42:49 +03:00
Vladislav Lisitsyn 15fcc0ac70 Update README.md 2025-04-23 22:29:54 +03:00
Vladless b1c73dc1a6 cancel for utm/disable servers for change location/remnawave traffic 2025-04-23 22:27:53 +03:00
Vladless 888812babd fix gift strip in start logic 2025-04-22 19:50:07 +03:00
Vladless 023cd1728d update user middleware 2025-04-22 19:21:15 +03:00
Vladless abbd3474b4 update table for utm 2025-04-22 00:41:34 +03:00
Vladless fa17faa424 update version 2025-04-21 22:04:07 +03:00
Vladless 799c657605 refactoring/utm/fix minor bugs 2025-04-21 21:53:13 +03:00
Vladless fa875fb3bc CLI version 0.1.4 2025-04-21 03:37:27 +03:00
Vladless cc628a08fa CLI version 0.1.4 2025-04-21 03:34:44 +03:00
Vladless fa85fb70f1 hot_leads_sender 2025-04-21 02:18:41 +03:00
Vladless 1d5980d725 rm old logic 2025-04-21 01:46:19 +03:00
Vladless 9474ccfb06 refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args 2025-04-21 00:22:14 +03:00
Vladislav Lisitsyn c6e91c0543 Merge pull request #192 from Capybara-z/patch-4
Fix for extension
2025-04-19 22:18:23 +03:00
Vladislav Lisitsyn 593206f6a0 Merge pull request #191 from Capybara-z/patch-3
v2raytun agent processing
2025-04-19 22:18:09 +03:00
Capybara-z ea6f5297c1 Fix for extension 2025-04-19 21:46:04 +03:00
Capybara-z 471714b80d v2raytun agent processing
- Добавлена обратотка агента v2raytun
2025-04-19 20:13:00 +03:00
Vladislav Lisitsyn a92a19b84d Merge pull request #190 from Capybara-z/patch-2 2025-04-19 17:49:58 +03:00
Capybara-z 289e0ae5b5 fix coupons/formatted day 2025-04-19 17:24:05 +03:00
Vladless d08b410888 update_button on/off 2025-04-18 15:32:07 +03:00
Vladless 455455d696 beta version 2025-04-18 05:01:47 +03:00
Vladless 57ad26711d fix get_keys 2025-04-18 03:51:59 +03:00
Vladless 24428a030e add column in table servers 2025-04-18 01:34:00 +03:00
Vladless c42fee4298 remove table connections/token and cookies 3x-ui/bug fixes 2025-04-18 00:10:51 +03:00
Vladless aba9620c0f limit_key on servers 2025-04-17 16:38:39 +03:00
Vladless dd8c87f887 on/off server in cluster 2025-04-17 15:02:54 +03:00
Vladless eebc2ee1d5 fix get_online in remnawave 2025-04-16 23:20:28 +03:00
Vladless 7bd5ecc55f remnawave in country_mode/save shortid/bug fixes 2025-04-16 22:50:19 +03:00
Vladislav Lisitsyn 5d25e211df Merge pull request #188 from Capybara-z/patch-1
Fix time declination
2025-04-16 20:09:17 +03:00
Capybara-z 0887e28b2b Adding time declination
- Добавил новые склонения
2025-04-15 01:26:17 +03:00
Capybara-z e04be209cb Fix time declination
-Склоение времени.
2025-04-15 01:25:48 +03:00
Capybara-z d19eee8897 Fix time declination
-Склонение времени
2025-04-15 01:24:36 +03:00
Vladless 3efdeed00c add create_key in country_mode/fix traffic in remnawave 2025-04-15 00:12:56 +03:00
Vladless 9e89a2db10 pic.jpg in other menu 2025-04-14 01:08:58 +03:00
Vladislav Lisitsyn 59a23ea9bd Merge pull request #186 from Capybara-z/patch-13
fix trail_time
2025-04-14 01:08:40 +03:00
Capybara-z 216c8f8d81 fix trail_time
- Адаптироавл вывод в инлайн моде, склонение дней.
2025-04-13 22:29:19 +03:00
Vladislav Lisitsyn d0f6511c5c Merge pull request #185 from Capybara-z/patch-12
Fix admin button
2025-04-13 17:51:47 +03:00
Capybara-z e1ecb6a1dc Fix admin button
- Исправлено отображение кнопки админа при активации купона на деньги
2025-04-12 19:48:55 +03:00
Vladless 99c02874ec CTRL-C and install venv in CLI 2025-04-11 22:52:13 +03:00
Vladless 0cd896d35e gb_count/fix coupons 2025-04-11 22:40:38 +03:00
Vladless ab35ce9178 fix renew after sync 2025-04-11 17:34:47 +03:00
Vladless f71e6eb226 add import renew 2025-04-10 15:02:46 +03:00
Vladless cd3b55271a add support for RemnaWave 2025-04-10 14:51:01 +03:00
hteppl e71de5f3cf Fix duplicated button text variables + typo (TARIFFS is unused) 2025-04-10 01:01:13 +03:00
Vladless 0fc2c535de Merge branch 'main' into dev 2025-04-09 23:31:23 +03:00
Vladless d9533e673e refactor: reorganize keys logic into modular files 2025-04-09 17:38:19 +03:00
73 changed files with 5188 additions and 4124 deletions
+40 -78
View File
@@ -1,97 +1,47 @@
# 🚀 SoloBot
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
# Основные возможности
## ⚙️ Основные возможности SoloBot
**SoloBot** реализует множество функций, включая:
| 📌 Раздел | 💡 Возможности |
|----------|----------------|
| **Мультипанель** | • 3x-ui <br> • Remnawave <br> • Работа в режиме совместимости (две панели в одном боте) |
| **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки <br> • Пробный период на заданный период <br> • Продление ключей по тарифному плану <br> • Поддержка различных клиентов (Happ, Hiddify, v2raytun) <br> • Кастомизируемые заголовки профилей |
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
| **Интеграция платёжных систем** | • Юкасса (ИП / Самозанятые) <br> • Юмани (Физ. лица, TrackLine) <br> • Robokassa (Физ. лица) <br> • Cryptobot (izzzzzi) <br> • Звёзды Telegram |
| **Безопасность и стабильность** | • Периодические бэкапы <br> • Смена домена в случае переезда <br> • Проверка доступности серверов <br> • Уведомление о недоступности сервера и его аптайм <br> |
| **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент) <br> • Напоминания о неиспользованном трафике |
| **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве) <br> • Выдача в разных режимах (по одной локации или в формате подписки) <br> • Автопроверка доступности <br> • Балансировка нагрузки при выдаче ключей <br> • Синхронизация клиентов между серверами <br> • Ограничение максимального количества ключей на сервер <br> • Возможность включения/отключения отдельных серверов |
| **Админ-панель** | • Поиск по TG ID / username / ключу / email <br> • Управление балансом, подписками, заморозками <br> • Перезагрузка, бан-лист <br> • Создание купонов, UTM, статистика |
| **Кастомизация** | • Все функции бота кастомизируемы, вплоть до логики работы <br> |
### Управление подписками
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
## 🛠 Обновления
### Полный контроль клиента над своими ключами
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
### SoloBot регулярно получает новые функции и улучшения.
### Реферальная программа
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
#### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
### Интеграция платёжных систем
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
# Наш сайт и полная версия
### Безопасность и стабильность
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
### Переходи на наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
### Уведомления
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
### *Настраивайте как вам удобно!*
## Список версий
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
## 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
#### Отзывы пользователей:
SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/fe13a00f-94d3-419c-afd0-d1c1efec5179)
## Отзывы пользователей:
#### SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30)
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
@@ -99,8 +49,20 @@ SoloBot уже помог сотням пользователей в нашем
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
## Права на использование
## 🚨 Права на использование
#### Перепродажа кода запрещена, ознакомьтесь с лицензией
| ❗ | **Этот проект распространяется по лицензии [CC BY-NC 4.0](LICENSE)** |
|----|---------------------------------------------------------------------|
| ⛔ **Монетизация кода запрещена** | Нельзя продавать или перепродавать код без разрешения автора. |
| ✅ **Для личного использования** | Код можно использовать и модифицировать для личного использования. |
> **Нарушение приведёт к блокировке доступа и может повлечь ответственность согласно законодательству РФ.**
## Участники проекта
Благодарим всех, кто помогает развивать SoloBot! 💖
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" />
</a>
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
+90 -20
View File
@@ -1,3 +1,4 @@
CREATE TABLE IF NOT EXISTS users
(
tg_id BIGINT PRIMARY KEY NOT NULL,
@@ -7,15 +8,28 @@ CREATE TABLE IF NOT EXISTS users
language_code TEXT,
is_bot BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS connections
(
tg_id BIGINT PRIMARY KEY NOT NULL,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'balance'
) THEN
ALTER TABLE users ADD COLUMN balance REAL NOT NULL DEFAULT 0.0;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'trial'
) THEN
ALTER TABLE users ADD COLUMN trial INTEGER NOT NULL DEFAULT 0;
END IF;
END$$;
CREATE TABLE IF NOT EXISTS payments
(
@@ -44,6 +58,21 @@ CREATE TABLE IF NOT EXISTS keys
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'key' AND is_nullable = 'NO'
) THEN
ALTER TABLE keys ALTER COLUMN key DROP NOT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'remnawave_link'
) THEN
ALTER TABLE keys ADD COLUMN remnawave_link TEXT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'is_frozen'
@@ -59,7 +88,6 @@ BEGIN
END IF;
END$$;
CREATE TABLE IF NOT EXISTS referrals
(
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
@@ -110,34 +138,76 @@ CREATE TABLE IF NOT EXISTS servers
cluster_name TEXT NOT NULL,
server_name TEXT NOT NULL,
api_url TEXT NOT NULL,
subscription_url TEXT NOT NULL,
subscription_url TEXT,
inbound_id TEXT NOT NULL,
panel_type TEXT NOT NULL DEFAULT '3x-ui',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
max_keys INTEGER,
UNIQUE (cluster_name, server_name)
);
ALTER TABLE servers ADD COLUMN IF NOT EXISTS panel_type TEXT NOT NULL DEFAULT '3x-ui';
ALTER TABLE servers
ALTER COLUMN subscription_url DROP NOT NULL;
ALTER TABLE servers
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE servers ADD COLUMN IF NOT EXISTS max_keys INTEGER;
CREATE TABLE IF NOT EXISTS gifts
(
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
selected_months INTEGER NOT NULL,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
recipient_tg_id BIGINT,
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS temporary_data (
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS blocked_users (
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tracking_sources (
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT,
is_active BOOLEAN DEFAULT TRUE
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS source_code TEXT REFERENCES tracking_sources (code);
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'connections'
) THEN
EXECUTE $upd$
UPDATE users
SET balance = c.balance,
trial = c.trial
FROM connections c
WHERE users.tg_id = c.tg_id;
$upd$;
EXECUTE 'DROP TABLE connections';
END IF;
END$$;
+2 -1
View File
@@ -19,7 +19,8 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
version = "4.1-Release"
version = "4.2.1"
register_middleware(dp)
Regular → Executable
+204 -59
View File
@@ -1,18 +1,21 @@
import os
import sys
import re
import subprocess
import sys
import requests
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text
import re
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding='utf-8')
sys.stdout.reconfigure(encoding='utf-8')
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
@@ -20,24 +23,41 @@ 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()
BACK_DIR = os.path.expanduser("~/.solobot_backup")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == "/root"
if IS_ROOT_DIR:
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
console.print("[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]")
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
console = Console()
def is_service_exists(service_name):
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
return service_name in result.stdout
def print_logo():
logo = Text("""
logo = Text(
"""
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
""", style="bold cyan")
""",
style="bold cyan",
)
console.print(logo)
@@ -49,12 +69,61 @@ def backup_project():
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
def fix_permissions():
"""Устанавливает корректные права на файлы проекта"""
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
try:
user = os.getenv("SUDO_USER") or os.getenv("USER")
if user:
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
console.print("[green]✅ Права успешно установлены[/green]")
except subprocess.CalledProcessError as e:
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
def install_rsync_if_needed():
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
console.print("[blue]📦 Установка rsync...[/blue]")
os.system("sudo apt update && sudo apt install -y rsync")
def clean_project_dir_safe(update_buttons=False):
console.print("[yellow]🧹 Очистка проекта перед обновлением...[/yellow]")
preserved_paths = {
os.path.join(PROJECT_DIR, "config.py"),
os.path.join(PROJECT_DIR, "handlers", "buttons.py"),
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
os.path.join(PROJECT_DIR, "img"),
}
if not update_buttons:
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
for file in files:
path = os.path.join(root, file)
if path in preserved_paths:
continue
try:
os.remove(path)
except PermissionError:
subprocess.run(["sudo", "rm", "-f", path])
except Exception as e:
console.print(f"[red]Не удалось удалить файл: {path}: {e}[/red]")
for dir in dirs:
dir_path = os.path.join(root, dir)
if os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "handlers"):
continue
try:
os.rmdir(dir_path)
except Exception:
subprocess.run(["sudo", "rm", "-rf", dir_path])
def install_git_if_needed():
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
console.print("[blue]Установка Git...[/blue]")
@@ -65,22 +134,31 @@ def install_dependencies():
console.print("[blue]🔧 Установка зависимостей...[/blue]")
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
try:
subprocess.run("bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True)
if not os.path.exists("venv"):
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
subprocess.run("python3 -m venv venv", shell=True, check=True)
subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True
)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
def restart_service():
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
def get_local_version():
path = os.path.join(PROJECT_DIR, "bot.py")
if not os.path.isfile(path):
return None
with open(path, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
for line in f:
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
@@ -103,6 +181,7 @@ def get_remote_version(branch="main"):
def update_from_beta():
update_cli_launcher()
local_version = get_local_version()
remote_version = get_remote_version(branch="dev")
@@ -117,38 +196,57 @@ def update_from_beta():
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
return
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
os.chdir(PROJECT_DIR)
console.print("[cyan]📥 Клонируем репозиторий dev во временную папку...[/cyan]")
console.print("[cyan]📅 Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons)
exclude_options = "--exclude=img"
if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py"
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
fix_permissions()
install_dependencies()
restart_service()
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
def update_from_release():
update_cli_launcher()
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
return
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
try:
response = requests.get(
"https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10
)
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
releases = response.json()[:3]
tag_choices = [r["tag_name"] for r in releases]
@@ -160,18 +258,29 @@ def update_from_release():
console.print(f"[cyan]{idx}.[/cyan] {tag}")
selected = Prompt.ask(
"[bold blue]Выберите номер релиза[/bold blue]",
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
"[bold blue]Выберите номер релиза[/bold blue]", choices=[str(i) for i in range(1, len(tag_choices) + 1)]
)
tag_name = tag_choices[int(selected) - 1]
if not Confirm.ask(f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"):
return
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", shell=True, check=True)
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons)
exclude_options = "--exclude=img"
if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py"
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
fix_permissions()
install_dependencies()
restart_service()
console.print(f"[green]✅ Обновление до релиза {tag_name} завершено.[/green]")
@@ -181,6 +290,11 @@ def update_from_release():
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
return
table = Table(title="Выберите способ обновления", title_style="bold green")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Источник", style="white")
@@ -198,11 +312,9 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI", title_style="bold magenta", header_style="bold blue")
table = Table(title="Solobot CLI v0.1.7", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
table.add_row("3", "Перезапустить бота (systemd)")
@@ -210,45 +322,78 @@ 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", "Обновить CLI лаунчер")
table.add_row("9", "Выход")
console.print(table)
def update_cli_launcher():
"""Обновляет CLI лаунчер с dev ветки"""
console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10)
if response.status_code == 200:
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8") as f:
f.write(response.text)
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
else:
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
def main():
if os.geteuid() != 0:
console.print("[bold red]⛔ Требуется запуск от имени root или через sudo.[/bold red]")
sys.exit(1)
os.chdir(PROJECT_DIR)
os.chdir(PROJECT_DIR)
print_logo()
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 9)])
if choice == "1":
os.system(f"sudo systemctl start {SERVICE_NAME}")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
os.system("sudo venv/bin/python main.py")
elif choice == "3":
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
os.system(f"sudo systemctl restart {SERVICE_NAME}")
elif choice == "4":
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
os.system(f"sudo systemctl stop {SERVICE_NAME}")
elif choice == "5":
os.system(f"sudo journalctl -u {SERVICE_NAME} -n 80 --no-pager")
elif choice == "6":
os.system(f"sudo systemctl status {SERVICE_NAME}")
elif choice == "7":
show_update_menu()
elif choice == "8":
console.print("[bold cyan] Выход из CLI. Удачного дня![/bold cyan]")
break
try:
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 10)])
if choice == "1":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
subprocess.run(["venv/bin/python", "main.py"])
elif choice == "3":
if is_service_exists(SERVICE_NAME):
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "4":
if is_service_exists(SERVICE_NAME):
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "5":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "journalctl", "-u", SERVICE_NAME, "-n", "80", "--no-pager"])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "6":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "status", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "7":
show_update_menu()
elif choice == "8":
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
update_cli_launcher()
elif choice == "9":
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
update_cli_launcher()
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
if __name__ == "__main__":
+251 -526
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,7 @@ 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
from .start import router as start_router
@@ -29,4 +30,5 @@ router.include_routers(
keys_router,
instructions_router,
admin_router,
refferal_router,
)
+2
View File
@@ -2,6 +2,7 @@ __all__ = ("router",)
from aiogram import Router
from .ads import router as ads_router
from .backups import router as backups_router
from .bans import router as bans_router
from .clusters import router as clusters_router
@@ -29,4 +30,5 @@ router.include_routers(
coupons_router,
restart_router,
bans_router,
ads_router,
)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .ads_handler import router
+155
View File
@@ -0,0 +1,155 @@
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from config import USERNAME_BOT
from database import (
create_tracking_source,
get_all_tracking_sources,
get_tracking_source_stats,
)
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminAdsCallback,
build_ads_delete_confirm_kb,
build_ads_kb,
build_ads_list_kb,
build_ads_stats_kb,
build_cancel_input_kb,
)
router = Router()
class AdminAdsState(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_code = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
async def handle_ads_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminAdsState.waiting_for_new_name)
await callback_query.message.edit_text(
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
)
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
async def handle_ads_name_input(message: Message, state: FSMContext):
name = message.text.strip()
await state.update_data(name=name)
await state.set_state(AdminAdsState.waiting_for_new_code)
await message.answer(
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.", reply_markup=build_cancel_input_kb()
)
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
async def handle_ads_code_input(message: Message, state: FSMContext, session):
code = message.text.strip()
data = await state.get_data()
name = data["name"]
code_with_prefix = f"utm_{code}"
try:
await create_tracking_source(
name=name, code=code_with_prefix, type_="utm", created_by=message.from_user.id, session=session
)
stats = await get_tracking_source_stats(code_with_prefix, session)
if not stats:
await message.answer("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await message.answer(
text=msg,
reply_markup=build_ads_stats_kb(code_with_prefix),
)
except Exception as e:
logger.error(f"Ошибка при создании ссылки: {e}")
await message.answer("❌ Произошла ошибка при создании ссылки.")
finally:
await state.clear()
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
async def handle_ads_list(callback_query: CallbackQuery, session):
try:
ads = await get_all_tracking_sources(session)
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
await callback_query.message.edit_text(
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Ошибка при получении списка UTM: {e}")
await callback_query.message.edit_text("Произошла ошибка при получении списка.")
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
async def handle_ads_view(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
code = callback_data.code
try:
stats = await get_tracking_source_stats(code, session)
if not stats:
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await callback_query.message.edit_text(
text=msg, reply_markup=build_ads_stats_kb(code), parse_mode=ParseMode.HTML
)
except Exception as e:
logger.error(f"Ошибка при просмотре статистики: {e}")
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
code = callback_data.code
await callback_query.message.edit_text(
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
reply_markup=build_ads_delete_confirm_kb(code),
)
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
async def handle_ads_delete(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
code = callback_data.code
try:
await session.execute("UPDATE users SET source_code = NULL WHERE source_code = $1", code)
await session.execute("DELETE FROM tracking_sources WHERE code = $1", code)
await callback_query.message.edit_text(f"🗑️ Ссылка <code>{code}</code> удалена.", reply_markup=build_ads_kb())
except Exception as e:
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
def format_ads_stats(stats: dict, username_bot: str) -> str:
return (
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
f"📌 <b>Название:</b> {stats['name']}\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
f"💡 <b>Активность:</b>\n"
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n"
f"\n💰 <b>Финансовая информация:</b>\n"
f"└ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n\n"
f"<i>Просмотр статистики и управление рекламными ссылками</i>."
)
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
+77
View File
@@ -0,0 +1,77 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import build_admin_back_btn
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
action: str
code: str | None = None
def build_ads_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=" Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for ad in ads:
builder.button(
text=f"📎 {ad['name']}",
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
)
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(build_admin_back_btn("ads"))
return builder.as_markup()
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🗑️ Удалить", callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack())
builder.row(build_admin_back_btn("ads"))
return builder.as_markup()
def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="✅ Да, удалить",
callback_data=AdminAdsCallback(
action="delete",
code=code,
).pack(),
)
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="view", code=code).pack())
builder.adjust(1)
return builder.as_markup()
def build_cancel_input_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="cancel_input", code="none").pack())
return builder.as_markup()
+158 -130
View File
@@ -12,11 +12,25 @@ from aiogram.types import CallbackQuery, Message
from py3xui import AsyncApi
from backup import create_backup_and_send_to_admins
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, TOTAL_GB, USE_COUNTRY_SELECTION
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
DATABASE_URL,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
TOTAL_GB,
USE_COUNTRY_SELECTION,
)
from database import check_unique_server_name, get_servers, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster, renew_key_in_cluster
from handlers.keys.key_utils import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
renew_key_in_cluster,
)
from logger import logger
from panels.remnawave import RemnawaveAPI
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import (
@@ -25,6 +39,7 @@ from .keyboard import (
build_cluster_management_kb,
build_clusters_editor_kb,
build_manage_cluster_kb,
build_panel_type_kb,
build_sync_cluster_kb,
)
@@ -54,9 +69,10 @@ async def handle_servers(callback_query: CallbackQuery):
text = (
"<b>🔧 Управление кластерами</b>\n\n"
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
"<blockquote>"
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
"</blockquote>\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
)
@@ -143,9 +159,11 @@ async def handle_server_name_input(message: Message, state: FSMContext, session:
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Ссылку можно найти в поисковой строке браузера, при входе в 3X-UI.\n\n"
"️ Формат API URL:\n"
"<code>https://your_domain:port/panel_path/</code>"
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
"<b>Формат для 3X-UI:</b>\n"
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
"️ <b>Формат для Remnawave:</b>\n"
"<code>https://your-domain.com/api</code>"
)
await message.answer(
@@ -158,58 +176,38 @@ async def handle_server_name_input(message: Message, state: FSMContext, session:
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Ссылку можно найти в панели 3X-UI, в информации о клиенте.\n\n"
"Формат Subscription URL:\n"
"<code>https://your_domain:port_sub/sub_path/</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
"Если вы используете Remnawave — введите <code>0</code>\n\n"
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
)
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
subscription_url = message.text.strip().rstrip("/")
raw = message.text.strip()
subscription_url = None if raw == "0" else raw.rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
text = (
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n"
)
await message.answer(
text=text,
text=f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
f"Для Remnawave это UUID Инбаунда, для 3x-ui — просто ID (например, <code>1</code>).",
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
@@ -218,39 +216,50 @@ async def handle_subscription_url_input(message: Message, state: FSMContext):
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: Message, state: FSMContext):
inbound_id = message.text.strip()
await state.update_data(inbound_id=inbound_id)
if not inbound_id.isdigit():
await message.answer(
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await message.answer(
text=(
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
"Поддержка режима выбора стран — <b>ограничена</b>."
),
reply_markup=build_panel_type_kb(),
)
@router.callback_query(AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])), IsAdminFilter())
async def handle_panel_type_selection(
callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext
):
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
inbound_id = user_data.get("inbound_id")
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id, panel_type)
VALUES ($1, $2, $3, $4, $5, $6)
""",
cluster_name,
server_name,
api_url,
subscription_url,
inbound_id,
panel_type,
)
await conn.close()
await message.answer(
text=f"Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
await callback_query.message.edit_text(
text=f"Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
reply_markup=build_admin_back_kb("clusters"),
)
await state.clear()
@@ -260,11 +269,20 @@ async def handle_clusters_manage(
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
await callback_query.message.edit_text(
text=f"<b>🔧 Управление кластером {cluster_name}</b>",
reply_markup=build_cluster_management_kb(cluster_name),
)
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
async def handle_cluster_servers(callback: CallbackQuery):
cluster_name = callback.data.split("|", 1)[1]
servers = await get_servers()
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
text=f"<b>📡 Серверы в кластере {cluster_name}</b>",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@@ -281,30 +299,67 @@ async def handle_cluster_availability(
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
text = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
await callback_query.message.edit_text(
text=(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
)
await callback_query.message.edit_text(text=text)
total_online_users = 0
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
for server in cluster_servers:
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
server_name = server["server_name"]
panel_type = server.get("panel_type", "3x-ui").lower()
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
try:
await xui.login()
online_clients = await xui.client.online()
inbound_id = int(server["inbound_id"])
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{server['server_name']}</b> - {online_inbound_users} онлайн\n"
if panel_type == "3x-ui":
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
await xui.login()
inbound_id = int(server["inbound_id"])
online_clients = await xui.client.online()
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
elif panel_type == "remnawave":
remna = RemnawaveAPI(server["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise Exception("Не удалось авторизоваться")
server_inbound_id = server.get("inbound_id")
if not server_inbound_id:
raise Exception("Не указан inbound_id сервера")
all_nodes = await remna.get_all_nodes()
if not all_nodes:
raise Exception("Не удалось получить список нод")
matching_node = None
for node in all_nodes:
excluded_inbounds = node.get("excludedInbounds", [])
if server_inbound_id not in excluded_inbounds:
matching_node = node
break
if not matching_node:
raise Exception("Нода, обслуживающая этот inbound_id, не найдена")
online_remna_users = matching_node.get("usersOnline", 0)
total_online_users += online_remna_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
except Exception as e:
result_text += f"❌ <b>{server['server_name']}</b> - ошибка: {str(e) if str(e).strip() else 'Сервер недоступен'}\n"
error_text = str(e) or "Сервер недоступен"
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
@@ -414,10 +469,10 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
try:
query_keys = """
SELECT tg_id, client_id, email, expiry_time
FROM keys
WHERE server_id = $1
"""
SELECT tg_id, client_id, email, expiry_time, remnawave_link
FROM keys
WHERE server_id = $1
"""
keys_to_sync = await session.fetch(query_keys, cluster_name)
if not keys_to_sync:
@@ -427,33 +482,42 @@ async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data
)
return
text = f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
await callback_query.message.edit_text(
text=text,
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
)
for key in keys_to_sync:
try:
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"])
await session.execute(
"DELETE FROM keys WHERE tg_id = $1 AND client_id = $2", key["tg_id"], key["client_id"]
)
await create_key_on_cluster(
cluster_name,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
session=session,
remnawave_link=key.get("remnawave_link"),
)
await asyncio.sleep(0.6)
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}")
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters")
text=f"❌ Произошла ошибка при синхронизации: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
@@ -477,16 +541,6 @@ async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminS
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.callback_query(AdminClusterCallback.filter(F.action == "manage_cluster"), IsAdminFilter())
async def handle_manage_cluster_menu(callback_query: CallbackQuery, callback_data: AdminClusterCallback):
cluster_name = callback_data.data
await callback_query.message.edit_text(
text=f"<b>🛠 Управление кластером {cluster_name}</b>\nВыберите действие:",
reply_markup=build_cluster_management_kb(cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
cluster_name = callback_data.data
@@ -511,6 +565,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
now = int(time.time() * 1000)
add_ms = days * 86400 * 1000
total_gb = int((days / 30) * TOTAL_GB * 1024**3)
keys = await session.fetch(
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
@@ -529,7 +584,7 @@ async def handle_days_input(message: Message, state: FSMContext, session: Any):
email=key["email"],
client_id=key["client_id"],
new_expiry_time=new_expiry,
total_gb=TOTAL_GB,
total_gb=total_gb,
)
await update_key_expiry(key["client_id"], new_expiry, session)
@@ -588,8 +643,7 @@ async def handle_new_cluster_name_input(message: Message, state: FSMContext, ses
conn = await asyncpg.connect(DATABASE_URL)
try:
existing_cluster = await conn.fetchval(
"SELECT cluster_name FROM servers WHERE cluster_name = $1 LIMIT 1",
new_cluster_name
"SELECT cluster_name FROM servers WHERE cluster_name = $1 LIMIT 1", new_cluster_name
)
if existing_cluster:
await message.answer(
@@ -598,23 +652,16 @@ async def handle_new_cluster_name_input(message: Message, state: FSMContext, ses
)
return
keys_count = await conn.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
old_cluster_name
)
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_cluster_name)
async with conn.transaction():
await conn.execute(
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2",
new_cluster_name,
old_cluster_name
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2", new_cluster_name, old_cluster_name
)
if keys_count > 0:
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_cluster_name,
old_cluster_name
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
)
await message.answer(
@@ -695,7 +742,7 @@ async def handle_new_server_name_input(message: Message, state: FSMContext, sess
existing_server = await conn.fetchval(
"SELECT server_name FROM servers WHERE cluster_name = $1 AND server_name = $2 LIMIT 1",
cluster_name,
new_server_name
new_server_name,
)
if existing_server:
await message.answer(
@@ -704,24 +751,19 @@ async def handle_new_server_name_input(message: Message, state: FSMContext, sess
)
return
keys_count = await conn.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
old_server_name
)
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_server_name)
async with conn.transaction():
await conn.execute(
"UPDATE servers SET server_name = $1 WHERE cluster_name = $2 AND server_name = $3",
new_server_name,
cluster_name,
old_server_name
old_server_name,
)
if keys_count > 0:
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_server_name,
old_server_name
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name
)
final_text = f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!"
@@ -753,20 +795,14 @@ async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContex
conn = await asyncpg.connect(DATABASE_URL)
try:
async with conn.transaction():
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_server_name,
old_server_name
)
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name)
await conn.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
old_server_name
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
)
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
sync_reminder = "\n\n⚠️ Не забудьте сделать \"Синхронизацию\"."
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
await callback_query.message.edit_text(
@@ -797,21 +833,13 @@ async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMConte
conn = await asyncpg.connect(DATABASE_URL)
try:
async with conn.transaction():
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_server_name)
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_cluster_name,
old_server_name
)
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_cluster_name,
old_cluster_name
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
)
await conn.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
old_server_name
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
)
await callback_query.message.edit_text(
+16 -16
View File
@@ -1,12 +1,10 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
@@ -55,13 +53,6 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
)
)
builder.row(
InlineKeyboardButton(
text="🛠 Управление",
callback_data=AdminClusterCallback(action="manage_cluster", data=cluster_name).pack(),
)
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
@@ -69,6 +60,12 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📡 Серверы",
callback_data=f"cluster_servers|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность",
@@ -99,12 +96,7 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
@@ -130,3 +122,11 @@ def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKey
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_panel_type_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
builder.button(text="🌀 Remnawave", callback_data=AdminClusterCallback(action="panel_remnawave").pack())
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
+16 -188
View File
@@ -1,8 +1,10 @@
from datetime import datetime
import html
import pytz
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
@@ -18,19 +20,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
from database import (
add_connection,
check_connection_exists,
create_coupon,
create_coupon_usage,
delete_coupon,
get_all_coupons,
get_keys,
update_key_expiry,
)
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.utils import format_days
from logger import logger
@@ -45,7 +40,6 @@ class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
waiting_for_key_selection = State()
@router.callback_query(
@@ -225,18 +219,15 @@ async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: Adm
coupon_code = callback_data.coupon_code
kb = InlineKeyboardBuilder()
kb.button(
text="✅ Да, удалить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
text="✅ Да, удалить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
)
kb.button(
text="❌ Нет, отменить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
text="❌ Нет, отменить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
)
kb.adjust(1)
await callback_query.message.edit_text(
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?",
reply_markup=kb.as_markup()
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?", reply_markup=kb.as_markup()
)
@@ -250,15 +241,13 @@ async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: Ad
result = await delete_coupon(coupon_code, session)
if not result:
await callback_query.message.edit_text(
f"❌ Купон с кодом {coupon_code} не найден.",
reply_markup=build_admin_back_kb("coupons")
f"❌ Купон с кодом {coupon_code} не найден.", reply_markup=build_admin_back_kb("coupons")
)
return
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
await callback_query.message.edit_text(
"Произошла ошибка при удалении купона.",
reply_markup=build_admin_back_kb("coupons")
"Произошла ошибка при удалении купона.", reply_markup=build_admin_back_kb("coupons")
)
return
@@ -303,7 +292,11 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
return
title = f"Купон {coupon['code']}"
description = f"Получи {coupon['amount']} рублей!" if coupon["amount"] > 0 else f"Продли подписку на {format_days(coupon['days'])}!"
description = (
f"Получи {coupon['amount']} рублей!"
if coupon["amount"] > 0
else f"Продли подписку на {format_days(coupon['days'])}!"
)
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n"
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
@@ -317,173 +310,8 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
id=coupon_code,
title=title,
description=description,
input_message_content=InputTextMessageContent(
message_text=message_text,
parse_mode=ParseMode.HTML
),
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
await inline_query.answer(
results=[result],
cache_time=86400,
is_personal=True
)
@router.message(F.text.regexp(r"^/start coupons_(.+)$"))
async def handle_coupon_activation(
message: Message, state: FSMContext, session: Any, admin: bool = False, text: str = None, user_id: int = None
):
coupon_text = text if text is not None else message.text
logger.info(f"Текст купона в handle_coupon_activation: {coupon_text}")
coupon_code = coupon_text.split("coupons_")[1]
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
if not coupon:
await message.answer("❌ Купон не найден.")
return
if coupon["usage_count"] >= coupon["usage_limit"] or coupon["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
return
effective_user_id = user_id if user_id is not None else message.from_user.id
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon["id"],
effective_user_id
)
if usage:
await message.answer("❌ Вы уже активировали этот купон.")
return
connection_exists = await check_connection_exists(effective_user_id)
if not connection_exists:
await add_connection(tg_id=effective_user_id, session=session)
if coupon["amount"] > 0:
await session.execute(
"UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
coupon["amount"],
effective_user_id
)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], effective_user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon['amount']} рублей.")
await process_callback_view_profile(message, state, admin)
return
if coupon["days"] is not None and coupon["days"] > 0:
keys = await get_keys(effective_user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(AdminCouponsState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon["id"], user_id=effective_user_id)
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon_id,
callback_query.from_user.id
)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2",
callback_query.from_user.id,
client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
try:
await renew_key_in_cluster(
cluster_id=key["server_id"],
email=key["email"],
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M")
text = f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
await callback_query.message.answer(text)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Произошла ошибка при продлении подписки.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.answer("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
+6 -2
View File
@@ -12,7 +12,7 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
coupon_code: str
confirm: Optional[bool] = None
confirm: bool | None = None
def build_coupons_kb() -> InlineKeyboardMarkup:
@@ -59,7 +59,11 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
def format_coupons_list(coupons: list, username_bot: str) -> str:
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
value_text = f"💰 <b>Сумма:</b> {coupon['amount']} рублей" if coupon["amount"] > 0 else f"⏳ <b>{format_days(coupon['days'])}</b>"
value_text = (
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
if coupon["amount"] > 0
else f"⏳ <b>{format_days(coupon['days'])}</b>"
)
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"{value_text}\n"
+5
View File
@@ -1,6 +1,8 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from middlewares import maintenance
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
@@ -11,6 +13,9 @@ def build_management_kb() -> InlineKeyboardMarkup:
builder.button(text="🔄 Перезагрузить бота", callback_data=AdminPanelCallback(action="restart").pack())
builder.button(text="🌐 Сменить домен", callback_data=AdminPanelCallback(action="change_domain").pack())
builder.button(text="🔑 Восстановить пробники", callback_data=AdminPanelCallback(action="restore_trials").pack())
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
builder.button(text=maintenance_text, callback_data=AdminPanelCallback(action="toggle_maintenance").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
@@ -6,6 +6,7 @@ from asyncpg import Connection
from filters.admin import IsAdminFilter
from logger import logger
from middlewares import maintenance
from ..panel.keyboard import build_admin_back_kb
from .keyboard import AdminPanelCallback, build_management_kb
@@ -73,3 +74,13 @@ async def process_new_domain(message: Message, state: FSMContext, session: Conne
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
async def toggle_maintenance_mode(callback: CallbackQuery):
maintenance.maintenance_mode = not maintenance.maintenance_mode
new_status = "включён" if maintenance.maintenance_mode else "выключен"
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=build_management_kb())
+9 -6
View File
@@ -23,15 +23,18 @@ def build_panel_kb() -> InlineKeyboardMarkup:
builder.button(text="🔑 Поиск по названию ключа", callback_data=AdminPanelCallback(action="search_key").pack())
builder.row(
InlineKeyboardButton(text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.button(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack())
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(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()),
)
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
)
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1, 1, 2, 1, 2, 1)
builder.adjust(1, 1, 2, 2, 2, 1)
return builder.as_markup()
+2 -3
View File
@@ -1,16 +1,15 @@
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.exceptions import TelegramBadRequest
from bot import version
from filters.admin import IsAdminFilter
from logger import logger
from .keyboard import AdminPanelCallback, build_panel_kb
from logger import logger
router = Router()
+1
View File
@@ -23,6 +23,7 @@ def build_sender_kb() -> InlineKeyboardMarkup:
text="📍 Не использовавшие триал", callback_data=AdminSenderCallback(type="untrial").pack()
)
)
builder.row(InlineKeyboardButton(text="🔥 Горячие лиды", callback_data=AdminSenderCallback(type="hotleads").pack()))
builder.row(
InlineKeyboardButton(text="📢 Кластер", callback_data=AdminSenderCallback(type="cluster-select").pack())
)
+27 -14
View File
@@ -77,43 +77,56 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
state_data = await state.get_data()
send_to = state_data.get("type", "all")
now = int(datetime.utcnow().timestamp() * 1000)
if send_to == "subscribed":
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
SELECT DISTINCT u.tg_id
FROM users u
JOIN keys k ON u.tg_id = k.tg_id
WHERE k.expiry_time > $1
""",
int(datetime.utcnow().timestamp() * 1000),
now,
)
elif send_to == "unsubscribed":
tg_ids = await session.fetch(
"""
SELECT c.tg_id
FROM connections c
LEFT JOIN keys k ON c.tg_id = k.tg_id
GROUP BY c.tg_id
SELECT u.tg_id
FROM users u
LEFT JOIN keys k ON u.tg_id = k.tg_id
GROUP BY u.tg_id
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
""",
int(datetime.utcnow().timestamp() * 1000),
now,
)
elif send_to == "untrial":
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections WHERE trial = 0")
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users WHERE tg_id NOT IN (SELECT tg_id FROM keys)")
elif send_to == "cluster":
cluster_name = state_data.get("cluster_name")
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
SELECT DISTINCT u.tg_id
FROM users u
JOIN keys k ON u.tg_id = k.tg_id
JOIN servers s ON k.server_id = s.cluster_name
WHERE s.cluster_name = $1
""",
cluster_name,
)
elif send_to == "hotleads":
tg_ids = await session.fetch(
"""
SELECT DISTINCT u.tg_id
FROM users u
JOIN payments p ON u.tg_id = p.tg_id
LEFT JOIN keys k ON u.tg_id = k.tg_id
WHERE p.status = 'success'
AND k.tg_id IS NULL
"""
)
else:
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users")
total_users = len(tg_ids)
success_count = 0
+17 -2
View File
@@ -10,12 +10,27 @@ class AdminServerCallback(CallbackData, prefix="admin_server"):
data: str
def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup:
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
from ..clusters.keyboard import AdminClusterCallback
builder = InlineKeyboardBuilder()
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
toggle_action = "disable" if enabled else "enable"
builder.button(text=toggle_text, callback_data=AdminServerCallback(action=toggle_action, data=server_name).pack())
builder.button(
text="📈 Задать лимит", callback_data=AdminServerCallback(action="set_limit", data=server_name).pack()
)
builder.button(text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack())
builder.button(text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack())
builder.button(
text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack()
)
builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
builder.adjust(1)
return builder.as_markup()
+114 -32
View File
@@ -2,12 +2,13 @@ from typing import Any
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from database import delete_server, get_servers
from database import get_servers
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
@@ -19,10 +20,14 @@ from .keyboard import (
router = Router()
class ServerLimitState(StatesGroup):
waiting_for_limit = State()
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_server_manage(callback_query: CallbackQuery, callback_data: AdminServerCallback):
server_name = callback_data.data
servers = await get_servers()
servers = await get_servers(include_enabled=True)
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
@@ -32,17 +37,20 @@ async def handle_server_manage(callback_query: CallbackQuery, callback_data: Adm
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {api_url}\n"
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
f"<b>🔑 Inbound ID:</b> {inbound_id}"
f"<b>🔑 Inbound ID:</b> {inbound_id}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name),
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@@ -73,10 +81,7 @@ async def process_callback_delete_server(
)
return
keys_count = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
server_name
)
keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", server_name)
if keys_count > 0:
await state.update_data(server_name=server_name, cluster_name=cluster_name)
@@ -87,7 +92,7 @@ async def process_callback_delete_server(
FROM servers
WHERE server_name != $1
""",
server_name
server_name,
)
if all_servers:
@@ -96,13 +101,12 @@ async def process_callback_delete_server(
builder.row(
InlineKeyboardButton(
text=f"{server['server_name']} ({server['key_count']})",
callback_data=f"transfer_to_server|{server['server_name']}|{server_name}"
callback_data=f"transfer_to_server|{server['server_name']}|{server_name}",
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(action="manage", data=server_name).pack()
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
)
@@ -114,22 +118,16 @@ async def process_callback_delete_server(
return
remaining_servers = await session.fetchval(
"SELECT COUNT(*) FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
server_name
"SELECT COUNT(*) FROM servers WHERE cluster_name = $1 AND server_name != $2", cluster_name, server_name
)
if remaining_servers == 0:
other_clusters = await session.fetch(
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1",
cluster_name
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1", cluster_name
)
if other_clusters:
cluster_keys_count = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
cluster_name
)
cluster_keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", cluster_name)
if cluster_keys_count > 0:
await state.update_data(server_name=server_name, cluster_name=cluster_name)
@@ -141,7 +139,7 @@ async def process_callback_delete_server(
WHERE cluster_name != $1
GROUP BY cluster_name
""",
cluster_name
cluster_name,
)
builder = InlineKeyboardBuilder()
@@ -149,13 +147,12 @@ async def process_callback_delete_server(
builder.row(
InlineKeyboardButton(
text=f"{cluster['cluster_name']} ({cluster['key_count']})",
callback_data=f"transfer_to_cluster|{cluster['cluster_name']}|{cluster_name}|{server_name}"
callback_data=f"transfer_to_cluster|{cluster['cluster_name']}|{cluster_name}|{server_name}",
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(action="manage", data=server_name).pack()
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
)
@@ -167,9 +164,7 @@ async def process_callback_delete_server(
return
await session.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
server_name
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
)
await callback_query.message.edit_text(
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
@@ -177,11 +172,98 @@ async def process_callback_delete_server(
)
else:
await session.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
server_name
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
)
await callback_query.message.edit_text(
text=f"✅ Сервер '{server_name}' удален.",
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter())
async def toggle_server_enabled(callback_query: CallbackQuery, callback_data: AdminServerCallback, session: Any):
server_name = callback_data.data
action = callback_data.action
new_status = action == "enable"
await session.execute("UPDATE servers SET enabled = $1 WHERE server_name = $2", new_status, server_name)
servers = await get_servers(include_enabled=True)
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
)
if not server:
await callback_query.message.edit_text("❌ Сервер не найден.")
return
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
text = (
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>📈 Лимит ключей:</b> {limit_display}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=new_status),
)
@router.callback_query(AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter())
async def ask_server_limit(callback: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
server_name = callback_data.data
await state.set_state(ServerLimitState.waiting_for_limit)
await state.update_data(server_name=server_name)
await callback.message.edit_text(
f"Введите лимит ключей для сервера <b>{server_name}</b> (целое число, 0 — без лимита):",
)
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
async def save_server_limit(message: types.Message, state: FSMContext, session: Any):
try:
limit = int(message.text.strip())
if limit < 0:
raise ValueError
data = await state.get_data()
server_name = data["server_name"]
new_value = limit if limit > 0 else None
await session.execute("UPDATE servers SET max_keys = $1 WHERE server_name = $2", new_value, server_name)
servers = await get_servers(include_enabled=True)
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
)
if not server:
await message.answer("❌ Сервер не найден.")
await state.clear()
return
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys is not None else "не задан"
text = (
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>📈 Лимит ключей:</b> {limit_display}"
)
await message.answer(
text, reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True))
)
await state.clear()
except ValueError:
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
+138 -123
View File
@@ -1,4 +1,5 @@
import asyncio
import time
import uuid
from datetime import datetime, timedelta, timezone
@@ -13,14 +14,15 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import PUBLIC_LINK, RENEWAL_PRICES, TOTAL_GB
from config import RENEWAL_PRICES, TOTAL_GB, USE_COUNTRY_SELECTION
from database import (
delete_key,
delete_user_data,
get_balance,
get_client_id_by_email,
get_key_details,
get_servers,
store_key,
set_user_balance,
update_balance,
update_key_expiry,
update_trial,
@@ -48,7 +50,6 @@ from .keyboard import (
build_key_edit_kb,
build_user_delete_kb,
build_user_edit_kb,
build_user_key_kb,
build_users_balance_change_kb,
build_users_balance_kb,
build_users_key_expiry_kb,
@@ -62,15 +63,14 @@ router = Router()
class UserEditorState(StatesGroup):
# search
waiting_for_user_data = State()
waiting_for_key_name = State()
# updating data
waiting_for_balance = State()
waiting_for_expiry_time = State()
waiting_for_message_text = State()
selecting_cluster = State()
selecting_duration = State()
selecting_country = State()
@router.callback_query(
@@ -99,41 +99,6 @@ async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.edit_text(text="🔑 Введите имя ключа для поиска:", reply_markup=build_admin_back_kb())
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
if message.forward_from:
tg_id = message.forward_from.id
await process_user_search(message, state, session, tg_id)
return
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
if message.text.isdigit():
tg_id = int(message.text)
else:
# Удаление '@' символа в начале сообщения
username = message.text.strip().lstrip("@")
# Удаление начала ссылки на профиль
username = username.replace("https://t.me/", "")
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
if not user:
await message.answer(
text="🚫 Пользователь с указанным Username не найден!",
reply_markup=kb,
)
return
tg_id = user["tg_id"]
await process_user_search(message, state, session, tg_id)
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
async def handle_key_name_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
@@ -152,6 +117,39 @@ async def handle_key_name_input(message: Message, state: FSMContext, session: An
await process_user_search(message, state, session, key_details["tg_id"])
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
if message.forward_from:
tg_id = message.forward_from.id
await process_user_search(message, state, session, tg_id)
return
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
if message.text.isdigit():
tg_id = int(message.text)
else:
username = message.text.strip().lstrip("@")
username = username.replace("https://t.me/", "")
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
if not user:
await message.answer(
text="🚫 Пользователь с указанным Username не найден!",
reply_markup=kb,
)
return
tg_id = user["tg_id"]
await process_user_search(message, state, session, tg_id)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_send_message"),
IsAdminFilter(),
@@ -246,8 +244,15 @@ async def handle_balance_add(
tg_id = callback_data.tg_id
amount = callback_data.data
if amount:
await update_balance(tg_id, int(amount), session, is_admin=True)
if amount is not None:
amount = int(amount)
if amount >= 0:
await update_balance(tg_id, amount, session, is_admin=True)
else:
current_balance = await get_balance(tg_id)
new_balance = max(0, current_balance + amount)
await set_user_balance(tg_id, new_balance, session)
await handle_balance_change(callback_query, callback_data, session)
return
@@ -304,8 +309,11 @@ async def handle_balance_input(message: Message, state: FSMContext, session: Any
text = f"✅ К балансу пользователя добавлено <b>{amount}Р</b>"
await update_balance(tg_id, amount, session)
elif op_type == "take":
text = f"✅ Из баланса пользователя было вычтено <b>{amount}Р</b>"
await update_balance(tg_id, -amount, session)
current_balance = await get_balance(tg_id)
new_balance = max(0, current_balance - amount)
deducted = current_balance if amount > current_balance else amount
text = f"✅ Из баланса пользователя было вычтено <b>{deducted}Р</b>"
await set_user_balance(tg_id, new_balance, session)
else:
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
await set_user_balance(tg_id, amount, session)
@@ -330,9 +338,11 @@ async def handle_key_edit(
)
return
key_value = key_details.get("key") or key_details.get("remnawave_link") or ""
text = (
f"<b>🔑 Информация о ключе</b>"
f"\n\n<code>{key_details['key']}</code>"
f"\n\n<code>{key_value}</code>"
f"\n\n⏰ Дата истечения: <b>{key_details['expiry_date']} (UTC)</b>"
f"\n🌐 Кластер: <b>{key_details['cluster_name']}</b>"
f"\n🆔 ID клиента: <b>{key_details['tg_id']}</b>"
@@ -603,24 +613,24 @@ async def process_user_search(
) -> None:
await state.clear()
balance = await session.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
if balance is None:
user_data = await session.fetchrow(
"SELECT username, balance, created_at, updated_at FROM users WHERE tg_id = $1", tg_id
)
if not user_data:
await message.answer(
text="🚫 Пользователь с указанным ID не найден!",
reply_markup=build_admin_back_kb(),
)
return
balance = int(balance)
user_data = await session.fetchrow("SELECT username, created_at, updated_at FROM users WHERE tg_id = $1", tg_id)
username = await session.fetchval("SELECT username FROM users WHERE tg_id = $1", tg_id)
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
balance = int(user_data["balance"] or 0)
username = user_data["username"]
created_at = user_data["created_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
updated_at = user_data["updated_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
text = (
f"<b>📊 Информация о пользователе</b>"
f"\n\n🆔 ID: <b>{tg_id}</b>"
@@ -642,34 +652,6 @@ async def process_user_search(
await message.answer(text=text, reply_markup=kb)
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT k.client_id, k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
FROM keys k
JOIN connections c ON k.tg_id = c.tg_id
WHERE k.email = $1
""",
email,
)
if not record:
return None
cluster_name = record["server_id"]
expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
return {
"client_id": record["client_id"],
"balance": record["balance"],
"tg_id": record["tg_id"],
"key": record["key"],
"cluster_name": cluster_name,
"expiry_time": record["expiry_time"],
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
}
async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exception | None:
client_id = await get_client_id_by_email(email)
@@ -682,6 +664,8 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
return ValueError(f"User with client_id {server_id} was not found")
clusters = await get_servers()
added_days = max((expiry_time - int(time.time() * 1000)) / (1000 * 86400), 1)
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
async def update_key_on_all_servers():
tasks = [
@@ -691,7 +675,7 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
email,
client_id,
expiry_time,
total_gb=TOTAL_GB,
total_gb=total_gb,
)
)
for cluster_name in clusters
@@ -703,17 +687,6 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
await update_key_expiry(client_id, expiry_time, session)
async def set_user_balance(tg_id: int, balance: int, session: Any) -> None:
try:
await session.execute(
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
balance,
tg_id,
)
except Exception as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_traffic"), IsAdminFilter())
async def handle_user_traffic(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
@@ -771,13 +744,17 @@ async def restore_trials(callback_query: types.CallbackQuery, session: Any):
Восстанавливает пробники для пользователей, у которых нет активной подписки.
"""
query = """
UPDATE connections
UPDATE users
SET trial = 0
WHERE tg_id IN (
SELECT DISTINCT c.tg_id
FROM connections c
LEFT JOIN keys k ON c.tg_id = k.tg_id
WHERE k.tg_id IS NULL AND c.trial != 0
SELECT u.tg_id
FROM users u
LEFT JOIN (
SELECT tg_id
FROM keys
WHERE expiry_time > EXTRACT(EPOCH FROM NOW()) * 1000
) k ON u.tg_id = k.tg_id
WHERE k.tg_id IS NULL AND u.trial != 0
)
"""
await session.execute(query)
@@ -786,7 +763,7 @@ async def restore_trials(callback_query: types.CallbackQuery, session: Any):
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text="✅ Пробники успешно восстановлены для пользователей, у которых нет активных подписок.",
text="✅ Пробники успешно восстановлены для пользователей без активных подписок.",
reply_markup=builder.as_markup(),
)
@@ -814,11 +791,35 @@ async def handle_users_export_referrals(
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
async def handle_create_key_select_cluster(
async def handle_create_key_start(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id)
if USE_COUNTRY_SELECTION:
await state.set_state(UserEditorState.selecting_country)
rows = await session.fetch("SELECT DISTINCT server_name FROM servers ORDER BY server_name")
countries = [row["server_name"] for row in rows]
if not countries:
await callback_query.message.edit_text(
"❌ Нет доступных стран для создания ключа.", reply_markup=build_editor_kb(tg_id)
)
return
builder = InlineKeyboardBuilder()
for country in countries:
builder.button(text=country, callback_data=country)
builder.adjust(1)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
"🌍 <b>Выберите страну для создания ключа:</b>", reply_markup=builder.as_markup()
)
return
await state.set_state(UserEditorState.selecting_cluster)
servers = await get_servers(session)
@@ -832,10 +833,7 @@ async def handle_create_key_select_cluster(
builder = InlineKeyboardBuilder()
for cluster in cluster_names:
builder.button(
text=f"🌐 {cluster}",
callback_data=cluster
)
builder.button(text=f"🌐 {cluster}", callback_data=cluster)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
@@ -843,6 +841,23 @@ async def handle_create_key_select_cluster(
)
@router.callback_query(UserEditorState.selecting_country, IsAdminFilter())
async def handle_create_key_country(callback_query: CallbackQuery, state: FSMContext):
country = callback_query.data
await state.update_data(country=country)
await state.set_state(UserEditorState.selecting_duration)
builder = InlineKeyboardBuilder()
for months, _ in RENEWAL_PRICES.items():
builder.button(text=f"{months} мес.", callback_data=str(months))
builder.adjust(1)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text=f"🕒 <b>Выберите срок действия ключа для страны {country}:</b>", reply_markup=builder.as_markup()
)
@router.callback_query(UserEditorState.selecting_cluster, IsAdminFilter())
async def handle_create_key_cluster(callback_query: CallbackQuery, state: FSMContext):
cluster_name = callback_query.data
@@ -866,35 +881,35 @@ async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMCo
try:
months = int(callback_query.data)
data = await state.get_data()
tg_id = data["tg_id"]
cluster_name = data["cluster_name"]
client_id = str(uuid.uuid4())
email = generate_random_email()
expiry = datetime.now(tz=timezone.utc) + timedelta(days=30 * months)
expiry_ms = int(expiry.timestamp() * 1000)
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms)
if USE_COUNTRY_SELECTION and "country" in data:
country = data["country"]
await create_key_on_cluster(country, tg_id, client_id, email, expiry_ms, plan=months, session=session)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
await state.clear()
await callback_query.message.edit_text(
f"✅ Ключ успешно создан для страны <b>{country}</b> на {months} мес.",
reply_markup=build_editor_kb(tg_id),
)
await store_key(
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_time=expiry_ms,
key=public_link,
server_id=cluster_name,
session=session,
)
elif "cluster_name" in data:
cluster_name = data["cluster_name"]
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms, plan=months, session=session)
await state.clear()
await state.clear()
await callback_query.message.edit_text(
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.",
reply_markup=build_editor_kb(tg_id),
)
await callback_query.message.edit_text(
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.!",
reply_markup=build_editor_kb(tg_id),
)
else:
await callback_query.message.edit_text("❌ Не удалось определить источник — страна или кластер.")
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
+1 -6
View File
@@ -15,9 +15,7 @@ MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
INVITE = "👥 Пригласить друга"
TOP_FIVE = "🏆 Топ-5"
TRIAL_SUB = "🎁 Пробная подписка"
@@ -40,8 +38,7 @@ GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
GIFTS_MENU = "В меню подарков"
SHARE_GIFT = "🎁 Поделиться подарком"
APPLY = " Подтвердить"
CANCEL = "❌ Отмена"
GET_GIFT = "🎁 Получить подарок"
# Создание и подключение ключей
@@ -83,6 +80,4 @@ TV_INSTRUCTIONS = "📖 Полная инструкция"
PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
PAY = "Пополнить"
PAY_2 = "Оплатить"
STARS_BOT = "🤖 Бот для покупки звезд"
+53 -31
View File
@@ -8,8 +8,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CAPTCHA_EMOJIS
from handlers.texts import CAPTCHA_PROMPT_MSG
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
from logger import logger
from .utils import edit_or_send_message
@@ -25,12 +24,36 @@ async def generate_captcha(message: Message, state: FSMContext):
all_emojis = [correct_emoji] + wrong_emojis
random.shuffle(all_emojis)
await state.update_data(
correct_emoji=correct_emoji,
message_id=message.message_id,
chat_id=message.chat.id,
original_text=message.text,
)
state_data = await state.get_data()
if "user_data" not in state_data:
from_user = message.from_user
if not from_user:
logger.warning("[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data")
return None
await state.update_data(
user_data={
"tg_id": from_user.id,
"username": getattr(from_user, "username", None),
"first_name": getattr(from_user, "first_name", None),
"last_name": getattr(from_user, "last_name", None),
"language_code": getattr(from_user, "language_code", None),
"is_bot": getattr(from_user, "is_bot", False),
}
)
update_data = {
"correct_emoji": correct_emoji,
"message_id": message.message_id,
"chat_id": message.chat.id,
}
state_data = await state.get_data()
if "original_text" not in state_data:
update_data["original_text"] = message.text
await state.update_data(**update_data)
builder = InlineKeyboardBuilder()
for emoji in all_emojis:
@@ -45,34 +68,33 @@ async def generate_captcha(message: Message, state: FSMContext):
@router.callback_query(F.data.startswith("captcha_"))
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
from handlers.start import start_command
from handlers.start import process_start_logic
selected_emoji = callback.data.split("captcha_")[1]
state_data = await state.get_data()
correct_emoji = state_data.get("correct_emoji")
message_id = state_data.get("message_id")
chat_id = state_data.get("chat_id")
original_text = state_data.get("original_text")
user_data = state_data.get("user_data")
if not message_id or not chat_id:
target_message = callback.message
else:
try:
target_message = await callback.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=callback.message.text,
)
except Exception:
target_message = callback.message
target_message = callback.message
if selected_emoji == correct_emoji:
logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу")
await start_command(target_message, state, session, admin, captcha=False)
else:
logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу")
logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}")
await process_start_logic(
message=target_message,
state=state,
session=session,
admin=admin,
text_to_process=original_text,
user_data=user_data,
)
else:
logger.warning(f"Пользователь {callback.from_user.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
if captcha:
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
)
+176 -23
View File
@@ -1,31 +1,43 @@
import html
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import ADMIN_ID
from database import (
add_user,
check_coupon_usage,
check_user_exists,
create_coupon_usage,
get_coupon_by_code,
get_keys,
update_balance,
update_coupon_usage_count,
update_key_expiry,
)
from handlers.buttons import MAIN_MENU, COUPON_RESTART
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPON_ACTIVATED_SUCCESS_MSG,
COUPON_ALREADY_USED_MSG,
COUPON_INPUT_PROMPT,
COUPON_NOT_FOUND_MSG,
)
from .utils import edit_or_send_message
from handlers.utils import edit_or_send_message, format_days
from logger import logger
class CouponActivationState(StatesGroup):
waiting_for_coupon_code = State()
waiting_for_key_selection = State()
router = Router()
@@ -54,32 +66,173 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
@router.message(CouponActivationState.waiting_for_coupon_code)
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
coupon_code = message.text.strip()
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(InlineKeyboardButton(text=COUPON_RESTART, callback_data="activate_coupon"))
await message.answer(activation_result, reply_markup=builder.as_markup())
await state.clear()
await activate_coupon(message, state, session, coupon_code=coupon_code)
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
async def activate_coupon(
message: Message,
state: FSMContext,
session: Any,
coupon_code: str,
admin: bool = False,
user_data: dict | None = None,
):
logger.info(f"Активация купона: {coupon_code}")
coupon_record = await get_coupon_by_code(coupon_code, session)
if not coupon_record:
return COUPON_NOT_FOUND_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
return
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
if coupon_record["usage_count"] >= coupon_record["usage_limit"] or coupon_record["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
await state.clear()
return
if usage_exists:
return COUPON_ALREADY_USED_MSG
user = user_data or message.from_user or message.chat
user_id = user["tg_id"] if isinstance(user, dict) else user.id
coupon_amount = coupon_record["amount"]
usage = await check_coupon_usage(coupon_record["id"], user_id, session)
if usage:
await message.answer(COUPON_ALREADY_USED_MSG)
await state.clear()
return
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
user_exists = await check_user_exists(user_id)
if not user_exists:
if isinstance(user, dict):
await add_user(session=session, **user)
else:
await add_user(
tg_id=user.id,
username=getattr(user, "username", None),
first_name=getattr(user, "first_name", None),
last_name=getattr(user, "last_name", None),
language_code=getattr(user, "language_code", None),
is_bot=getattr(user, "is_bot", False),
session=session,
)
await update_balance(user_id, coupon_amount, session)
return COUPON_ACTIVATED_SUCCESS_MSG.format(coupon_amount=coupon_amount)
if coupon_record["amount"] > 0:
try:
await update_balance(user_id, coupon_record["amount"], session, skip_referral=True)
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon_record['amount']} рублей.")
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
if coupon_record["days"] is not None and coupon_record["days"] > 0:
try:
keys = await get_keys(user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
await state.clear()
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon_record['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(CouponActivationState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon_record["id"], user_id=user_id)
except Exception as e:
logger.error(f"Ошибка при обработке купона на дни: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
await state.clear()
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
try:
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await check_coupon_usage(coupon_id, callback_query.from_user.id, session)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2", callback_query.from_user.id, client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
await renew_key_in_cluster(
cluster_id=key["server_id"], email=key["email"], client_id=client_id, new_expiry_time=new_expiry, total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await update_coupon_usage_count(coupon["id"], session)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime(
"%d.%m.%y, %H:%M"
)
await callback_query.message.answer(
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Ошибка при активации купона.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
@router.callback_query(F.data == "exit_coupon_input")
async def handle_exit_coupon_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
is_admin = callback_query.from_user.id in ADMIN_ID
await process_callback_view_profile(callback_query.message, state, admin=is_admin)
+1
View File
@@ -1,4 +1,5 @@
import os
from typing import Any
from aiogram import F, Router
+6 -5
View File
@@ -2,13 +2,14 @@ __all__ = ("router",)
from aiogram import Router
from .key_management import router as management_router
from .key_connect import router as connect_router
from .key_freeze import router as freeze_router
from .key_mode import router as key_mode_router
from .key_renew import router as renew_router
from .key_view import router as view_router
from .keys import router as keys_router
router = Router(name="keys_main_router")
router.include_routers(
keys_router,
management_router,
)
router.include_routers(keys_router, view_router, renew_router, freeze_router, connect_router, key_mode_router)
+229
View File
@@ -0,0 +1,229 @@
import os
from io import BytesIO
from typing import Any
import asyncpg
import qrcode
from aiogram import F, Router, types
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, INSTRUCTIONS_BUTTON
from handlers.buttons import (
ANDROID,
BACK,
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
IPHONE,
MAIN_MENU,
MANUAL_INSTRUCTIONS,
PC,
TV,
)
from handlers.texts import (
ANDROID_DESCRIPTION_TEMPLATE,
CHOOSE_DEVICE_TEXT,
IOS_DESCRIPTION_TEMPLATE,
SUBSCRIPTION_DESCRIPTION,
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
try:
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
logger.error(f"Ошибка в handle_connect_device: {e}")
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow(
"""
SELECT key FROM keys WHERE email = $1
""",
email,
)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
)
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
try:
key_name = callback_query.data.split("|")[1]
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(record["key"])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record['email']}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
+220
View File
@@ -0,0 +1,220 @@
import time
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
TOTAL_GB,
)
from database import (
get_key_details,
)
from handlers.buttons import (
APPLY,
BACK,
CANCEL,
)
from handlers.keys.key_utils import (
renew_key_in_cluster,
toggle_client_on_cluster,
)
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
SUBSCRIPTION_UNFROZEN_MSG,
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
)
from handlers.utils import edit_or_send_message, handle_error
router = Router()
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"unfreeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Размораживает (включает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = FALSE
WHERE tg_id = $2
AND client_id = $3
""",
new_expiry_time,
record["tg_id"],
client_id,
)
added_days = max(leftover / (1000 * 86400), 0.01)
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
await renew_key_in_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
key_name = callback_query.data.split("|")[1]
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"freeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Замораживает (отключает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
time_left = record["expiry_time"] - now_ms
if time_left < 0:
time_left = 0
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = TRUE
WHERE tg_id = $2
AND client_id = $3
""",
time_left,
record["tg_id"],
client_id,
)
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
-669
View File
@@ -1,669 +0,0 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, FSInputFile
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from bot import bot
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
NOTIFY_EXTRA_DAYS,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_connection,
check_connection_exists,
create_temporary_data,
get_balance,
get_key_details,
get_trial,
store_key,
update_balance,
update_trial,
check_server_name_by_cluster,
)
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PAYMENT,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_COUNTRY_MSG,
SELECT_TARIFF_PLAN_MSG,
key_message_success,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
from panels.three_xui import delete_client
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
waiting_for_key_name = "waiting_for_key_name"
viewing_profile = "viewing_profile"
waiting_for_message = "waiting_for_message"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if DISCOUNTS and plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
async def create_key(
tg_id: int,
expiry_time: datetime,
state: FSMContext | None,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
"""Создаёт ключ с заданным сроком действия."""
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
if USE_COUNTRY_SELECTION:
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
least_loaded_cluster = await get_least_loaded_cluster()
logger.info(f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов")
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
if not servers:
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
logger.info(f"[Country Selection] Список доступных серверов: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if target_message:
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await bot.send_message(
chat_id=tg_id,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
return
while True:
key_name = generate_random_email()
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
least_loaded_cluster = await get_least_loaded_cluster()
tasks = [
asyncio.create_task(
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
)
]
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
least_loaded_cluster,
session,
)
data = {}
if state:
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.replace(tzinfo=None).astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
if target_message:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
current_server,
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
"""Финализирует создание ключа с выбором стран."""
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
public_link = old_key_details["key"]
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
if old_key_name:
old_server_id = old_key_details.get("server_id")
if old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
xui = AsyncApi(
old_server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
deletion_success = await delete_client(
xui,
old_server_info["inbound_id"],
email,
client_id,
)
if not deletion_success:
logger.warning(f"Не удалось удалить клиента с сервера {old_server_id}, но продолжаем процесс.")
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
if old_key_name:
await session.execute(
"""
UPDATE keys
SET server_id = $1
WHERE tg_id = $2 AND email = $3
""",
selected_country,
tg_id,
old_key_name,
)
else:
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
selected_country,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
if state:
await state.clear()
async def check_server_availability(server_info: dict) -> bool:
"""
Проверяет доступность сервера через API.
Args:
server_info (dict): Информация о сервере (api_url, username, password).
Returns:
bool: True, если сервер доступен, False в противном случае.
"""
try:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"Сервер {server_info.get('server_name', 'unknown')} доступен.")
return True
except asyncio.TimeoutError:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} недоступен: {e}")
return False
+16
View File
@@ -0,0 +1,16 @@
__all__ = ("router",)
from aiogram import Router
from .key_cluster_mode import router as cluster_router
from .key_country_mode import router as country_router
from .key_create import router as create_router
router = Router(name="key_mode_router")
router.include_routers(
create_router,
cluster_router,
country_router,
)
+149
View File
@@ -0,0 +1,149 @@
import uuid
from datetime import datetime
import pytz
from aiogram import Router
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
CONNECT_PHONE_BUTTON,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
)
from database import (
get_key_details,
get_trial,
update_balance,
update_trial,
)
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import (
key_message_success,
)
from handlers.utils import (
edit_or_send_message,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_cluster_mode(
tg_id: int,
expiry_time: datetime,
state,
session,
message_or_query: Message | CallbackQuery | None = None,
plan: int = None,
):
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
try:
least_loaded_cluster = await get_least_loaded_cluster()
await create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan, session)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
key_record = await get_key_details(email, session)
if not key_record:
raise ValueError(f"Ключ не найден после создания: {email}")
public_link = key_record.get("key")
remnawave_link = key_record.get("remnawave_link")
final_link = public_link or remnawave_link or ""
data = await state.get_data() if state else {}
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if await is_full_remnawave_cluster(least_loaded_cluster, session):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=final_link),
)
)
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(final_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
if target_message:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
+531
View File
@@ -0,0 +1,531 @@
import asyncio
import uuid
from datetime import datetime
from typing import Any
import asyncpg
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
DATABASE_URL,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
)
from database import (
add_user,
check_server_name_by_cluster,
check_user_exists,
get_key_details,
get_trial,
update_balance,
update_trial,
)
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_client_on_server
from handlers.texts import (
SELECT_COUNTRY_MSG,
key_message_success,
)
from handlers.utils import (
edit_or_send_message,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_country_mode(
tg_id: int,
expiry_time: datetime,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
):
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
least_loaded_cluster = await get_least_loaded_cluster()
servers = await session.fetch(
"SELECT server_name, api_url, panel_type FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
if not servers:
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
return
available_servers = []
tasks = [asyncio.create_task(check_server_availability(server)) for server in servers]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
return
logger.info(f"[Country Selection] Доступные серверы для выбора: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = await session.fetch(
"SELECT server_name, api_url, panel_type, enabled, max_keys FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
current_server,
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
"panel_type": server["panel_type"],
"enabled": server.get("enabled", True),
"max_keys": server.get("max_keys"),
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
if not await check_user_exists(tg_id):
if isinstance(callback_query, CallbackQuery):
from_user = callback_query.from_user
else:
from_user = callback_query.from_user
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
remna = None
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
panel_type = server_info["panel_type"].lower()
public_link = None
remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
cluster_info = await check_server_name_by_cluster(selected_country, session)
if not cluster_info:
raise ValueError(f"Кластер для сервера {selected_country} не найден")
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if old_key_name:
old_server_id = old_key_details.get("server_id")
if old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
old_panel_type = old_server_info["panel_type"].lower()
try:
if old_panel_type == "3x-ui":
xui = await get_xui_instance(old_server_info["api_url"])
await delete_client(
xui,
old_server_info["inbound_id"],
email,
client_id,
)
await session.execute(
"UPDATE keys SET key = NULL WHERE tg_id = $1 AND email = $2",
tg_id,
email,
)
logger.info(f"[Delete] Удалён клиент {email} с 3x-ui сервера {old_server_id}")
elif old_panel_type == "remnawave":
remna = RemnawaveAPI(old_server_info["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
await session.execute(
"UPDATE keys SET remnawave_link = NULL WHERE tg_id = $1 AND email = $2",
tg_id,
email,
)
logger.info(f"[Delete] Удалён клиент {client_id} с Remnawave сервера {old_server_id}")
else:
logger.warning(f"[Delete] Не удалось авторизоваться в Remnawave ({old_server_id})")
except Exception as e:
logger.warning(f"[Delete] Ошибка при удалении клиента с сервера {old_server_id}: {e}")
if panel_type == "remnawave" or is_full_remnawave:
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": [server_info["inbound_id"]],
}
result = await remna.create_user(user_data)
if not result:
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
client_id = result.get("uuid")
remnawave_link = result.get("subscriptionUrl")
logger.info(f"[Key Creation] Remnawave пользователь создан: {result}")
if old_key_name:
await session.execute(
"UPDATE keys SET client_id = $1 WHERE tg_id = $2 AND email = $3",
client_id,
tg_id,
email,
)
if panel_type == "3x-ui":
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
if old_key_name:
await session.execute(
"UPDATE keys SET server_id = $1 WHERE tg_id = $2 AND email = $3",
selected_country,
tg_id,
old_key_name,
)
if panel_type == "3x-ui":
await session.execute(
"UPDATE keys SET key = $1 WHERE tg_id = $2 AND email = $3",
public_link,
tg_id,
email,
)
elif panel_type == "remnawave":
await session.execute(
"UPDATE keys SET remnawave_link = $1 WHERE tg_id = $2 AND email = $3",
remnawave_link,
tg_id,
email,
)
else:
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, remnawave_link, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
remnawave_link,
selected_country,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
except Exception as e:
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
return
builder = InlineKeyboardBuilder()
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if is_full_remnawave and (public_link or remnawave_link):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=public_link or remnawave_link),
)
)
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
key_message_text = key_message_success(link_to_show, f"⏳ Осталось дней: {days} 📅")
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path="img/pic.jpg",
)
if state:
await state.clear()
async def check_server_availability(server_info: dict, session: Any = None) -> bool:
server_name = server_info.get("server_name", "unknown")
panel_type = server_info.get("panel_type", "3x-ui").lower()
enabled = server_info.get("enabled", True)
max_keys = server_info.get("max_keys")
if not enabled:
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
return False
connection = None
external_session = session is not None
try:
if not external_session:
connection = await asyncpg.connect(DATABASE_URL)
session = connection
if max_keys is not None:
count_query = "SELECT COUNT(*) FROM keys WHERE server_id = $1"
key_count = await session.fetchval(count_query, server_name)
if key_count >= max_keys:
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}.")
return False
except Exception as e:
logger.warning(f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}")
return False
finally:
if connection:
await connection.close()
try:
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True
else:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
return True
except TimeoutError:
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
return False
+213
View File
@@ -0,0 +1,213 @@
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
NOTIFY_EXTRA_DAYS,
RENEWAL_PRICES,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_user,
check_user_exists,
create_temporary_data,
get_balance,
get_trial,
)
from handlers.buttons import (
MAIN_MENU,
PAYMENT,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message
from logger import logger
from .key_cluster_mode import key_cluster_mode
from .key_country_mode import key_country_mode
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if DISCOUNTS and plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
async def create_key(
tg_id: int,
expiry_time,
state,
session,
message_or_query=None,
old_key_name: str = None,
plan: int = None,
):
"""
Универсальная точка входа для создания ключа.
Делегирует выполнение в зависимости от выбранного режима (страна или кластер).
Также отвечает за первичное подключение пользователя.
"""
if not await check_user_exists(tg_id):
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
if from_user:
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
if USE_COUNTRY_SELECTION:
await key_country_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
old_key_name=old_key_name,
)
else:
await key_cluster_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
plan=plan,
)
+226
View File
@@ -0,0 +1,226 @@
from datetime import datetime, timedelta
from typing import Any
import asyncpg
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
DATABASE_URL,
RENEWAL_PRICES,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
check_server_name_by_cluster,
create_temporary_data,
get_balance,
get_key_by_server,
get_key_details,
update_balance,
update_key_expiry,
)
from handlers.buttons import (
BACK,
MAIN_MENU,
PAYMENT,
)
from handlers.keys.key_utils import (
renew_key_in_cluster,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
DISCOUNTS,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
SUCCESS_RENEWAL_MSG,
)
from handlers.utils import edit_or_send_message, format_months
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
for plan_id, price in RENEWAL_PRICES.items():
months = int(plan_id)
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
button_text = f"📅 {format_months(months)} ({price} руб.)"
if discount > 0:
button_text += f" {discount}% скидка"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{months}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await callback_query.message.answer("<b>Ключ не найден.</b>")
except Exception as e:
logger.error(f"Ошибка в process_callback_renew_key: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
days_to_extend = 30 * int(plan)
total_gb = int((int(plan) or 1) * TOTAL_GB * 1024**3)
try:
record = await get_key_by_server(tg_id, client_id, session)
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
cost = RENEWAL_PRICES.get(plan)
if cost is None:
await callback_query.message.answer("❌ Неверный тарифный план.")
return
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
)
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"plan": plan,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
await handle_custom_amount_input(callback_query, session)
else:
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
else:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
logger.info(f"[Info] Продление ключа {client_id} на {plan} мес. (Start)")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
response_message = SUCCESS_RENEWAL_MSG.format(months_formatted=format_months(int(plan)))
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
conn = await asyncpg.connect(DATABASE_URL)
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
await conn.close()
return
server_id = key_info["server_id"]
if USE_COUNTRY_SELECTION:
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
await conn.close()
return
cluster_id = cluster_info["cluster_name"]
else:
cluster_id = server_id
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
await update_key_expiry(client_id, new_expiry_time, conn)
await update_balance(tg_id, -cost, conn)
await conn.close()
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
+415 -159
View File
@@ -1,34 +1,49 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
import asyncpg
from py3xui import AsyncApi
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB
from database import get_servers, store_key, delete_notification
from handlers.utils import get_least_loaded_cluster
from bot import bot
from config import (
DATABASE_URL,
LIMIT_IP,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
SUPERNODE,
TOTAL_GB,
)
from database import delete_notification, get_servers, store_key
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
cluster_id: str,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
plan: int = None,
session=None,
remnawave_link: str = None,
):
"""
Создает ключ на всех серверах указанного кластера (или на конкретном сервере, если cluster_id это имя сервера).
"""
try:
servers = await get_servers()
servers = await get_servers(include_enabled=True)
cluster = servers.get(cluster_id)
server_id_to_store = cluster_id
if not cluster:
found_servers = []
@@ -38,23 +53,118 @@ async def create_key_on_cluster(
found_servers.append(server_info)
if found_servers:
cluster = found_servers
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
enabled_servers = [s for s in cluster if s.get("enabled", True)]
if not enabled_servers:
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
return
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
remnawave_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, conn)
]
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, conn)
]
if not remnawave_servers and not xui_servers:
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
return
semaphore = asyncio.Semaphore(2)
if SUPERNODE:
for server_info in cluster:
await create_client_on_server(
server_info, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan
remnawave_created = False
remnawave_key = None
remnawave_client_id = None
if remnawave_servers:
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
else:
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
inbound_ids = [s.get("inbound_id") for s in remnawave_servers if s.get("inbound_id")]
if not inbound_ids:
logger.warning("Нет inbound_id у серверов Remnawave")
else:
traffic_limit_bytes = int((plan or 1) * TOTAL_GB * 1024**3)
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"trafficLimitBytes": traffic_limit_bytes,
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
if short_uuid:
user_data["shortUuid"] = short_uuid
result = await remna.create_user(user_data)
if not result:
logger.error("Ошибка при создании пользователя в Remnawave")
else:
remnawave_created = True
remnawave_key = result.get("subscriptionUrl")
remnawave_client_id = result.get("uuid")
logger.info(f"[Key Creation] Пользователь создан в Remnawave: {result}")
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" if xui_servers else None
final_client_id = remnawave_client_id or client_id
if xui_servers:
if SUPERNODE:
for server_info in xui_servers:
await create_client_on_server(
server_info,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
)
else:
await asyncio.gather(
*(
create_client_on_server(
server,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
)
for server in xui_servers
),
return_exceptions=True,
)
else:
await asyncio.gather(
*(
create_client_on_server(server, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan)
for server in cluster
),
return_exceptions=True,
if (remnawave_created and remnawave_client_id) or xui_servers:
await store_key(
tg_id,
final_client_id,
email,
expiry_timestamp,
key=public_link,
server_id=server_id_to_store,
session=session,
remnawave_link=remnawave_key,
)
except Exception as e:
@@ -75,12 +185,7 @@ async def create_client_on_server(
Создает клиента на указанном сервере.
"""
async with semaphore:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
@@ -96,7 +201,7 @@ async def create_client_on_server(
unique_email = email
sub_id = unique_email
total_gb_value = int(TOTAL_GB) if plan is None else int(plan) * int(TOTAL_GB)
total_gb_value = int((plan or 1) * TOTAL_GB * 1024**3)
await add_client(
xui,
@@ -150,35 +255,81 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
notification_id = f"{email}_{notif}"
await delete_notification(tg_id, notification_id, session=conn)
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
remnawave_inbound_ids = []
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if panel_type == "remnawave":
inbound_uuid = server_info.get("inbound_id")
if inbound_uuid:
remnawave_inbound_ids.append(inbound_uuid)
else:
logger.warning(f"Не указан inbound_id для продления Remnawave на сервере {server_name}")
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
tasks.append(
extend_client_key(
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
)
if remnawave_inbound_ids:
remnawave_server = next(
(
srv
for srv in cluster
if srv.get("panel_type", "").lower() == "remnawave"
and srv.get("inbound_id") in remnawave_inbound_ids
),
None,
)
if not remnawave_server:
logger.error("❌ Не найден Remnawave сервер для продления")
else:
remna = RemnawaveAPI(remnawave_server["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if logged_in:
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
updated = await remna.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=remnawave_inbound_ids,
traffic_limit_bytes=total_gb,
)
if updated:
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
await remna.reset_user_traffic(client_id)
else:
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}")
else:
logger.error("Не удалось войти в Remnawave API")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
tasks.append(
extend_client_key(
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
)
)
elif panel_type != "remnawave":
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
@@ -204,32 +355,42 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
panel_type = server_info.get("panel_type", "3x-ui").lower()
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
continue
success = await remna.delete_user(client_id)
if success:
logger.info(f"Клиент Remnawave {client_id} успешно удалён")
else:
logger.warning(f"Не удалось удалить клиента Remnawave {client_id}")
continue
tasks.append(
delete_client(
elif panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
continue
await delete_client(
xui,
int(inbound_id),
email,
client_id,
)
)
await asyncio.gather(*tasks, return_exceptions=True)
else:
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_info.get('server_name')}")
except Exception as e:
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
@@ -238,7 +399,10 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id):
"""
Обновляет ключ на всех серверах указанного кластера (или сервера, если передано имя).
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
Работает с панелями 3x-ui и Remnawave. Использует новый client_id от Remnawave для всех серверов (если пересоздан).
Если SUPERNODE активен email делается уникальным на каждый сервер.
Возвращает кортеж: (новый client_id, remnawave ссылка или None).
"""
try:
servers = await get_servers()
@@ -255,46 +419,84 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_client_id = None
remnawave_key = None
if remnawave_servers:
inbound_ids = [s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")]
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if logged_in:
await remna.delete_user(client_id)
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
else:
logger.error("[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
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"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
tasks.append(
add_client(
xui,
ClientConfig(
client_id=client_id,
email=email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=email,
),
)
xui = await get_xui_instance(server_info["api_url"])
if SUPERNODE:
sub_id = email
unique_email = f"{email}_{server_name.lower()}"
else:
sub_id = email
unique_email = email
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
await asyncio.gather(*tasks, return_exceptions=True)
tasks.append(add_client(xui, config))
logger.info(f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}")
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}")
return remnawave_client_id, remnawave_key
except Exception as e:
logger.error(f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}")
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise e
@@ -324,20 +526,23 @@ async def update_subscription(tg_id: int, email: str, session: Any, cluster_over
tg_id,
email,
)
new_cluster_id = cluster_override or await get_least_loaded_cluster()
await asyncio.gather(
update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id),
return_exceptions=True,
)
new_client_id, remnawave_key = await update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id)
servers = await get_servers()
cluster_servers = servers.get(new_cluster_id, [])
has_xui = any(s.get("panel_type", "").lower() == "3x-ui" for s in cluster_servers)
final_key_link = public_link if has_xui else None
await store_key(
tg_id,
client_id,
new_client_id,
email,
expiry_time,
public_link,
key=final_key_link,
remnawave_link=remnawave_key,
server_id=new_cluster_id,
session=session,
)
@@ -345,71 +550,94 @@ async def update_subscription(tg_id: int, email: str, session: Any, cluster_over
async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ.
Args:
session (Any): Сессия базы данных.
tg_id (int): ID пользователя Telegram.
email (str): Email пользователя.
Returns:
dict[str, Any]: Структура с данными о трафике.
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
"""
query = "SELECT client_id, server_id FROM keys WHERE tg_id = $1 AND email = $2"
rows = await session.fetch(query, tg_id, email)
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
return {"status": "error", "message": "У пользователя нет активных ключей."}
server_ids = {row["server_id"] for row in rows}
query_servers = """
SELECT server_name, api_url FROM servers
SELECT server_name, cluster_name, api_url, panel_type
FROM servers
WHERE server_name = ANY($1) OR cluster_name = ANY($1)
"""
server_rows = await session.fetch(query_servers, list(server_ids))
if not server_rows:
logger.error(f"Не найдено серверов для: {server_ids}")
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
logger.error(f"Не найдено серверов для: {server_ids}")
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
servers_map = {row["server_name"]: row["api_url"] for row in server_rows}
servers_map = {row["server_name"]: row for row in server_rows}
user_traffic_data = {}
async def fetch_traffic(api_url: str, client_id: str, server: str) -> tuple[str, Any]:
"""
Получает трафик с сервера для заданного client_id.
Возвращает кортеж: (server, used_gb) или (server, ошибка).
"""
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
try:
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server, round(used_gb, 2)
else:
return server, "Ошибка получения трафика"
except Exception as e:
return server, f"Ошибка: {e}"
tasks = []
remnawave_client_id = None
remnawave_checked = False
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"]
if server_id in servers_map:
api_url = servers_map[server_id]
tasks.append(fetch_traffic(api_url, client_id, server_id))
else:
for server, api_url in servers_map.items():
tasks.append(fetch_traffic(api_url, client_id, server))
matched_servers = [
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
]
for server_info in matched_servers:
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:
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}
@@ -446,12 +674,7 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
@@ -491,6 +714,7 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
"""
Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
Работает с 3x-ui и Remnawave.
Args:
cluster_id (str): ID кластера или имя сервера
@@ -513,24 +737,56 @@ async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
for server_info in cluster:
api_url = server_info["api_url"]
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
remnawave_done = False
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
api_url = server_info.get("api_url")
inbound_id = server_info.get("inbound_id")
if panel_type == "remnawave" and not remnawave_done:
conn = await asyncpg.connect(DATABASE_URL)
try:
row = await conn.fetchrow(
"SELECT client_id FROM keys WHERE email = $1 AND server_id = $2 LIMIT 1",
email,
cluster_id,
)
finally:
await conn.close()
if not row:
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row["client_id"]
remna = RemnawaveAPI(api_url)
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
remnawave_done = True
continue
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
xui = await get_xui_instance(api_url)
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
else:
logger.warning(f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f" Трафик клиента {email} успешно сброшен на всех серверах кластера {cluster_id}")
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(f" Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+322
View File
@@ -0,0 +1,322 @@
import html
import os
import re
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
QRCODE,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
)
from database import (
get_key_details,
get_keys,
)
from handlers.buttons import (
ADD_SUB,
ALIAS,
BACK,
CHANGE_LOCATION,
CONNECT_DEVICE,
CONNECT_PHONE,
DELETE,
FREEZE,
MAIN_MENU,
PC_BUTTON,
QR,
RENEW,
RENEW_FULL,
TV_BUTTON,
UNFREEZE,
)
from handlers.texts import (
FROZEN_SUBSCRIPTION_MSG,
NO_SUBSCRIPTIONS_MSG,
key_message,
)
from handlers.utils import (
edit_or_send_message,
format_days,
format_hours,
format_minutes,
get_russian_month,
handle_error,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
class RenameKeyState(StatesGroup):
waiting_for_new_alias = State()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
try:
records = await get_keys(target_message.chat.id, session)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
for record in records:
alias = record.get("alias")
email = record["email"]
client_id = record["client_id"]
expiry_time = record.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
else:
response_message = NO_SUBSCRIPTIONS_MSG
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("rename_key|"))
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
client_id = callback.data.split("|")[1]
await state.set_state(RenameKeyState.waiting_for_new_alias)
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
await edit_or_send_message(
target_message=callback.message,
text="✏️ Введите новое имя подписки (до 10 символов):",
reply_markup=builder.as_markup(),
)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
alias = message.text.strip()
if len(alias) > 10:
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
await message.answer(
"❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод."
)
return
data = await state.get_data()
client_id = data.get("client_id")
try:
await session.execute(
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
alias,
message.chat.id,
client_id,
)
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
finally:
await state.clear()
await process_callback_or_message_view_keys(message, session)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
return
is_frozen = record["is_frozen"]
builder = InlineKeyboardBuilder()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
if is_frozen:
builder.row(
InlineKeyboardButton(
text=UNFREEZE,
callback_data=f"unfreeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=FROZEN_SUBSCRIPTION_MSG,
reply_markup=builder.as_markup(),
media_path=image_path,
)
return
key = record.get("key")
remnawave_link = record.get("remnawave_link")
final_link = key or remnawave_link
expiry_time = record["expiry_time"]
server_name = record["server_id"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
time_left = expiry_date - datetime.utcnow()
if time_left.total_seconds() <= 0:
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла"
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = (
f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
)
formatted_expiry_date = (
f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
)
response_message = key_message(
final_link,
formatted_expiry_date,
days_left_message,
server_name,
server_name if USE_COUNTRY_SELECTION else None,
)
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
if is_full_remnawave and final_link:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=final_link),
)
)
else:
if CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(
text=CONNECT_PHONE,
callback_data=f"connect_phone|{key_name}",
)
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
if QRCODE:
builder.row(
InlineKeyboardButton(
text=QR,
callback_data=f"show_qr|{key_name}",
)
)
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
)
else:
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
if USE_COUNTRY_SELECTION:
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
if TOGGLE_CLIENT:
builder.row(
InlineKeyboardButton(
text=FREEZE,
callback_data=f"freeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
)
+3 -921
View File
@@ -1,754 +1,37 @@
import asyncio
import html
import locale
import os
import time
import re
from datetime import datetime, timedelta
from io import BytesIO
from typing import Any
import asyncpg
import pytz
import qrcode
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import CallbackQuery
from bot import bot
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
CONNECT_PHONE_BUTTON,
DATABASE_URL,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
PUBLIC_LINK,
QRCODE,
RENEWAL_PLANS,
TOGGLE_CLIENT,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
INSTRUCTIONS_BUTTON
)
from database import (
check_server_name_by_cluster,
create_temporary_data,
delete_key,
get_balance,
get_key_by_server,
get_key_details,
get_keys,
get_servers,
update_balance,
update_key_expiry,
)
from handlers.buttons import (
ADD_SUB,
ALIAS,
ANDROID,
APPLY,
BACK,
CANCEL,
CHANGE_LOCATION,
CONNECT_DEVICE,
CONNECT_PHONE,
DELETE,
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
FREEZE,
IMPORT_ANDROID,
IMPORT_IOS,
IPHONE,
MAIN_MENU,
MANUAL_INSTRUCTIONS,
PAYMENT,
PC,
PC_BUTTON,
QR,
RENEW,
RENEW_FULL,
TV,
TV_BUTTON,
UNFREEZE,
)
from handlers.keys.key_utils import (
delete_key_from_cluster,
renew_key_in_cluster,
toggle_client_on_cluster,
update_subscription,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.keys.key_view import process_callback_view_key
from handlers.texts import (
ANDROID_DESCRIPTION_TEMPLATE,
CHOOSE_DEVICE_TEXT,
DELETE_KEY_CONFIRM_MSG,
DISCOUNTS,
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
FROZEN_SUBSCRIPTION_MSG,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
IOS_DESCRIPTION_TEMPLATE,
KEY_DELETED_MSG_SIMPLE,
KEY_NOT_FOUND_MSG,
NO_SUBSCRIPTIONS_MSG,
PLAN_SELECTION_MSG,
SUBSCRIPTION_DESCRIPTION,
SUBSCRIPTION_FROZEN_MSG,
SUBSCRIPTION_UNFROZEN_MSG,
SUCCESS_RENEWAL_MSG,
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
key_message,
)
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
router = Router()
class RenameKeyState(StatesGroup):
waiting_for_new_alias = State()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
try:
records = await get_keys(target_message.chat.id, session)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
for record in records:
alias = record.get("alias")
email = record["email"]
client_id = record["client_id"]
expiry_time = record.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
else:
response_message = NO_SUBSCRIPTIONS_MSG
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("rename_key|"))
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
client_id = callback.data.split("|")[1]
await state.set_state(RenameKeyState.waiting_for_new_alias)
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
await edit_or_send_message(
target_message=callback.message,
text="✏️ Введите новое имя подписки (до 10 символов):",
reply_markup=builder.as_markup()
)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
alias = message.text.strip()
if len(alias) > 10:
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
await message.answer("❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод.")
return
data = await state.get_data()
client_id = data.get("client_id")
try:
await session.execute(
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
alias,
message.chat.id,
client_id,
)
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
finally:
await state.clear()
await process_callback_or_message_view_keys(message, session)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
is_frozen = record["is_frozen"]
if is_frozen:
response_message = FROZEN_SUBSCRIPTION_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=UNFREEZE,
callback_data=f"unfreeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=keyboard,
media_path=image_path,
)
else:
key = record["key"]
expiry_time = record["expiry_time"]
server_name = record["server_id"]
country = server_name
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0\nОсталось минут: 0"
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = f"Осталось: <b>{days}</b> дней, <b>{hours}</b> часов, <b>{minutes}</b> минут"
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
response_message = key_message(
key,
formatted_expiry_date,
days_left_message,
server_name,
country if USE_COUNTRY_SELECTION else None,
)
builder = InlineKeyboardBuilder()
if not key.startswith(PUBLIC_LINK) or ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
if CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(
text=CONNECT_PHONE,
callback_data=f"connect_phone|{key_name}",
)
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
if QRCODE:
builder.row(
InlineKeyboardButton(
text=QR,
callback_data=f"show_qr|{key_name}",
)
)
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
)
else:
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
if USE_COUNTRY_SELECTION:
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
if TOGGLE_CLIENT:
builder.row(
InlineKeyboardButton(
text=FREEZE,
callback_data=f"freeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=keyboard,
media_path=image_path,
)
else:
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
)
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
try:
key_name = callback_query.data.split("|")[1]
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(record["key"])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record['email']}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
try:
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
logger.error(f"Ошибка в handle_connect_device: {e}")
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"unfreeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Размораживает (включает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = FALSE
WHERE tg_id = $2
AND client_id = $3
""",
new_expiry_time,
record["tg_id"],
client_id,
)
await renew_key_in_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=TOTAL_GB,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
key_name = callback_query.data.split("|")[1]
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"freeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Замораживает (отключает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
time_left = record["expiry_time"] - now_ms
if time_left < 0:
time_left = 0
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = TRUE
WHERE tg_id = $2
AND client_id = $3
""",
time_left,
record["tg_id"],
client_id,
)
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow(
"""
SELECT key FROM keys WHERE email = $1
""",
email,
)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
)
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("update_subscription|"))
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
@@ -815,7 +98,7 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
servers = await get_servers(session)
async def delete_key_from_servers():
try: # lol
try:
tasks = []
for cluster_id, _cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
@@ -835,204 +118,3 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
)
except Exception as e:
logger.error(e)
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
for plan_id, plan_details in RENEWAL_PLANS.items():
months = plan_details["months"]
price = plan_details["price"]
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
button_text = f"📅 {months} месяц{'а' if months > 1 else ''} ({price} руб.)"
if discount > 0:
button_text += f" {discount}% скидка"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{months}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await callback_query.message.answer("<b>Ключ не найден.</b>")
except Exception as e:
logger.error(f"Ошибка в process_callback_renew_key: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
days_to_extend = 30 * int(plan)
gb_multiplier = {"1": 1, "3": 3, "6": 6, "12": 12}
total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0
try:
record = await get_key_by_server(tg_id, client_id, session)
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
cost = RENEWAL_PLANS[plan]["price"]
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
)
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"plan": plan,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
await handle_custom_amount_input(callback_query, session)
else:
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
else:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
logger.info(
f"[RENEW] Начинаю процесс продления ключа с параметрами: "
f"tg_id={tg_id}, client_id={client_id}, email={email}, "
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}, cost={cost}, "
f"callback_query={'есть' if callback_query else 'отсутствует'}, plan={plan}"
)
response_message = SUCCESS_RENEWAL_MSG.format(months=plan)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка редактирования сообщения в complete_key_renewal: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
conn = await asyncpg.connect(DATABASE_URL)
logger.info(f"[RENEW] Получение данных о ключе для email: {email}")
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[RENEW] Ключ с client_id {client_id} для пользователя {tg_id} не найден.")
await conn.close()
return
server_id = key_info["server_id"]
if USE_COUNTRY_SELECTION:
logger.info(f"[RENEW] USE_COUNTRY_SELECTION включён. Проверяю информацию о сервере {server_id}")
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[RENEW] Сервер {server_id} не найден в таблице servers.")
await conn.close()
return
cluster_id = cluster_info["cluster_name"]
logger.info(f"[RENEW] Информация о сервере получена: {cluster_info}. Использую cluster_id: {cluster_id}")
else:
cluster_id = server_id
logger.info(f"[RENEW] USE_COUNTRY_SELECTION выключен. Использую server_id в качестве cluster_id: {cluster_id}")
logger.info(f"[RENEW] Запуск продления ключа для пользователя {tg_id} на {plan} мес. в кластере {cluster_id}.")
async def renew_key_on_cluster():
logger.info(
f"[RENEW] Запуск renew_key_on_cluster с параметрами: "
f"cluster_id={cluster_id}, email={email}, client_id={client_id}, "
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}"
)
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
logger.info("[RENEW] Продление ключа на сервере завершено. Обновляю срок действия в базе данных.")
await update_key_expiry(client_id, new_expiry_time, conn)
logger.info("[RENEW] Срок действия ключа обновлён. Обновляю баланс пользователя.")
await update_balance(tg_id, -cost, conn)
logger.info(f"[RENEW] Ключ {client_id} успешно продлён на {plan} мес. для пользователя {tg_id}.")
logger.info("[RENEW] Инициализация процесса продления ключа в кластере.")
await renew_key_on_cluster()
logger.info("[RENEW] Процесс продления ключа завершён. Закрываю соединение с базой данных.")
await conn.close()
+108 -223
View File
@@ -5,21 +5,17 @@ import re
import time
import urllib.parse
from datetime import datetime
import aiohttp
import asyncpg
import pytz
from aiohttp import web
from config import (
DATABASE_URL,
PROJECT_NAME,
RANDOM_SUBSCRIPTIONS,
SUPERNODE,
SUPPORT_CHAT_URL,
TOTAL_GB,
TRANSITION_DATE_STR,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
@@ -28,240 +24,155 @@ from handlers.utils import convert_to_bytes
from logger import logger
async def fetch_url_content(url: str, identifier: str) -> list[str]:
"""
Получает содержимое подписки по URL и декодирует его.
Args:
url: URL для получения содержимого
identifier: Идентификатор пользователя (tg_id или email)
Returns:
Список строк из подписки
"""
async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[str, str]]:
try:
logger.info(f"Получение URL: {url} для идентификатора: {identifier}")
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, ssl=False) as response:
if response.status == 200:
content = await response.text()
logger.info(f"Успешно получен контент с {url} для идентификатора: {identifier}")
return base64.b64decode(content).decode("utf-8").split("\n")
else:
logger.error(
f"Не удалось получить {url} для идентификатора: {identifier}, статус: {response.status}"
)
return []
except TimeoutError:
logger.error(f"Таймаут при получении {url} для идентификатора: {identifier}")
return []
lines = base64.b64decode(content).decode("utf-8").split("\n")
headers = {k.lower(): v for k, v in response.headers.items()}
logger.debug(f"Fetched {url}: {len(lines)} lines, headers: {headers}")
return lines, headers
return [], {}
except Exception as e:
logger.error(f"Ошибка при получении {url} для идентификатора: {identifier}: {e}")
return []
logger.error(f"Error fetching URL {url}: {e}")
return [], {}
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]:
"""
Объединяет строки подписки из нескольких URL, удаляя дубликаты.
Args:
urls: Список URL для получения подписок
identifier: Идентификатор пользователя (tg_id или email)
query_string: Строка запроса для добавления к URL
Returns:
Список уникальных строк из всех подписок
"""
async def combine_unique_lines(
urls: list[str], identifier: str, query_string: str
) -> tuple[list[str], list[dict[str, str]]]:
if SUPERNODE:
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
if not urls:
return []
return [], []
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
return await fetch_url_content(url_with_query, identifier)
lines, headers = await fetch_url_content(url_with_query, identifier)
return lines, [headers]
logger.info(f"Начинаем объединение подписок для идентификатора: {identifier}, запрос: {query_string}")
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
logger.info(f"Составлены URL-адреса: {urls_with_query}")
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_lines = set()
for lines in results:
all_lines.update(filter(None, lines))
logger.info(
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для идентификатора: {identifier}"
)
return list(all_lines)
all_headers = []
for result in results:
if isinstance(result, tuple):
lines, headers = result
all_lines.update(filter(None, lines))
all_headers.append(headers)
return list(all_lines), all_headers
async def get_subscription_urls(server_id: str, email: str, conn) -> list[str]:
"""
Получает список URL-адресов для подписки в зависимости от режима выбора страны.
Args:
server_id: Идентификатор сервера или кластера
email: Email пользователя
conn: Соединение с базой данных
Returns:
Список URL-адресов для подписки
"""
async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]:
urls = []
if USE_COUNTRY_SELECTION:
logger.info(f"Режим выбора страны активен. Ищем сервер {server_id} в БД.")
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
if not server_data:
logger.warning(f"Не найден сервер {server_id} в БД!")
return []
subscription_url = server_data["subscription_url"]
urls = [f"{subscription_url}/{email}"]
logger.info(f"Используем подписку {urls[0]}")
return urls
if server_data and server_data["subscription_url"]:
urls.append(f"{server_data['subscription_url']}/{email}")
else:
servers = await get_servers(conn)
cluster_servers = servers.get(server_id, [])
for server in cluster_servers:
if url := server.get("subscription_url"):
urls.append(f"{url}/{email}")
if include_remnawave_key:
urls.append(include_remnawave_key)
servers = await get_servers(conn)
logger.info(f"Режим выбора страны отключен. Используем кластер {server_id}.")
cluster_servers = servers.get(server_id, [])
if not cluster_servers:
logger.warning(f"Не найдены сервера для {server_id}")
return []
urls = [f"{server['subscription_url']}/{email}" for server in cluster_servers]
logger.info(f"Найдено {len(urls)} URL-адресов в кластере {server_id}")
return urls
def get_transition_timestamp() -> int:
"""
Получает временную метку перехода с учетом часового пояса Москвы.
Returns:
Временная метка перехода в миллисекундах
"""
moscow_tz = pytz.timezone("Europe/Moscow")
transition_date_naive = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S")
transition_date = moscow_tz.localize(transition_date_naive)
transition_timestamp_ms = int(transition_date.timestamp() * 1000)
return transition_timestamp_ms
def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None) -> str:
"""
Рассчитывает информацию о трафике на основе подписок.
Args:
cleaned_subscriptions: Список строк подписки
expiry_time_ms: Время истечения подписки в миллисекундах
Returns:
Строка с информацией о трафике
"""
def calculate_traffic(
cleaned_subscriptions: list[str], expiry_time_ms: int | None, headers_list: list[dict[str, str]]
) -> str:
logger.debug(f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}")
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
if TOTAL_GB != 0:
country_remaining = {}
for line in cleaned_subscriptions:
if "#" not in line:
continue
upload = 0
download = 0
total = 0
for headers in headers_list:
userinfo = headers.get("subscription-userinfo", "")
if userinfo:
parts = userinfo.split(";")
for part in parts:
part = part.strip()
if part.startswith("upload="):
upload += int(part.split("=")[1])
elif part.startswith("download="):
download += int(part.split("=")[1])
elif part.startswith("total="):
total += int(part.split("=")[1])
logger.debug(f"Processed Subscription-Userinfo: {userinfo}")
try:
_, meta = line.split("#", 1)
except ValueError:
continue
country_remaining = {}
for line in cleaned_subscriptions:
if "#" not in line:
continue
try:
_, meta = line.split("#", 1)
except ValueError:
continue
parts = meta.split("-")
country = parts[0].strip()
remaining_str = parts[1].strip() if len(parts) == 2 else ""
if remaining_str:
remaining_str = remaining_str.replace(",", ".")
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
if m_total:
value = float(m_total.group(1))
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit)
country_remaining[country] = remaining_bytes
logger.debug(f"Found traffic: {value}{unit} for {country}")
parts = meta.split("-")
country = parts[0].strip()
remaining_str = parts[1].strip() if len(parts) == 2 else ""
consumed_traffic_bytes = total - sum(country_remaining.values()) if country_remaining else download
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
download = max(download, consumed_traffic_bytes)
if remaining_str:
remaining_str = remaining_str.replace(",", ".")
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
if m_total:
value = float(m_total.group(1))
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit)
country_remaining[country] = remaining_bytes
if download == 0 and total == 0 and not country_remaining:
download = 1
num_countries = len(country_remaining)
issued_per_country = TOTAL_GB
total_traffic_bytes = issued_per_country * num_countries
consumed_traffic_bytes = total_traffic_bytes - sum(country_remaining.values())
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
else:
consumed_traffic_bytes = 1
total_traffic_bytes = 0
return f"upload=0; download={consumed_traffic_bytes}; total={total_traffic_bytes}; expire={expire_timestamp}"
result = f"upload={upload}; download={download}; total={total}; expire={expire_timestamp}"
logger.debug(f"Calculated subscription-userinfo: {result}")
return result
def clean_subscription_line(line: str) -> str:
"""
Очищает строку подписки, оставляя только нужную информацию.
Args:
line: Исходная строка подписки
Returns:
Очищенная строка подписки
"""
if "#" not in line:
return line
try:
base, meta = line.split("#", 1)
except ValueError:
return line
parts = meta.split("-")
country = parts[0].strip() if parts else ""
traffic = ""
for part in parts[1:]:
part_decoded = urllib.parse.unquote(part).strip()
if re.search(r"\d+(?:[.,]\d+)?\s*(?:GB|MB|KB|TB)", part_decoded, re.IGNORECASE):
traffic = part_decoded
break
meta_clean = f"{country} - {traffic}" if traffic else country
return base + "#" + meta_clean
def format_time_left(expiry_time_ms: int | None) -> str:
"""
Форматирует оставшееся время подписки.
Args:
expiry_time_ms: Время истечения подписки в миллисекундах
Returns:
Отформатированная строка с оставшимся временем
"""
if not expiry_time_ms:
return "N/A"
now_ms = int(time.time() * 1000)
remaining_sec = max((expiry_time_ms - now_ms) / 1000, 0)
days = int(remaining_sec // 86400)
hours = int((remaining_sec % 86400) // 3600)
return f"{days}D,{hours}H ⏳" if days else f"{hours}H ⏳"
def prepare_headers(
user_agent: str, project_name: str, subscription_info: str, subscription_userinfo: str
) -> dict[str, str]:
"""
Подготавливает заголовки ответа в зависимости от User-Agent клиента.
Args:
user_agent: User-Agent клиента
project_name: Название проекта
subscription_info: Информация о подписке
subscription_userinfo: Информация о трафике
Returns:
Словарь с заголовками ответа
"""
if "Happ" in user_agent:
encoded_project_name = f"{project_name}"
announce_str = f"↖️Бот | {subscription_info} | Поддержка↗️"
@@ -278,13 +189,23 @@ def prepare_headers(
elif "Hiddify" in user_agent:
parts = subscription_info.split(" - ")[0].split(": ")
key_info = parts[1] if len(parts) > 1 else parts[0]
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
return {
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
elif "v2raytun" in user_agent:
encoded_project_name = f"{project_name}\n{subscription_info}"
announce_str = "🔑 Выберите сервер ⬇️ | 💬 Поддержка ➡️"
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"update-always": "true",
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce-url": f"{SUPPORT_CHAT_URL}",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
}
else:
encoded_project_name = f"{project_name}\n{subscription_info}"
return {
@@ -295,64 +216,39 @@ def prepare_headers(
}
async def handle_subscription(request: web.Request, old_subscription: bool = False) -> web.Response:
"""
Обрабатывает запрос на подписку (старую или новую).
Args:
request: Объект запроса
old_subscription: Флаг, указывающий на тип подписки (старая или новая)
Returns:
Ответ с подпиской или сообщением об ошибке
"""
async def handle_subscription(request: web.Request) -> web.Response:
email = request.match_info.get("email")
tg_id = request.match_info.get("tg_id") if not old_subscription else None
tg_id = request.match_info.get("tg_id")
if not email or (not old_subscription and not tg_id):
logger.warning("Получен запрос с отсутствующими параметрами")
if not email or not tg_id:
return web.Response(text="❌ Неверные параметры запроса.", status=400)
logger.info(
f"Обработка запроса для {'старого' if old_subscription else 'нового'} клиента: email={email}, tg_id={tg_id}"
)
conn = await asyncpg.connect(DATABASE_URL)
try:
client_data = await get_key_details(email, conn)
if not client_data:
logger.warning(f"Клиент с email {email} не найден в базе.")
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
stored_tg_id = client_data.get("tg_id")
server_id = client_data["server_id"]
if not old_subscription and int(tg_id) != int(stored_tg_id):
logger.warning(f"Неверный tg_id для клиента с email {email}.")
if int(tg_id) != int(stored_tg_id):
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
if old_subscription:
created_at_ms = client_data["created_at"]
created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000)
logger.info(f"created_at для {email}: {created_at_datetime}, server_id: {server_id}")
transition_timestamp_ms = get_transition_timestamp()
logger.info(f"Время перехода (с учетом часового пояса Москвы): {transition_timestamp_ms}")
if created_at_ms >= transition_timestamp_ms:
logger.info(f"Клиент с email {email} является новым.")
return web.Response(text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.", status=400)
expiry_time_ms = client_data.get("expiry_time")
time_left = format_time_left(expiry_time_ms)
urls = await get_subscription_urls(server_id, email, conn)
urls = await get_subscription_urls(
server_id, email, conn, include_remnawave_key=client_data.get("remnawave_link")
)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
query_string = request.query_string if not old_subscription else ""
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string)
random.shuffle(combined_subscriptions)
query_string = request.query_string
combined_subscriptions, headers_list = await combine_unique_lines(urls, tg_id or email, query_string)
if RANDOM_SUBSCRIPTIONS:
random.shuffle(combined_subscriptions)
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
@@ -360,20 +256,9 @@ async def handle_subscription(request: web.Request, old_subscription: bool = Fal
subscription_info = f"📄 Подписка: {email} - {time_left}"
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms)
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms, headers_list)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
logger.info(f"Возвращаем объединенные подписки для email: {email}")
return web.Response(text=base64_encoded, headers=headers)
finally:
await conn.close()
async def handle_old_subscription(request: web.Request) -> web.Response:
"""Обработка запроса для старых клиентов."""
return await handle_subscription(request, old_subscription=True)
async def handle_new_subscription(request: web.Request) -> web.Response:
"""Обработка запроса для новых клиентов."""
return await handle_subscription(request, old_subscription=False)
+190 -104
View File
@@ -23,16 +23,20 @@ from config import (
from database import (
add_notification,
check_notification_time,
check_notifications_bulk,
delete_key,
delete_notification,
get_all_keys,
get_balance,
get_last_notification_time,
update_balance,
update_key_expiry,
delete_notification
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
from handlers.notifications.notify_kb import (
build_notification_expired_kb,
build_notification_kb,
)
from handlers.texts import (
KEY_DELETED_MSG,
KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG,
@@ -44,17 +48,15 @@ from handlers.texts import (
KEY_RENEWED,
KEY_RENEWED_TEMP_MSG,
)
from handlers.utils import format_hours, format_minutes
from logger import logger
from .notify_utils import send_notification
from .notify_utils import send_messages_with_limit, send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
notification_lock = asyncio.Lock()
@@ -65,7 +67,7 @@ async def periodic_notifications(bot: Bot):
"""
while True:
if notification_lock.locked():
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
await asyncio.sleep(NOTIFICATION_TIME)
continue
@@ -74,11 +76,10 @@ async def periodic_notifications(bot: Bot):
try:
conn = await asyncpg.connect(DATABASE_URL)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
logger.info("🚀 Запуск обработки уведомлений")
logger.info("Запуск обработки уведомлений")
try:
keys = await get_all_keys(session=conn)
@@ -88,32 +89,53 @@ async def periodic_notifications(bot: Bot):
keys = []
if not TRIAL_TIME_DISABLE:
await notify_inactive_trial_users(bot, conn)
try:
await notify_inactive_trial_users(bot, conn)
except Exception as e:
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
await asyncio.sleep(0.5)
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
try:
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
await asyncio.sleep(1)
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
try:
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
await asyncio.sleep(1)
await handle_expired_keys(bot, conn, current_time, keys)
try:
await handle_expired_keys(bot, conn, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в handle_expired_keys: {e}")
await asyncio.sleep(0.5)
if NOTIFY_INACTIVE_TRAFFIC:
await notify_users_no_traffic(bot, conn, current_time, keys)
try:
await notify_users_no_traffic(bot, conn, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
await asyncio.sleep(0.5)
logger.info("Завершена обработка уведомлений")
logger.info("Завершена обработка уведомлений")
except Exception as e:
logger.error(f"Ошибка в periodic_notifications: {e}")
logger.error(f"Ошибка в periodic_notifications: {e}")
finally:
if conn:
await conn.close()
logger.info("🔌 Соединение с базой данных закрыто.")
logger.info("Соединение с базой данных закрыто.")
await asyncio.sleep(NOTIFICATION_TIME)
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_24h: int, keys: list):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
"""
logger.info("Начало проверки подписок, истекающих через 24 часа.")
expiring_keys = [
@@ -121,24 +143,29 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.get("email", "") for key in expiring_keys]
users = await check_notifications_bulk("key_24h", 24, conn, tg_ids=tg_ids, emails=emails)
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
expiry_timestamp = key.get("expiry_time")
notification_id = f"{email}_key_24h"
try:
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
except Exception as e:
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
continue
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
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_timestamp = key.get("expiry_time")
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
days_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -146,22 +173,44 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_24H.format(
email=email,
days_left_message=days_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
if NOTIFY_RENEW:
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
try:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text
)
except Exception as e:
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else:
keyboard = build_notification_kb(email)
try:
await send_notification(bot, tg_id, "notify_24h.jpg", notification_text, keyboard)
logger.info(f"Отправлено уведомление об истечении подписки через 24 часа для пользователя {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_24h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
})
logger.info("✅ Обработка всех уведомлений за 24 часа завершена.")
if messages:
results = await send_messages_with_limit(bot, messages, conn=conn)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
await asyncio.sleep(1)
@@ -176,24 +225,29 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.get("email", "") for key in expiring_keys]
users = await check_notifications_bulk("key_10h", 10, conn, tg_ids=tg_ids, emails=emails)
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
expiry_timestamp = key.get("expiry_time")
notification_id = f"{email}_key_10h"
try:
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
except Exception as e:
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
continue
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
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_timestamp = key.get("expiry_time")
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -201,7 +255,7 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_10H.format(
email=email,
hours_left_message=hours_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
@@ -212,16 +266,33 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
)
except Exception as e:
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else:
keyboard = build_notification_kb(email)
try:
await send_notification(bot, tg_id, "notify_10h.jpg", notification_text, keyboard)
logger.info(f"Отправлено уведомление об истечении подписки через 10 часов для пользователя {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_10h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
})
logger.info("✅ Обработка всех уведомлений за 10 часов завершена.")
if messages:
results = await send_messages_with_limit(bot, messages, conn=conn)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
await asyncio.sleep(1)
@@ -234,6 +305,12 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
tg_ids = [key["tg_id"] for key in expired_keys]
emails = [key.get("email", "") for key in expired_keys]
users = await check_notifications_bulk("key_expired", 0, conn, tg_ids=tg_ids, emails=emails)
messages = []
for key in expired_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
@@ -241,11 +318,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
server_id = key.get("server_id")
notification_id = f"{email}_key_expired"
try:
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Ошибка получения времени последнего уведомления для пользователя {tg_id}: {e}")
continue
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
if NOTIFY_RENEW_EXPIRED:
try:
@@ -284,22 +357,19 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
keyboard = build_notification_expired_kb()
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
KEY_DELETED_MSG.format(email=email),
keyboard,
)
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
except Exception as e:
logger.error(f"Не удалось отправить уведомление об удалении пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": KEY_DELETED_MSG.format(email=email),
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
except Exception as e:
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
continue
if last_notification_time is None:
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
keyboard = build_notification_kb(email)
if NOTIFY_DELETE_DELAY > 0:
@@ -308,45 +378,51 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
if hours > 0:
if minutes > 0:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix} и {minutes} минут"
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(email=email, time_str=time_str)
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
)
else:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix}"
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(email=email, time_str=time_str)
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
email=email, hours_formatted=format_hours(hours)
)
else:
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(email=email, minutes=NOTIFY_DELETE_DELAY)
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
email=email, minutes_formatted=format_minutes(minutes)
)
else:
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
delay_message,
keyboard,
)
await add_notification(tg_id, notification_id, session=conn)
logger.info(
f"📢 Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}."
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление о продлении подписки пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": delay_message,
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
logger.info("✅ Обработка истекших ключей завершена.")
if messages:
results = await send_messages_with_limit(bot, messages, conn=conn)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
email = msg["email"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекшем ключе для подписки {email} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекшем ключе для подписки {email} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истекших ключах.")
logger.info("Обработка истекших ключей завершена.")
await asyncio.sleep(1)
async def process_auto_renew_or_notify(
bot,
conn,
key: dict,
notification_id: str,
renewal_period_months: int,
standard_photo: str,
standard_caption: str
bot, conn, key: dict, notification_id: str, renewal_period_months: int, standard_photo: str, standard_caption: str
):
"""
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
@@ -385,13 +461,14 @@ async def process_auto_renew_or_notify(
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
total_gb = int(renewal_period_months * TOTAL_GB * 1024**3)
logger.info(
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
f"Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
)
try:
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, TOTAL_GB)
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, total_gb)
await update_balance(tg_id, -renewal_cost, session=conn)
await update_key_expiry(client_id, new_expiry_time, conn)
@@ -407,13 +484,22 @@ async def process_auto_renew_or_notify(
)
keyboard = build_notification_expired_kb()
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
if result:
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
)
except KeyError as e:
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
except Exception as e:
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
else:
keyboard = build_notification_kb(email)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
if result:
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
else:
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
+70 -18
View File
@@ -2,14 +2,72 @@ import asyncio
import os
import aiofiles
import asyncpg
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from database import create_blocked_user
from logger import logger
async def send_messages_with_limit(
bot: Bot,
messages: list[dict],
conn: asyncpg.Connection = None,
source_file: str = None,
messages_per_second: int = 25,
):
"""
Отправляет сообщения с ограничением по количеству сообщений в секунду.
Возвращает список результатов отправки (True для успеха, False для ошибки).
"""
batch_size = messages_per_second
results = []
for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size]
tasks = []
for msg in batch:
tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")))
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
processed_results = []
for msg, result in zip(batch, batch_results, strict=False):
tg_id = msg["tg_id"]
if isinstance(result, bool) and result:
processed_results.append(True)
elif isinstance(result, TelegramForbiddenError):
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
if source_file == "special_notifications" and conn:
try:
await create_blocked_user(tg_id, conn)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception:
pass
processed_results.append(False)
elif isinstance(result, TelegramBadRequest) and "chat not found" in str(result).lower():
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
if source_file == "special_notifications" and conn:
try:
await create_blocked_user(tg_id, conn)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception:
pass
processed_results.append(False)
else:
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id}.")
if source_file == "special_notifications" and conn:
try:
await create_blocked_user(tg_id, conn)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception:
pass
processed_results.append(False)
results.extend(processed_results)
await asyncio.sleep(1.0)
return results
def rate_limited_send(func):
async def wrapper(*args, **kwargs):
while True:
@@ -21,7 +79,11 @@ def rate_limited_send(func):
await asyncio.sleep(retry_in)
except TelegramForbiddenError:
tg_id = kwargs.get("tg_id") or args[1]
logger.warning(f"Пользователь {tg_id} заблокировал бота.")
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
return False
except TelegramBadRequest:
tg_id = kwargs.get("tg_id") or args[1]
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
return False
except Exception as e:
tg_id = kwargs.get("tg_id") or args[1]
@@ -34,25 +96,17 @@ def rate_limited_send(func):
async def send_notification(
bot: Bot,
tg_id: int,
image_filename: str,
image_filename: str | None,
caption: str,
keyboard: InlineKeyboardMarkup | None = None,
) -> bool:
"""
Отправляет уведомление пользователю.
Args:
bot: Экземпляр бота для отправки сообщений
tg_id: Telegram ID пользователя
image_filename: Имя файла изображения в директории img
caption: Текст сообщения
keyboard: Клавиатура для сообщения (опционально)
Returns:
bool: True если сообщение успешно отправлено, False в случае ошибки
"""
photo_path = os.path.join("img", image_filename)
if image_filename is None:
return await _send_text_notification(bot, tg_id, caption, keyboard)
photo_path = os.path.join("img", image_filename)
if os.path.isfile(photo_path):
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
else:
@@ -76,8 +130,7 @@ async def _send_photo_notification(
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
return True
except TelegramForbiddenError:
logger.error(f"Пользователь {tg_id} заблокировал бота")
except (TelegramForbiddenError, TelegramBadRequest):
return False
except Exception as e:
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
@@ -95,8 +148,7 @@ async def _send_text_notification(
try:
await bot.send_message(tg_id, caption, reply_markup=keyboard)
return True
except TelegramForbiddenError:
logger.error(f"Пользователь {tg_id} заблокировал бота")
except (TelegramForbiddenError, TelegramBadRequest):
return False
except Exception as e:
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
+86 -100
View File
@@ -6,23 +6,30 @@ import asyncpg
import pytz
from aiogram import Bot, Router, types
from aiogram.exceptions import TelegramForbiddenError
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
from database import (
add_notification,
check_notification_time,
create_blocked_user,
from config import (
NOTIFY_EXTRA_DAYS,
NOTIFY_INACTIVE,
NOTIFY_INACTIVE_TRAFFIC,
SUPPORT_CHAT_URL,
TRIAL_TIME,
)
from database import add_notification, check_notifications_bulk, create_blocked_user
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import get_user_traffic
from handlers.texts import TRIAL_INACTIVE_BONUS_MSG, TRIAL_INACTIVE_FIRST_MSG, ZERO_TRAFFIC_MSG
from handlers.texts import (
TRIAL_INACTIVE_BONUS_MSG,
TRIAL_INACTIVE_FIRST_MSG,
ZERO_TRAFFIC_MSG,
)
from handlers.utils import format_days
from logger import logger
from .notify_utils import send_messages_with_limit, send_notification
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@@ -33,80 +40,57 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
"""
logger.info("Проверка пользователей, не активировавших пробный период...")
inactive_trial_users = await conn.fetch(
"""
SELECT tg_id, username, first_name, last_name FROM users
WHERE tg_id IN (
SELECT tg_id FROM connections
WHERE trial IN (0, -1)
)
AND tg_id NOT IN (
SELECT tg_id FROM blocked_users
)
AND tg_id NOT IN (
SELECT DISTINCT tg_id FROM keys
)
"""
)
logger.info(f"👥 Найдено {len(inactive_trial_users)} неактивных пользователей.")
for user in inactive_trial_users:
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
messages = []
for user in users:
tg_id = user["tg_id"]
username = user["username"]
first_name = user["first_name"]
last_name = user["last_name"]
display_name = username or first_name or last_name or "Пользователь"
try:
can_notify = await check_notification_time(tg_id, "inactive_trial", hours=NOTIFY_INACTIVE, session=conn)
if can_notify:
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
)
)
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
trial_extended = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM notifications
WHERE tg_id = $1 AND notification_type = 'inactive_trial'
)
""",
tg_id,
)
if trial_extended:
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
message = TRIAL_INACTIVE_BONUS_MSG.format(
display_name=display_name, NOTIFY_EXTRA_DAYS=NOTIFY_EXTRA_DAYS, total_days=total_days
)
await conn.execute("UPDATE connections SET trial = -1 WHERE tg_id = $1", tg_id)
else:
message = TRIAL_INACTIVE_FIRST_MSG.format(display_name=display_name, TRIAL_TIME=TRIAL_TIME)
try:
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
await add_notification(tg_id, "inactive_trial", session=conn)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
await create_blocked_user(tg_id, conn)
except Exception as e:
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
except Exception as e:
logger.error(f"⚠ Ошибка при обработке пользователя {tg_id}: {e}")
await asyncio.sleep(1)
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
)
)
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
trial_extended = user["last_notification_time"] is not None
if trial_extended:
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
message = TRIAL_INACTIVE_BONUS_MSG.format(
display_name=display_name,
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
total_days_formatted=format_days(total_days),
)
await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
else:
message = TRIAL_INACTIVE_FIRST_MSG.format(
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
)
messages.append({
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"notification_id": "inactive_trial",
})
if messages:
results = await send_messages_with_limit(
bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
else:
logger.warning(f"📩 Не удалось отправить уведомление неактивному пользователю {tg_id}.")
logger.info(f"Отправлено {sent_count} уведомлений неактивным пользователям.")
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
@@ -117,8 +101,8 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
но исключает пользователей, у которых подписка недавно продлилась.
"""
logger.info("Проверка пользователей с нулевым трафиком...")
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
messages = []
for key in keys:
tg_id = key.get("tg_id")
@@ -142,7 +126,6 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
renewal_threshold = expiry_dt - timedelta(days=30)
renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
if renewal_recent:
continue
@@ -162,36 +145,39 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
total_traffic = sum(
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
)
logger.info(f"Ключ для {email}: общий трафик: {total_traffic} ГБ")
try:
await conn.execute("UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id)
except Exception as e:
logger.error(f"Ошибка обновления notified для пользователя {tg_id} (client_id: {client_id}): {e}")
continue
if total_traffic == 0:
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
message = ZERO_TRAFFIC_MSG.format(email=email)
messages.append({
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"client_id": client_id,
})
try:
await bot.send_message(tg_id, message, reply_markup=keyboard)
if messages:
results = await send_messages_with_limit(
bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
sent_count += 1
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await create_blocked_user(tg_id, conn)
except Exception as e:
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
else:
try:
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
)
logger.info(f"Ключ для {email} имеет трафик. Обновлено notified = TRUE.")
except Exception as e:
logger.error(f"Ошибка обновления notified для пользователя {tg_id}: {e}")
else:
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
+77 -2
View File
@@ -1,3 +1,7 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -10,8 +14,19 @@ from config import (
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
)
from handlers.buttons import CRYPTOBOT, MAIN_MENU, ROBOKASSA, STARS, YOOKASSA, YOOMONEY
from handlers.texts import PAYMENT_METHODS_MSG
from database import get_last_payments
from handlers.buttons import (
BALANCE_HISTORY,
COUPON,
CRYPTOBOT,
MAIN_MENU,
PAYMENT,
ROBOKASSA,
STARS,
YOOKASSA,
YOOMONEY,
)
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
from .utils import edit_or_send_message
@@ -69,3 +84,63 @@ async def handle_pay(callback_query: CallbackQuery):
media_path=None,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "balance")
async def balance_handler(callback_query: CallbackQuery, session: Any):
result = await session.fetchrow(
"SELECT balance FROM users WHERE tg_id = $1",
callback_query.from_user.id,
)
balance = result["balance"] if result else 0.0
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", "pic.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(callback_query.from_user.id, session)
if records:
history_text = BALANCE_HISTORY_HEADER
for record in records:
amount = record["amount"]
payment_system = record["payment_system"]
status = record["status"]
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
history_text += (
f"<b>Сумма:</b> {amount}\n"
f"<b>Способ оплаты:</b> {payment_system}\n"
f"<b>Статус:</b> {status}\n"
f"<b>Дата:</b> {date}\n\n"
)
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,
)
+15 -5
View File
@@ -19,9 +19,9 @@ from config import (
from robokassa import HashAlgorithm, Robokassa
from database import (
add_connection,
add_user,
check_user_exists,
add_payment,
check_connection_exists,
get_key_count,
get_temporary_data,
update_balance,
@@ -96,10 +96,20 @@ async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, st
key_count = await get_key_count(tg_id)
if key_count == 0:
exists = await check_connection_exists(tg_id)
exists = await check_user_exists(tg_id)
if not exists:
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"Created new connection for user {tg_id} with balance 0.0.")
from_user = callback_query.from_user
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
await callback_query.message.delete()
+4 -269
View File
@@ -1,63 +1,41 @@
import html
from io import BytesIO
import os
import asyncpg
import qrcode
from typing import Any
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
ADMIN_ID,
DATABASE_URL,
GIFT_BUTTON,
INLINE_MODE,
INSTRUCTIONS_BUTTON,
NEWS_MESSAGE,
REFERRAL_BUTTON,
REFERRAL_OFFERS,
RENEWAL_PLANS,
SHOW_START_MENU_ONCE,
TOP_REFERRAL_BUTTON,
TRIAL_TIME,
USERNAME_BOT,
)
from database import get_balance, get_key_count, get_last_payments, get_referral_stats, get_trial
from database import get_balance, get_key_count, get_trial
from handlers.buttons import (
ABOUT_VPN,
ADD_SUB,
BACK,
BALANCE,
BALANCE_HISTORY,
COUPON,
GIFTS,
INSTRUCTIONS,
INVITE,
MAIN_MENU,
MY_SUBS,
PAYMENT,
QR,
TOP_FIVE,
TRIAL_SUB
TRIAL_SUB,
)
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, INVITE_TEXT_NON_INLINE, TOP_REFERRALS_TEXT
from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .texts import get_referral_link, invite_message_send, profile_message_send
from .texts import profile_message_send
from .utils import edit_or_send_message
@@ -132,7 +110,7 @@ async def process_callback_view_profile(
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
if admin:
builder.row(
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
if SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
@@ -149,246 +127,3 @@ async def process_callback_view_profile(
)
finally:
await conn.close()
@router.callback_query(F.data == "balance")
async def balance_handler(callback_query: CallbackQuery, session: Any):
result = await session.fetchrow(
"SELECT balance FROM connections WHERE tg_id = $1",
callback_query.from_user.id,
)
balance = result["balance"] if result else 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)
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
@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(callback_query.from_user.id, session)
if records:
history_text = BALANCE_HISTORY_HEADER
for record in records:
amount = record["amount"]
payment_system = record["payment_system"]
status = record["status"]
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
history_text += (
f"<b>Сумма:</b> {amount}\n"
f"<b>Способ оплаты:</b> {payment_system}\n"
f"<b>Статус:</b> {status}\n"
f"<b>Дата:</b> {date}\n\n"
)
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.message(F.text == "/tariffs")
@router.callback_query(F.data == "view_tariffs")
async def view_tariffs_handler(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
image_path = os.path.join("img", "tariffs.jpg")
tariffs_message = "<b>🚀 Доступные тарифы VPN:</b>\n\n" + "\n".join([
f"{months} {'месяц' if months == '1' else 'месяца' if int(months) in [2, 3, 4] else 'месяцев'}: "
f"{RENEWAL_PLANS[months]['price']} "
f"{'💳' if months == '1' else '🌟' if months == '3' else '🔥' if months == '6' else '🚀'} рублей"
for months in sorted(RENEWAL_PLANS.keys(), key=int)
])
await edit_or_send_message(
target_message=callback_query.message,
text=tariffs_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "invite")
@router.message(F.text == "/invite")
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
chat_id = None
if isinstance(callback_query_or_message, CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
target_message = callback_query_or_message.message
else:
chat_id = callback_query_or_message.chat.id
target_message = callback_query_or_message
referral_link = get_referral_link(chat_id)
referral_stats = await get_referral_stats(chat_id)
invite_message = invite_message_send(referral_link, referral_stats)
image_path = os.path.join("img", "pic_invite.jpg")
builder = InlineKeyboardBuilder()
if INLINE_MODE:
builder.button(text=INVITE, switch_inline_query="invite")
else:
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
builder.button(text=INVITE, switch_inline_query=invite_text)
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
if TOP_REFERRAL_BUTTON:
builder.button(text=TOP_FIVE, callback_data="top_referrals")
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1)
await edit_or_send_message(
target_message=target_message,
text=invite_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
async def inline_referral_handler(inline_query: InlineQuery):
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
results: list[InlineQueryResultArticle] = []
for index, offer in enumerate(REFERRAL_OFFERS):
description = offer["description"][:64]
message_text = offer["message"].format(trial_time=TRIAL_TIME)[:4096]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
results.append(
InlineQueryResultArticle(
id=str(index),
title=offer["title"],
description=description,
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
)
await inline_query.answer(results=results, cache_time=86400, is_personal=True)
@router.callback_query(F.data.startswith("show_referral_qr|"))
async def show_referral_qr(callback_query: CallbackQuery):
try:
chat_id = callback_query.data.split("|")[1]
referral_link = get_referral_link(chat_id)
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(referral_link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="📷 <b>Ваш QR-код для реферальной ссылки.</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
@router.callback_query(F.data == "top_referrals")
async def top_referrals_handler(callback_query: CallbackQuery):
conn = await asyncpg.connect(DATABASE_URL)
try:
user_referral_count = await conn.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1",
callback_query.from_user.id
) or 0
personal_block = "Твоё место в рейтинге:\n"
if user_referral_count > 0:
user_position = await conn.fetchval(
"""
SELECT COUNT(*) + 1 FROM (
SELECT COUNT(*) as cnt
FROM referrals
GROUP BY referrer_tg_id
HAVING COUNT(*) > $1
) AS better_users
""",
user_referral_count
)
personal_block += f"{user_position}. {callback_query.from_user.id} - {user_referral_count} чел."
else:
personal_block += "Ты еще не приглашал пользователей в проект."
top_referrals = await conn.fetch(
"""
SELECT referrer_tg_id, COUNT(*) as referral_count
FROM referrals
GROUP BY referrer_tg_id
ORDER BY referral_count DESC
LIMIT 5
"""
)
is_admin = callback_query.from_user.id in ADMIN_ID
rows = ""
for i, row in enumerate(top_referrals, 1):
tg_id = str(row["referrer_tg_id"])
count = row["referral_count"]
display_id = tg_id if is_admin else f"{tg_id[:5]}*****"
rows += f"{i}. {display_id} - {count} чел.\n"
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
finally:
await conn.close()
+267
View File
@@ -0,0 +1,267 @@
import os
from io import BytesIO
from typing import Any
import asyncpg
import qrcode
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
ADMIN_ID,
DATABASE_URL,
INLINE_MODE,
TOP_REFERRAL_BUTTON,
TRIAL_TIME,
USERNAME_BOT,
)
from database import add_referral, add_user, check_user_exists, get_referral_by_referred_id, get_referral_stats
from handlers.buttons import (
BACK,
INVITE,
MAIN_MENU,
QR,
TOP_FIVE,
)
from handlers.texts import (
INVITE_TEXT_NON_INLINE,
NEW_REFERRAL_NOTIFICATION,
REFERRAL_OFFERS,
REFERRAL_SUCCESS_MSG,
TOP_REFERRALS_TEXT,
)
from logger import logger
from .texts import get_referral_link, invite_message_send
from .utils import edit_or_send_message, format_days
router = Router()
@router.callback_query(F.data == "invite")
@router.message(F.text == "/invite")
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
chat_id = None
if isinstance(callback_query_or_message, CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
target_message = callback_query_or_message.message
else:
chat_id = callback_query_or_message.chat.id
target_message = callback_query_or_message
referral_link = get_referral_link(chat_id)
referral_stats = await get_referral_stats(chat_id)
invite_message = invite_message_send(referral_link, referral_stats)
image_path = os.path.join("img", "pic_invite.jpg")
builder = InlineKeyboardBuilder()
if INLINE_MODE:
builder.button(text=INVITE, switch_inline_query="invite")
else:
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
builder.button(text=INVITE, switch_inline_query=invite_text)
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
if TOP_REFERRAL_BUTTON:
builder.button(text=TOP_FIVE, callback_data="top_referrals")
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1)
await edit_or_send_message(
target_message=target_message,
text=invite_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
async def inline_referral_handler(inline_query: InlineQuery):
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
trial_time_formatted = format_days(TRIAL_TIME)
results: list[InlineQueryResultArticle] = []
for index, offer in enumerate(REFERRAL_OFFERS):
description = offer["description"][:64]
message_text = offer["message"].format(trial_time=TRIAL_TIME, trial_time_formatted=trial_time_formatted)[:4096]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
results.append(
InlineQueryResultArticle(
id=str(index),
title=offer["title"],
description=description,
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
)
await inline_query.answer(results=results, cache_time=86400, is_personal=True)
@router.callback_query(F.data.startswith("show_referral_qr|"))
async def show_referral_qr(callback_query: CallbackQuery):
try:
chat_id = callback_query.data.split("|")[1]
referral_link = get_referral_link(chat_id)
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(referral_link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="📷 <b>Ваш QR-код для реферальной ссылки.</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
@router.callback_query(F.data == "top_referrals")
async def top_referrals_handler(callback_query: CallbackQuery):
conn = await asyncpg.connect(DATABASE_URL)
try:
user_referral_count = (
await conn.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", callback_query.from_user.id)
or 0
)
personal_block = "Твоё место в рейтинге:\n"
if user_referral_count > 0:
user_position = await conn.fetchval(
"""
SELECT COUNT(*) + 1 FROM (
SELECT COUNT(*) as cnt
FROM referrals
GROUP BY referrer_tg_id
HAVING COUNT(*) > $1
) AS better_users
""",
user_referral_count,
)
personal_block += f"{user_position}. {callback_query.from_user.id} - {user_referral_count} чел."
else:
personal_block += "Ты еще не приглашал пользователей в проект."
top_referrals = await conn.fetch(
"""
SELECT referrer_tg_id, COUNT(*) as referral_count
FROM referrals
GROUP BY referrer_tg_id
ORDER BY referral_count DESC
LIMIT 5
"""
)
is_admin = callback_query.from_user.id in ADMIN_ID
rows = ""
for i, row in enumerate(top_referrals, 1):
tg_id = str(row["referrer_tg_id"])
count = row["referral_count"]
display_id = tg_id if is_admin else f"{tg_id[:5]}*****"
rows += f"{i}. {display_id} - {count} чел.\n"
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
finally:
await conn.close()
async def handle_referral_link(
referral_code: str,
message: Message,
state: FSMContext,
session: Any,
user_data: dict | None = None,
):
try:
referrer_tg_id = int(referral_code)
user = user_data or message.from_user or message.chat
user_id = user["tg_id"] if isinstance(user, dict) else user.id
if referrer_tg_id == user_id:
await message.answer("❌ Вы не можете быть реферальной ссылкой самого себя.")
return
existing_referral = await get_referral_by_referred_id(user_id, session)
if existing_referral:
await message.answer("❌ Вы уже использовали реферальную ссылку.")
return
user_exists = await check_user_exists(user_id)
if not user_exists:
if isinstance(user, dict):
await add_user(session=session, **user)
else:
await add_user(
tg_id=user.id,
username=getattr(user, "username", None),
first_name=getattr(user, "first_name", None),
last_name=getattr(user, "last_name", None),
language_code=getattr(user, "language_code", None),
is_bot=getattr(user, "is_bot", False),
session=session,
)
await add_referral(user_id, referrer_tg_id, session)
try:
await bot.send_message(
referrer_tg_id,
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
except Exception as e:
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}")
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
+198 -177
View File
@@ -24,24 +24,17 @@ from config import (
SUPPORT_CHAT_URL,
)
from database import (
add_connection,
add_referral,
check_connection_exists,
get_referral_by_referred_id,
add_user,
check_user_exists,
get_trial,
update_balance,
)
from handlers.admin.coupons.coupons_handler import handle_coupon_activation
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT, TRIAL_SUB
from handlers.captcha import generate_captcha
from handlers.keys.key_management import create_key
from handlers.coupons import activate_coupon
from handlers.payments.gift import handle_gift_link
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPON_SUCCESS_MSG,
GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG,
NEW_REFERRAL_NOTIFICATION,
NOT_SUBSCRIBED_YET_MSG,
REFERRAL_SUCCESS_MSG,
SUBSCRIPTION_CHECK_ERROR_MSG,
SUBSCRIPTION_CONFIRMED_MSG,
SUBSCRIPTION_REQUIRED_MSG,
@@ -51,6 +44,7 @@ from handlers.texts import (
from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .refferal import handle_referral_link
from .utils import edit_or_send_message
@@ -66,169 +60,24 @@ async def handle_start_callback_query(
@router.message(Command("start"))
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True):
"""Обрабатывает команду /start, включая логику проверки подписки, рефералов и подарков."""
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
if CAPTCHA_ENABLE and captcha:
captcha_data = await generate_captcha(message, state)
await edit_or_send_message(
target_message=message,
text=captcha_data["text"],
reply_markup=captcha_data["markup"],
)
return
user_exists = await check_user_exists(message.chat.id)
if not user_exists:
captcha_data = await generate_captcha(message, state)
await edit_or_send_message(
target_message=message,
text=captcha_data["text"],
reply_markup=captcha_data["markup"],
)
return
state_data = await state.get_data()
text_to_process = state_data.get("original_text", message.text)
if CHANNEL_EXISTS and CHANNEL_REQUIRED:
try:
member = await bot.get_chat_member(CHANNEL_ID, message.chat.id)
if member.status not in ["member", "administrator", "creator"]:
await state.update_data(original_text=text_to_process)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
else:
logger.info(
f"Пользователь {message.chat.id} подписан на канал (статус: {member.status}). Продолжаем работу."
)
except Exception as e:
logger.error(f"Ошибка проверки подписки пользователя {message.chat.id}: {e}")
await state.update_data(start_text=text_to_process)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
await process_start_logic(message, state, session, admin, text_to_process)
async def process_start_logic(
message: Message, state: FSMContext, session: Any, admin: bool, text_to_process: str = None
):
text = text_to_process if text_to_process is not None else message.text
if text:
try:
if "coupons_" in text:
logger.info(f"Обнаружена ссылка на купон: {text}")
user_id = message.chat.id
await handle_coupon_activation(message, state, session, admin, text=text, user_id=user_id)
return
if "gift_" in text:
parts = text.split("gift_")[1].split("_")
if len(parts) < 2:
await message.answer("❌ Неверный формат ссылки на подарок.")
return await process_callback_view_profile(message, state, admin)
gift_id = parts[0]
async with session.transaction():
gift_info = await session.fetchrow(
"""
SELECT sender_tg_id, selected_months, expiry_time, is_used, recipient_tg_id
FROM gifts
WHERE gift_id = $1
FOR UPDATE
""",
gift_id,
)
if not gift_info:
await message.answer(GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG)
return await process_callback_view_profile(message, state, admin)
if gift_info["is_used"]:
await message.answer("Этот подарок уже был использован.")
return await process_callback_view_profile(message, state, admin)
if gift_info["sender_tg_id"] == message.chat.id:
await message.answer("❌ Вы не можете получить подарок от самого себя.")
return await process_callback_view_profile(message, state, admin)
if gift_info["recipient_tg_id"]:
await message.answer("❌ Этот подарок уже был активирован другим пользователем.")
return await process_callback_view_profile(message, state, admin)
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
if not existing_referral:
await add_referral(message.chat.id, gift_info["sender_tg_id"], session)
connection_exists = await check_connection_exists(message.chat.id)
if not connection_exists:
await add_connection(tg_id=message.chat.id, session=session)
await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", message.chat.id)
await create_key(
message.chat.id,
gift_info["expiry_time"].replace(tzinfo=None),
state,
session,
message,
)
await session.execute(
"UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1 WHERE gift_id = $2",
message.chat.id,
gift_id,
)
await message.answer(
f"🎉 Ваш подарок на {gift_info['selected_months']} "
f"{'месяц' if gift_info['selected_months'] == 1 else 'месяца' if gift_info['selected_months'] in [2, 3, 4] else 'месяцев'} активирован!"
)
return
if "referral_" in text:
try:
referrer_tg_id = int(text.split("referral_")[1])
connection_exists_now = await check_connection_exists(message.chat.id)
if connection_exists_now:
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
return await process_callback_view_profile(message, state, admin)
if referrer_tg_id == message.chat.id:
await message.answer("❌ Вы не можете быть рефералом самого себя.")
return await process_callback_view_profile(message, state, admin)
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
if existing_referral:
return await process_callback_view_profile(message, state, admin)
await add_referral(message.chat.id, referrer_tg_id, session)
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
try:
await bot.send_message(
referrer_tg_id,
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id),
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
return await process_callback_view_profile(message, state, admin)
except (ValueError, IndexError):
pass
logger.info("Пользователь зашел без реферальной ссылки, подарка или купона.")
except Exception as e:
logger.error(f"Ошибка при обработке текста {message.text}{e}", exc_info=True)
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
final_exists = await check_connection_exists(message.chat.id)
if final_exists:
if SHOW_START_MENU_ONCE:
return await process_callback_view_profile(message, state, admin)
else:
return await show_start_menu(message, admin, session)
else:
await add_connection(tg_id=message.chat.id, session=session)
return await show_start_menu(message, admin, session)
@router.callback_query(F.data == "check_subscription")
async def check_subscription_callback(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool):
user_id = callback_query.from_user.id
@@ -248,16 +97,180 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
else:
await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG)
data = await state.get_data()
original_text = data.get("original_text")
if not original_text:
original_text = callback_query.message.text
await process_start_logic(callback_query.message, state, session, admin, text_to_process=original_text)
original_text = data.get("original_text") or callback_query.message.text
user_data = data.get("user_data")
await process_start_logic(
message=callback_query.message,
state=state,
session=session,
admin=admin,
text_to_process=original_text,
user_data=user_data
)
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
except Exception as e:
logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True)
await callback_query.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
async def process_start_logic(
message: Message,
state: FSMContext,
session: Any,
admin: bool,
text_to_process: str = None,
user_data: dict | None = None,
):
text = text_to_process or message.text or message.caption
user_data = user_data or {
"tg_id": (message.from_user or message.chat).id,
"username": getattr(message.from_user, "username", None),
"first_name": getattr(message.from_user, "first_name", None),
"last_name": getattr(message.from_user, "last_name", None),
"language_code": getattr(message.from_user, "language_code", None),
"is_bot": getattr(message.from_user, "is_bot", False),
}
async def process_start_logic(
message: Message,
state: FSMContext,
session: Any,
admin: bool,
text_to_process: str = None,
user_data: dict | None = None,
):
from config import CHANNEL_EXISTS, CHANNEL_REQUIRED, CHANNEL_ID, CHANNEL_URL
from handlers.texts import SUBSCRIPTION_REQUIRED_MSG
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardButton
user_data = user_data or {
"tg_id": (message.from_user or message.chat).id,
"username": getattr(message.from_user, "username", None),
"first_name": getattr(message.from_user, "first_name", None),
"last_name": getattr(message.from_user, "last_name", None),
"language_code": getattr(message.from_user, "language_code", None),
"is_bot": getattr(message.from_user, "is_bot", False),
}
text = text_to_process or message.text or message.caption
if CHANNEL_EXISTS and CHANNEL_REQUIRED:
try:
member = await bot.get_chat_member(CHANNEL_ID, user_data["tg_id"])
if member.status not in ["member", "administrator", "creator"]:
await state.update_data(original_text=text, user_data=user_data)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
except Exception as e:
logger.error(f"Ошибка проверки подписки для {user_data['tg_id']}: {e}")
await state.update_data(original_text=text, user_data=user_data)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
if not text:
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}")
await show_start_menu(message, admin, session)
return
if text.startswith("/start "):
parts = text.split(maxsplit=1)
if len(parts) > 1:
text = parts[1]
try:
gift_detected = False
text_parts = text.split("-")
for part in text_parts:
if "coupons" in part:
logger.info(f"Обнаружена ссылка на купон: {part}")
coupon_code = part.split("coupons")[1].strip("_")
await activate_coupon(message, state, session, coupon_code, admin=admin, user_data=user_data)
continue
if "gift" in part:
gift_raw = part.split("gift")[1].strip("_")
parts = gift_raw.split("_")
if len(parts) < 2:
await message.answer("❌ Неверный формат ссылки на подарок.")
return await process_callback_view_profile(message, state, admin)
gift_id = parts[0]
sender_id = parts[1]
logger.info(f"[GIFT] Обнаружен подарок {gift_id} от {sender_id}")
await handle_gift_link(gift_id, message, state, session, user_data=user_data)
gift_detected = True
break
if "referral" in part:
referrer_tg_id = part.split("referral")[1].strip("_")
try:
referrer_tg_id = int(referrer_tg_id)
await handle_referral_link(referrer_tg_id, message, state, session, user_data=user_data)
except (ValueError, IndexError):
pass
continue
if "utm" in part:
utm_code = part
logger.info(f"[UTM] Обнаружена ссылка на UTM: {utm_code}")
await handle_utm_link(utm_code, message, state, session, user_data=user_data)
continue
await state.clear()
if gift_detected:
return
user_exists = await check_user_exists(user_data["tg_id"])
if not user_exists:
await add_user(session=session, **user_data)
trial_status = await get_trial(user_data["tg_id"], session)
if SHOW_START_MENU_ONCE:
if trial_status > 0:
await process_callback_view_profile(message, state, admin)
else:
await show_start_menu(message, admin, session)
else:
await show_start_menu(message, admin, session)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при обработке текста {text}{e}", exc_info=True)
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
async def handle_utm_link(utm_code, message, state, session, user_data: dict):
user_id = user_data["tg_id"]
exists = await session.fetchval("SELECT 1 FROM tracking_sources WHERE code = $1", utm_code)
if exists:
current_code = await session.fetchval("SELECT source_code FROM users WHERE tg_id = $1", user_id)
if current_code is None:
user_exists = await check_user_exists(user_id)
if not user_exists:
await add_user(session=session, source_code=utm_code, **user_data)
else:
await session.execute("UPDATE users SET source_code = $1 WHERE tg_id = $2", utm_code, user_id)
logger.info(f"[UTM] Привязана {utm_code} к пользователю {user_id}")
else:
await message.answer("❌ UTM ссылка не найдена.")
async def show_start_menu(message: Message, admin: bool, session: Any):
"""Функция для отображения стандартного меню через редактирование сообщения.
Если редактирование не удалось, отправляем новое сообщение."""
@@ -266,15 +279,16 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
image_path = os.path.join("img", "pic.jpg")
builder = InlineKeyboardBuilder()
trial_status = None
if session is not None:
trial_status = await get_trial(message.chat.id, session)
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
if trial_status == 0:
builder.row(InlineKeyboardButton(text="🎁 Пробная подписка", callback_data="create_key"))
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
else:
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
if not SHOW_START_MENU_ONCE:
if trial_status != 0 or not SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if CHANNEL_EXISTS:
@@ -287,7 +301,7 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
if admin:
builder.row(
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
@@ -301,7 +315,12 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
@router.callback_query(F.data == "about_vpn")
async def handle_about_vpn(callback_query: CallbackQuery):
async def handle_about_vpn(callback_query: CallbackQuery, session: Any):
user_id = callback_query.from_user.id
trial = await get_trial(user_id, session)
back_target = "profile" if SHOW_START_MENU_ONCE and trial > 0 else "start"
builder = InlineKeyboardBuilder()
if DONATIONS_ENABLE:
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
@@ -312,13 +331,15 @@ async def handle_about_vpn(callback_query: CallbackQuery):
else:
builder.row(support_btn)
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=back_target))
text = get_about_vpn("3.2.3-minor")
image_path = os.path.join("img", "pic.jpg")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
media_path=image_path,
force_text=False,
)
)
+167 -113
View File
@@ -4,6 +4,8 @@ import re
import secrets
import string
from datetime import datetime
import aiofiles
import aiohttp
import asyncpg
@@ -11,96 +13,103 @@ import asyncpg
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
from bot import bot
from config import DATABASE_URL
from config import ADMIN_ID, DATABASE_URL
from database import get_all_keys, get_servers
from logger import logger
async def get_usd_rate():
try:
async with aiohttp.ClientSession() as session:
async with session.get("https://www.cbr-xml-daily.ru/daily_json.js") as response:
if response.status == 200:
data = await response.text()
usd = float(json.loads(data)["Valute"]["USD"]["Value"])
else:
usd = float(100)
except Exception as e:
logger.exception(f"Error fetching USD rate: {e}")
usd = float(100)
return usd
def sanitize_key_name(key_name: str) -> str:
"""
Очищает название ключа, оставляя только допустимые символы.
Args:
key_name (str): Исходное название ключа.
Returns:
str: Очищенное название ключа в нижнем регистре.
"""
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
def generate_random_email(length: int = 6) -> str:
def generate_random_email(length: int = 8) -> str:
"""
Генерирует случайный email с заданной длиной.
Args:
length (int, optional): Длина случайной строки. По умолчанию 6.
Returns:
str: Сгенерированная случайная строка.
"""
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
async def get_least_loaded_cluster() -> str:
"""
Определяет кластер с наименьшей загрузкой.
Returns:
str: Идентификатор наименее загруженного кластера.
Возвращает кластер с наименьшей загрузкой, где есть хотя бы один сервер с доступным лимитом.
"""
servers = await get_servers()
server_to_cluster = {}
cluster_loads = dict.fromkeys(servers.keys(), 0)
cluster_loads = {}
for cluster_name, cluster_servers in servers.items():
cluster_loads[cluster_name] = 0
for server in cluster_servers:
server_to_cluster[server["server_name"]] = cluster_name
logger.info(f"Сопоставление серверов и кластеров: {server_to_cluster}")
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
keys = await get_all_keys(conn)
for key in keys:
server_id = key["server_id"]
cluster_id = server_to_cluster.get(server_id, server_id)
if cluster_id in cluster_loads:
cluster_loads[cluster_id] += 1
else:
logger.warning(f"⚠️ Сервер {server_id} не найден в известных кластерах!")
logger.info(f"Загруженность кластеров после запроса к БД: {cluster_loads}")
if not cluster_loads:
logger.warning("⚠️ В базе данных или конфигурации нет кластеров!")
available_clusters = {}
for cluster_name, cluster_servers in servers.items():
for server in cluster_servers:
if server.get("enabled", True) and await check_server_key_limit(server, conn):
available_clusters[cluster_name] = cluster_loads[cluster_name]
break
if not available_clusters:
logger.warning("❌ Нет доступных кластеров с лимитом ключей!")
return "cluster1"
least_loaded_cluster = min(cluster_loads, key=lambda k: (cluster_loads[k], k))
logger.info(f"✅ Выбран наименее загруженный кластер: {least_loaded_cluster}")
least_loaded_cluster = min(available_clusters, key=lambda k: (available_clusters[k], k))
logger.info(f"✅ Выбран наименее загруженный кластер с лимитом: {least_loaded_cluster}")
return least_loaded_cluster
async def check_server_key_limit(server_info: dict, conn) -> bool:
"""
Универсальная проверка лимита ключей для сервера в режимах кластеров и стран.
"""
server_name = server_info.get("server_name")
cluster_name = server_info.get("cluster_name")
max_keys = server_info.get("max_keys")
if not max_keys:
return True
identifier = cluster_name if cluster_name else server_name
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", identifier)
if total_keys >= max_keys:
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}")
return False
usage_percent = total_keys / max_keys
if usage_percent >= 0.9:
notif_key = f"server_warn_{server_name}"
already_sent = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = 0 AND notification_type = $1)", notif_key
)
if not already_sent:
for admin_id in ADMIN_ID:
try:
await bot.send_message(
admin_id,
f"⚠️ Сервер <b>{server_name}</b> почти заполнен ({int(usage_percent * 100)}%)."
f"\nРекомендуется создать новый для балансировки.",
)
except Exception:
pass
await conn.execute(
"INSERT INTO notifications (tg_id, notification_type) VALUES (0, $1) ON CONFLICT DO NOTHING",
notif_key,
)
return True
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
"""
Обрабатывает ошибку, отправляя сообщение пользователю.
Args:
tg_id (int): Идентификатор пользователя в Telegram.
callback_query (Optional[object], optional): Объект запроса обратного вызова. По умолчанию None.
message (str, optional): Текст сообщения об ошибке. По умолчанию пустая строка.
"""
try:
if callback_query and hasattr(callback_query, "message"):
@@ -115,64 +124,58 @@ async def handle_error(tg_id: int, callback_query: object | None = None, message
logger.error(f"Ошибка при обработке ошибки: {e}")
def format_time_until_deletion(seconds: int) -> str:
if seconds <= 0:
return "0 минут"
days = seconds // (3600 * 24)
hours = (seconds % (3600 * 24)) // 3600
minutes = (seconds % 3600 + 59) // 60
parts = []
if days > 0:
if days == 1:
parts.append(f"{days} день")
elif 2 <= days <= 4:
parts.append(f"{days} дня")
else:
parts.append(f"{days} дней")
if hours > 0:
if hours == 1:
parts.append(f"{hours} час")
elif 2 <= hours <= 4:
parts.append(f"{hours} часа")
else:
parts.append(f"{hours} часов")
if minutes > 0 and days == 0:
if minutes == 1:
parts.append("1 минута")
elif 2 <= minutes <= 4:
parts.append(f"{minutes} минуты")
else:
parts.append(f"{minutes} минут")
return " и ".join(parts) if parts else "менее минуты"
def get_plural_form(num: int, form1: str, form2: str, form3: str) -> str:
"""Универсальная функция для получения правильной формы множественного числа"""
n = abs(num) % 100
if 10 < n < 20:
return form3
return {1: form1, 2: form2, 3: form2, 4: form2}.get(n % 10, form3)
def format_months(months: int) -> str:
"""Форматирует количество месяцев с правильным склонением"""
if months <= 0:
return "0 месяцев"
return f"{months} {get_plural_form(months, 'месяц', 'месяца', 'месяцев')}"
def format_days(days: int) -> str:
"""
Форматирует количество дней с правильным склонением.
Args:
days (int): Количество дней.
Returns:
str: Строка с числом и склонённым словом "день/дня/дней".
"""
if days <= 0:
return "0 дней"
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
def format_hours(hours: int) -> str:
"""Форматирует количество часов с правильным склонением"""
if hours <= 0:
return "0 часов"
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
def format_minutes(minutes: int) -> str:
"""Форматирует количество минут с правильным склонением"""
if minutes <= 0:
return "0 минут"
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
def format_hours(hours: int) -> str:
"""Форматирует количество часов с правильным склонением"""
if hours <= 0:
return "0 часов"
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
def format_minutes(minutes: int) -> str:
"""Форматирует количество минут с правильным склонением"""
if minutes <= 0:
return "0 минут"
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
async def edit_or_send_message(
target_message: Message,
text: str,
@@ -183,15 +186,6 @@ async def edit_or_send_message(
):
"""
Универсальная функция для редактирования исходного сообщения target_message.
- Если media_path указан и существует, считается, что сообщение содержит фото, и используется редактирование медиа
(замена фото и подписи) через edit_media. Если редактирование не удаётся, отправляется новое сообщение с фото.
- Если media_path не указан:
- Если force_text=False и target_message уже имеет caption, пытаемся отредактировать подпись (edit_caption).
- Иначе (или если редактирование caption не удалось) редактируем текст (edit_text).
В случае неудачи fallback отправка нового сообщения.
"""
if media_path and os.path.isfile(media_path):
async with aiofiles.open(media_path, "rb") as f:
@@ -236,11 +230,6 @@ async def edit_or_send_message(
def convert_to_bytes(value: float, unit: str) -> int:
"""
Конвертирует значение с указанной единицей измерения в байты.
Args:
value (float): Числовое значение.
unit (str): Единица измерения ('KB', 'MB', 'GB', 'TB').
Returns:
int: Количество байт.
"""
KB = 1024
MB = KB * 1024
@@ -248,3 +237,68 @@ def convert_to_bytes(value: float, unit: str) -> int:
TB = GB * 1024
units = {"KB": KB, "MB": MB, "GB": GB, "TB": TB}
return int(value * units.get(unit.upper(), 1))
async def is_full_remnawave_cluster(cluster_id: str, session) -> bool:
"""
Универсальная проверка:
- Если cluster_id это имя кластера, проверяет, что все его сервера используют Remnawave.
- Если cluster_id это имя одиночного сервера, проверяет, что он Remnawave.
"""
cluster_servers = await session.fetch(
"SELECT panel_type FROM servers WHERE cluster_name = $1",
cluster_id,
)
if cluster_servers:
panel_types = [s["panel_type"].lower() for s in cluster_servers if s.get("panel_type")]
return all(pt == "remnawave" for pt in panel_types)
server = await session.fetchrow(
"SELECT panel_type FROM servers WHERE server_name = $1",
cluster_id,
)
return server and server["panel_type"].lower() == "remnawave"
def sanitize_key_name(key_name: str) -> str:
"""
Очищает название ключа, оставляя только допустимые символы.
Args:
key_name (str): Исходное название ключа.
Returns:
str: Очищенное название ключа в нижнем регистре.
"""
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
RUSSIAN_MONTHS = {
"January": "Января",
"February": "Февраля",
"March": "Марта",
"April": "Апреля",
"May": "Мая",
"June": "Июня",
"July": "Июля",
"August": "Августа",
"September": "Сентября",
"October": "Октября",
"November": "Ноября",
"December": "Декабря",
}
def get_russian_month(date: datetime) -> str:
"""
Преобразует английское название месяца в русское.
Args:
date: Объект datetime, из которого извлекается месяц.
Returns:
Название месяца на русском языке.
"""
english_month = date.strftime("%B")
return RUSSIAN_MONTHS.get(english_month, english_month)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 586 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 583 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 596 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 592 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 590 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -16
View File
@@ -5,6 +5,7 @@ from aiogram.dispatcher.middlewares.base import BaseMiddleware
from .admin import AdminMiddleware
from .loggings import LoggingMiddleware
from .maintenance import MaintenanceModeMiddleware
from .session import SessionMiddleware
from .throttling import ThrottlingMiddleware
from .user import UserMiddleware
@@ -15,41 +16,27 @@ def register_middleware(
middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None,
exclude: Iterable[str] | None = None,
) -> None:
"""Регистрирует middleware в диспетчере.
Args:
dispatcher: Экземпляр диспетчера Aiogram
middlewares: Опциональный список middleware для регистрации.
Если не указан, регистрируются все стандартные middleware.
exclude: Опциональный список имен middleware, которые нужно исключить из регистрации.
Применяется только если middlewares не указан.
"""
# Если middleware не указаны, используем стандартный набор
"""Регистрирует middleware в диспетчере."""
if middlewares is None:
# Словарь всех доступных middleware
available_middlewares = {
"admin": AdminMiddleware(),
"session": SessionMiddleware(),
"maintenance": MaintenanceModeMiddleware(),
"logging": LoggingMiddleware(),
"throttling": ThrottlingMiddleware(),
"user": UserMiddleware(),
}
# Фильтруем middleware по списку исключений
exclude_set = set(exclude or [])
middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set]
# Регистрируем middleware для всех типов обработчиков
handlers = [
dispatcher.message,
dispatcher.callback_query,
dispatcher.inline_query,
# Можно добавить другие типы обработчиков при необходимости
]
# Регистрируем каждый middleware для каждого типа обработчика
for middleware in middlewares:
# Если передан класс, а не экземпляр, создаем экземпляр
if isinstance(middleware, type):
middleware = middleware()
+23
View File
@@ -0,0 +1,23 @@
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message
from config import ADMIN_ID
maintenance_mode = False
class MaintenanceModeMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
if maintenance_mode:
user_id = None
if isinstance(event, Message):
user_id = event.from_user.id
elif isinstance(event, CallbackQuery):
user_id = event.from_user.id
if user_id and user_id not in ADMIN_ID:
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
return
return await handler(event, data)
+1
View File
@@ -51,6 +51,7 @@ class UserMiddleware(BaseMiddleware):
language_code=user.language_code,
is_bot=user.is_bot,
session=session,
only_if_exists=True,
)
logger.debug(f"Получены данные пользователя из БД: {user.id}")
Binary file not shown.
+45 -68
View File
@@ -1,10 +1,21 @@
import time
from dataclasses import dataclass
from typing import Any
import httpx
import py3xui
from config import LIMIT_IP, SUPERNODE
from py3xui import AsyncApi
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
LIMIT_IP,
SUPERNODE,
USE_XUI_TOKEN,
XUI_TOKEN,
)
from logger import logger
@@ -24,21 +35,39 @@ class ClientConfig:
sub_id: str
_xui_instance_cache: dict[str, tuple[AsyncApi, float]] = {}
SESSION_TTL = 1800
async def get_xui_instance(api_url: str) -> AsyncApi:
key = f"{api_url}|{ADMIN_USERNAME}"
current_time = time.time()
xui_entry = _xui_instance_cache.get(key)
if xui_entry:
xui, last_login = xui_entry
if current_time - last_login < SESSION_TTL:
return xui
else:
logger.info("[XUI Cache] Сессия устарела (>30 минут), переподключение...")
await xui.login()
_xui_instance_cache[key] = (xui, current_time)
return xui
xui = AsyncApi(
api_url,
ADMIN_USERNAME,
ADMIN_PASSWORD,
token=XUI_TOKEN if USE_XUI_TOKEN else None,
logger=logger,
)
await xui.login()
_xui_instance_cache[key] = (xui, current_time)
return xui
async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, Any]:
"""
Добавляет клиента на сервер через 3x-ui.
Args:
xui: Экземпляр API клиента
config: Конфигурация клиента
Returns:
Dict[str, Any]: Результат операции в формате
{'status': 'success'|'failed'|'duplicate', 'error': str, 'email': str}
"""
try:
await xui.login()
client = py3xui.Client(
id=config.client_id,
email=config.email.lower(),
@@ -53,7 +82,6 @@ async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, An
response = await xui.client.add(config.inbound_id, [client])
logger.info(f"Клиент {config.email} успешно добавлен с ID {config.client_id}")
return response if response else {"status": "failed"}
except httpx.ConnectTimeout as e:
@@ -80,31 +108,10 @@ async def extend_client_key(
sub_id: str,
tg_id: int,
) -> bool | None:
"""
Обновляет срок действия ключа клиента.
Args:
xui: Экземпляр API клиента
inbound_id: ID входящего соединения
email: Email клиента
new_expiry_time: Новое время истечения
client_id: ID клиента
total_gb: Общий объем трафика
sub_id: ID подписки
Returns:
Optional[bool]: True если успешно, False если ошибка, None если клиент не найден
"""
try:
await xui.login()
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} не найден.")
return None
if not client.id:
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
if not client or not client.id:
logger.warning(f"Клиент с email {email} не найден или не имеет ID.")
return None
logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}")
@@ -152,8 +159,6 @@ async def delete_client(
bool: True если удаление успешно, False в противном случае
"""
try:
await xui.login()
if SUPERNODE:
await xui.client.delete(inbound_id, client_id)
logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE)")
@@ -179,20 +184,8 @@ async def delete_client(
async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str, Any]:
"""
Получает информацию о трафике пользователя по client_id.
Args:
xui: Экземпляр API клиента
client_id: UUID клиента
Returns:
dict[str, Any]: Информация о трафике пользователя или ошибка
"""
try:
await xui.login()
traffic_data = await xui.client.get_traffic_by_id(client_id)
if not traffic_data:
logger.warning(f"Трафик для клиента {client_id} не найден.")
return {"status": "not_found", "client_id": client_id}
@@ -210,24 +203,8 @@ async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str,
async def toggle_client(xui: py3xui.AsyncApi, inbound_id: int, email: str, client_id: str, enable: bool = True) -> bool:
"""
Функция для включения/отключения клиента на сервере 3x-ui.
Args:
xui: Экземпляр API клиента
inbound_id: ID инбаунда
email: Email клиента
client_id: UUID клиента
enable: True для включения, False для отключения
Returns:
bool: True при успешном выполнении, False при ошибке
"""
try:
await xui.login()
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
return False
+17 -14
View File
@@ -1,5 +1,6 @@
import csv
from datetime import datetime
from io import StringIO
from typing import Any
@@ -12,18 +13,17 @@ async def export_users_csv(session: Any) -> BufferedInputFile:
"""
query = """
SELECT
u.tg_id,
u.username,
u.first_name,
u.last_name,
u.language_code,
u.is_bot,
c.balance,
c.trial,
u.created_at -- Добавляем дату регистрации
FROM users u
LEFT JOIN connections c ON u.tg_id = c.tg_id
ORDER BY u.created_at ASC -- Сортировка от старых к новым
tg_id,
username,
first_name,
last_name,
language_code,
is_bot,
balance,
trial,
created_at
FROM users
ORDER BY created_at ASC
"""
users = await session.fetch(query)
@@ -173,7 +173,7 @@ async def export_hot_leads_csv(session: Any) -> BufferedInputFile:
async def export_keys_csv(session) -> BufferedInputFile:
"""
Экспорт подписок в CSV.
Экспорт подписок в CSV с нормальными датами.
"""
keys = await session.fetch("""
SELECT tg_id, client_id, email, created_at, expiry_time, key, server_id, is_frozen, alias
@@ -185,9 +185,12 @@ async def export_keys_csv(session) -> BufferedInputFile:
buffer.write("tg_id,client_id,email,created_at,expiry_time,key,server_id,is_frozen,alias\n")
for row in keys:
created_at = datetime.utcfromtimestamp(row["created_at"] / 1000).strftime("%Y-%m-%d %H:%M:%S")
expiry_time = datetime.utcfromtimestamp(row["expiry_time"] / 1000).strftime("%Y-%m-%d %H:%M:%S")
buffer.write(
f"{row['tg_id']},{row['client_id']},{row['email']},"
f"{row['created_at']},{row['expiry_time']},{row['key']},"
f"{created_at},{expiry_time},{row['key']},"
f"{row['server_id']},{row['is_frozen']},{row['alias'] or ''}\n"
)
+9
View File
@@ -0,0 +1,9 @@
from aiohttp.web_urldispatcher import UrlDispatcher
import bot
async def register_web_routes(router: UrlDispatcher) -> None:
dp = bot.dp
# todo: add your api routes here