140 Commits

Author SHA1 Message Date
Vladless a953479ebf make get_git_commit_number work under systemd 2025-06-14 06:23:31 +03:00
Vladislav Lisitsyn db21914afe Merge pull request #208 from Vladless/dev
fix alembic migrations
2025-06-10 16:06:25 +03:00
Vladless bbc907ef93 fix alembic migrations 2025-06-10 16:05:57 +03:00
Vladislav Lisitsyn 084b8a3a08 Merge pull request #207 from Vladless/dev
version 4.3 update
2025-06-09 22:46:36 +03:00
Vladislav Lisitsyn e7e7de8115 Merge branch 'main' into dev 2025-06-09 22:45:13 +03:00
Vladislav Lisitsyn a65c38394f Update README.md 2025-06-09 22:43:02 +03:00
Vladless 82ad2ae8e9 daily reports and minor fixes 2025-06-09 21:41:30 +03:00
Vladless 3e251a46ea fix permissions for delete tables 2025-06-09 18:43:45 +03:00
Capybara-z c76c8d6544 fix: android tv connection for remnawave 2025-06-05 19:16:05 +03:00
Capybara-z d61c0dcb30 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-05 01:15:21 +03:00
Capybara-z e2d31525ea add: android tv connection for remnawave 2025-06-05 01:14:20 +03:00
Vladless 4dda834805 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-04 14:58:23 +03:00
Vladless 6a46f7e7ff fix custom amount input for stars pay 2025-06-04 14:58:00 +03:00
Capybara-z 05ee8ff1f4 fix remnawave sync logic
- Исправлена логика синхронизации Remnawave, если обновлять нечего пересоздаем.
2025-06-04 00:52:22 +03:00
Vladless 9f696fa9cd sort tariffs by ID 2025-06-03 03:37:25 +03:00
Vladless 9666947125 use update instead of recreate for Remnawave-only clusters 2025-06-03 02:40:56 +03:00
Vladless 0f6d09c06c fix UTC daily report and back button for bans 2025-06-03 00:35:20 +03:00
Vladless f1e3cb0c05 add cryptobot description 2025-06-02 20:41:58 +03:00
Vladless 20a4c0ab38 fix cashback in stats/back tariff/disable cluster 2025-06-02 20:11:02 +03:00
Capybara-z eef645fbcf hide device count for unlimited tariffs 2025-06-01 02:30:46 +03:00
Capybara-z e00588d468 format tariff duration in messages / format delete delay message for expired subscriptions 2025-06-01 01:30:55 +03:00
Vladless 5ab21c9e8d Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-31 20:44:16 +03:00
Vladless cb45a4d760 up bot version 2025-05-31 20:44:04 +03:00
Capybara-z 7d8d874390 group tariffs and sort by duration 2025-05-31 18:08:52 +03:00
Capybara-z 36107a32c7 group tariffs and sort by duration 2025-05-31 16:48:40 +03:00
Vladless c0dc3dcc18 delete cuopons stats for utm 2025-05-31 15:54:29 +03:00
Vladless df43ea2716 fix remna traffic for create_key 2025-05-30 22:12:16 +03:00
Capybara-z 43af9df078 fix: trial key display / show correct trial days / fix trial limits 2025-05-30 19:07:27 +03:00
Capybara-z 31b9d0abdf fix: admin balance buttons / show tariff in key / show tariff on buy/renew / backup send modes 2025-05-30 17:02:37 +03:00
Vladless 572aa88ee1 rebase subs for country_mode 2025-05-30 16:54:44 +03:00
Capybara-z 3dbec2196a fix: coupon renew uses tariff limits / fix autorenew / fix renew keyboard / show tariff info in key / key renew uses correct tariff / fix start menu display 2025-05-29 22:31:28 +03:00
Vladless 9a5cc083ca Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-29 14:38:53 +03:00
Vladless 2172aa02a8 Preserve images inside the img/ folder during updates 2025-05-29 14:37:22 +03:00
Capybara-z 4316bf2209 Fix auto-renewal logic to properly select tariff during renewal 2025-05-29 00:03:04 +03:00
Vladless 331d140b42 restore hot leads export and stats section 2025-05-28 22:49:59 +03:00
Vladless e67dce82d6 actual commit for admin-menu 2025-05-28 22:14:07 +03:00
Vladless b442e70657 CLI 0.1.8/update tariff menu 2025-05-28 20:48:11 +03:00
Vladless 4fb02063fd fix add time in admins 2025-05-28 19:10:46 +03:00
Capybara-z 96fb83f7f4 fix: key_utils.py 2025-05-28 01:18:58 +03:00
Capybara-z cc3b8a0571 Merge branch 'dev' of fix: key management in admin menu / fixed synchronization 2025-05-28 01:08:16 +03:00
Capybara-z 32fda4e8b5 fix: key management in admin menu / fixed synchronization 2025-05-28 00:44:26 +03:00
Capybara-z cd903944fa tariff lookup for country model
- Определение тарифа для режима стран при продлении ключей.
2025-05-27 00:15:36 +03:00
Vladless 3ab1007178 add commit number to bot version display 2025-05-26 23:11:04 +03:00
Vladless 0e820f62c9 add new payment flow for yoomoney/ add payment for refferals 2025-05-26 22:36:26 +03:00
Vladless 3e33513fe9 add support traffic for trial in cluster mode/country mode 2025-05-26 21:37:00 +03:00
Capybara-z daa9710579 exclude referral and coupon payments from statistics 2025-05-26 19:07:19 +03:00
Capybara-z 6b2f4f6ac5 delete accrual statistics 2025-05-26 12:24:43 +03:00
Vladless 8e44c9755b Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-25 23:56:02 +03:00
Vladless d55fbf0fed add trial limits for create clusters_mode 2025-05-25 23:54:43 +03:00
Capybara-z e05f8f8183 add to accrual statistics
- Добавлено логирование начислений купонов.
2025-05-25 23:38:31 +03:00
Capybara-z 1ae19cd351 add to accrual statistics
- Добавлено логирование начислений реферавлов.
2025-05-25 23:35:29 +03:00
Vladislav Lisitsyn d79deb307e Merge pull request #206 from Capybara-z/patch-8
Fix key expiration notification and auto-renewal handling
2025-05-25 20:38:28 +03:00
Vladless 8f494c0b98 add round for balance and less callback for tariffs 2025-05-25 20:34:16 +03:00
Capybara-z 71803174b0 Fix key expiration notification and auto-renewal handling
- Исправлена обработка уведомлений об истечении ключей.
- Добавлена корректная работа с задержкой удаления ключей и авто-продлением подписок.
2025-05-25 19:47:35 +03:00
Vladless 1c660320da cleanup_orphans_before_migrations 2025-05-25 13:13:18 +03:00
Vladless 014dbd7d73 cleanup_orphans_before_migrations 2025-05-25 12:59:42 +03:00
Vladless 22e0d610a7 cleanup_orphans_before_migrations 2025-05-25 12:49:38 +03:00
Vladless d4f77df197 cleanup_orphans_before_migrations 2025-05-25 12:36:40 +03:00
Vladless 0f7a9e58d8 fix back button in view tariff groups 2025-05-24 23:41:25 +03:00
Vladless 9ae2de2eb5 fix key create for user in admin 2025-05-24 23:25:49 +03:00
Vladless 1309c3ef43 minor fixes 2025-05-24 22:32:15 +03:00
Vladless 0edadd9592 fix gift tariff name 2025-05-24 22:01:32 +03:00
Vladless cc403fac67 session for renew_key 2025-05-24 21:53:15 +03:00
Vladless faecb7fb13 trial user in check_notification 2025-05-24 21:31:53 +03:00
Vladless 32cc047943 session in process_callback_view_profile 2025-05-24 20:49:19 +03:00
Vladless d4d2ff1867 ORM update and lots of improvements 2025-05-24 19:30:23 +03:00
Vladless d2d6ba1be9 key column null for table keys 2025-05-20 23:53:49 +03:00
Vladless 96905c0f51 sorted tariffs by id for renew key 2025-05-20 18:23:46 +03:00
Vladless fe2c70ab75 delete tarif_group for servers 2025-05-20 16:12:41 +03:00
Vladless 1174a3ff34 convert balance column 2025-05-20 13:21:31 +03:00
Vladless e66a465eee group by id 2025-05-20 13:04:37 +03:00
Vladless d6ec510055 fix add_notification for blocked users 2025-05-20 12:35:54 +03:00
Vladless 29bd962d49 balance numeric in database 2025-05-20 09:06:47 +03:00
Vladless 8fb8e203ae sudo python3 main.py 2025-05-20 08:55:18 +03:00
Vladless 78cfd35ad8 add tariff_group in get_servers 2025-05-20 08:45:17 +03:00
Vladless e6429fda9d fix delete tariff 2025-05-19 17:15:38 +03:00
Vladless d491178410 add group_code 2025-05-19 14:17:35 +03:00
Vladless b3b21d1f5f minor improvements 2025-05-19 11:08:51 +03:00
Vladless e8030c1db9 discounts tariffs/notify hot leads/change success payment 2025-05-19 09:07:32 +03:00
Vladless a1f0449e5c bot version 2025-05-19 06:03:36 +03:00
Vladless fd3e26dd47 tariff id in callback 2025-05-19 06:03:03 +03:00
Vladless ae39b6df9a statistic tariffs/database logs 2025-05-19 05:41:19 +03:00
Vladless cbd2f58499 ban middleware/ ban users 2025-05-19 03:59:26 +03:00
Vladless 8ea78943fe add daily project report 2025-05-18 00:44:17 +03:00
Vladless 8bd904af6c tariffs view in cluster menu 2025-05-18 00:27:39 +03:00
Vladless b3369d8708 tblocker router 2025-05-17 20:54:23 +03:00
Vladless 4af368549e tariff_id for payments 2025-05-17 20:44:43 +03:00
Vladless 07bbd9b177 data formatted in renew 2025-05-16 20:27:48 +03:00
Vladless 11898a5d13 fix gifts user_data 2025-05-16 18:37:41 +03:00
Vladless e662e0d06e tariffs order by id 2025-05-16 15:46:12 +03:00
Vladless dc2e1158fa ip limit for tariffs 2025-05-16 10:06:26 +03:00
Vladless 109df2354c add tariffs stats 2025-05-16 08:51:43 +03:00
Vladless 4d337e3df5 add device_limit in tariffs 2025-05-16 07:09:18 +03:00
Vladless 9a042c638a tariff customization/traffic for each tariff/ruff 2025-05-16 06:02:18 +03:00
Vladless d520f02c03 add alias for key menu in admin-panel 2025-05-13 06:45:56 +03:00
Vladless 0581327358 add more hwid stats 2025-05-13 04:36:06 +03:00
Vladless 0883cf2bf6 add more stats 2025-05-13 04:32:36 +03:00
Vladless 6067eca053 hwid management in admin-panel and key menu 2025-05-12 23:16:56 +03:00
Vladislav Lisitsyn 48d7e197a1 Merge pull request #205 from Capybara-z/patch-7
Add tblocker blocking notifications
2025-05-12 23:04:28 +03:00
Capybara-z 961514d712 Add tblocker blocking notifications
- Логика обработчки оповещений на вебхук от tblocker
2025-05-12 22:12:01 +03:00
Capybara-z 97bfc1bc43 add tblocker webhook handler
- Добавлен обработчик вебхука tblocker
2025-05-12 22:05:36 +03:00
Oleg Kraev b3b15db8c0 Merge pull request #204 from Capybara-z/patch-6
add sub info
2025-05-12 02:46:05 +03:00
Vladless ce275babc3 4.3-beta 2025-05-11 19:23:27 +03:00
Vladless 3aac6b9872 add support HWID 2025-05-11 16:55:19 +03:00
Oleg Kraev 8813664f3f Update README.md 2025-05-11 16:53:22 +03:00
Capybara-z ebd50f8b3d add sub info
- добавил хедер v2raytun для отображения травика и даты завершения подписки.
2025-05-11 16:14:35 +03:00
Vladless 62fccf2b10 add support remnawave api 1.6.x 2025-05-11 16:12:51 +03:00
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
109 changed files with 10190 additions and 5164 deletions
+2 -1
View File
@@ -7,6 +7,7 @@ __pycache__/
dist/
build/
*.egg-info/
alembic/
# Virtual environments
venv/
@@ -28,6 +29,7 @@ env/
# Sensitive configuration files
config.py
config.ini
alembic.ini
.env
# Backup files
@@ -51,7 +53,6 @@ Thumbs.db
nginx.conf
scripts
models.py
Dockerfile
.csv
/logs
-1
View File
@@ -1 +0,0 @@
3.12
+58 -77
View File
@@ -1,106 +1,87 @@
# 🚀 SoloBot
**SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
# Основные возможности
## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
**SoloBot** реализует множество функций, включая:
## ⚙️ Основные возможности SoloBot
### Управление подписками
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
| 📌 Раздел | 💡 Возможности |
|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Мультипанель** | • Работа с панелями [3x-ui](https://github.com/MHSanaei/3x-ui) <br> • Работа с панелями [Remnawave](https://github.com/remnawave/panel) <br> • Работа в режиме совместимости (различные панели одновременно) |
| **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки <br> • Пробный период на заданный период <br> • Продление ключей по тарифному плану <br> • Поддержка различных клиентов (Happ, Hiddify, v2RayTun) <br> • Кастомизируемые заголовки профилей |
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
| **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые) <br> • YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Robokassa (ИП) <br> • Cryptobot [@izzzzzi](https://github.com/izzzzzi) <br> • Telegram Stars |
| **Безопасность и стабильность** | • Периодические бэкапы <br> • Смена домена в случае переезда <br> • Проверка доступности серверов <br> • Уведомление о недоступности сервера и его аптайм <br> |
| **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент) <br> • Напоминания о неиспользованном трафике |
| **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве) <br> • Выдача в разных режимах (по одной локации или в формате подписки) <br> • Автопроверка доступности <br> • Балансировка нагрузки при выдаче ключей <br> • Синхронизация клиентов между серверами <br> • Ограничение максимального количества ключей на сервер <br> • Возможность включения/отключения отдельных серверов |
| **Админ-панель** | • Поиск по TG ID / username / ключу / email <br> • Управление балансом, подписками, заморозками <br> • Перезагрузка, бан-лист <br> • Создание купонов, UTM, статистика |
| **Кастомизация** | • Все функции бота кастомизируемы, вплоть до логики работы <br> |
### Полный контроль клиента над своими ключами
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img src="https://img.shields.io/badge/Возможности_бота-перейти-ffa726?style=for-the-badge" />
</a>
### Реферальная программа
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
## 🛠 Обновления
### Интеграция платёжных систем
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
### SoloBot регулярно получает новые функции и улучшения.
### Безопасность и стабильность
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
#### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
### Уведомления
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
# Наш сайт и полная версия
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
### Переходи на наш сайт!
<h3>
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ffa726?style=for-the-badge" alt="Сайт SoloNetBot">
</a>
</h3>
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
![image](https://github.com/user-attachments/assets/fb0028cd-c588-4548-b290-a4bfcc96a3e1)
### *Настраивайте как вам удобно!*
<h3>
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img src="https://img.shields.io/badge/Попробовать_SoloBot_в_Telegram-жми-ffa726?style=for-the-badge&logo=telegram" alt="Попробовать SoloBot в Telegram">
</a>
</h3>
## Отзывы пользователей:
## Список версий
#### SoloBot уже помог сотням пользователей в нашем сообществе:
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30)
## 🔗 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)
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
<h3>
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
<img src="https://img.shields.io/badge/Читать_отзывы-на_сайте-ffa726?style=for-the-badge&logo=discourse" alt="Читать отзывы">
</a>
</h3>
Связаться с нами через [**➡ поддержку**](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)
-213
View File
@@ -1,213 +0,0 @@
CREATE TABLE IF NOT EXISTS users
(
tg_id BIGINT PRIMARY KEY NOT NULL,
username TEXT,
first_name TEXT,
last_name TEXT,
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,
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
(
id SERIAL PRIMARY KEY,
tg_id BIGINT NOT NULL,
amount REAL NOT NULL,
payment_system TEXT NOT NULL,
status TEXT DEFAULT 'success',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS keys
(
tg_id BIGINT NOT NULL,
client_id TEXT NOT NULL,
email TEXT NOT NULL,
created_at BIGINT NOT NULL,
expiry_time BIGINT NOT NULL,
key TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT 'cluster1',
notified BOOLEAN NOT NULL DEFAULT FALSE,
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (tg_id, client_id)
);
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'
) THEN
ALTER TABLE keys ADD COLUMN is_frozen BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'alias'
) THEN
ALTER TABLE keys ADD COLUMN alias TEXT;
END IF;
END$$;
CREATE TABLE IF NOT EXISTS referrals
(
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
referrer_tg_id BIGINT NOT NULL,
reward_issued BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS coupons
(
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
amount INTEGER NOT NULL,
days INTEGER CHECK (days > 0 OR days IS NULL),
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0,
is_used BOOLEAN NOT NULL DEFAULT FALSE
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'coupons' AND column_name = 'days'
) THEN
ALTER TABLE coupons ADD COLUMN days INTEGER CHECK (days > 0 OR days IS NULL);
END IF;
END$$;
CREATE TABLE IF NOT EXISTS coupon_usages
(
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (coupon_id, user_id)
);
CREATE TABLE IF NOT EXISTS notifications
(
tg_id BIGINT NOT NULL,
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
notification_type TEXT NOT NULL,
PRIMARY KEY (tg_id, notification_type)
);
CREATE TABLE IF NOT EXISTS servers
(
id SERIAL PRIMARY KEY,
cluster_name TEXT NOT NULL,
server_name TEXT NOT NULL,
api_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,
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,
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,
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()
);
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$$;
+59 -8
View File
@@ -1,15 +1,17 @@
import os
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
import aiofiles
from aiogram.types import BufferedInputFile
from aiogram import Bot
from bot import bot
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
from config import (
ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT,
BACKUP_SEND_MODE, BACKUP_CHANNEL_ID, BACKUP_CHANNEL_THREAD_ID, BACKUP_OTHER_BOT_TOKEN, BACKUP_CAPTION
)
from logger import logger
@@ -141,18 +143,67 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
async def send_default():
for admin_id in ADMIN_ID:
try:
await bot.send_document(
chat_id=admin_id, document=backup_input_file
)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
try:
async with aiofiles.open(backup_file_path, "rb") as backup_file:
backup_data = await backup_file.read()
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
for admin_id in ADMIN_ID:
if BACKUP_SEND_MODE == "default":
await send_default()
elif BACKUP_SEND_MODE == "channel":
channel_id = BACKUP_CHANNEL_ID.strip()
thread_id = BACKUP_CHANNEL_THREAD_ID.strip()
if not channel_id:
logger.error("BACKUP_CHANNEL_ID не задан для режима 'channel', fallback на default")
await send_default()
return
send_kwargs = dict(chat_id=channel_id, document=backup_input_file)
if thread_id:
send_kwargs["message_thread_id"] = int(thread_id)
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
await bot.send_document(**send_kwargs)
logger.info(f"Бэкап базы данных отправлен в канал: {channel_id} (топик: {thread_id})")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
logger.error(f"Не удалось отправить бэкап в канал {channel_id}: {e}, fallback на default")
await send_default()
elif BACKUP_SEND_MODE == "bot":
if not BACKUP_OTHER_BOT_TOKEN:
logger.error("BACKUP_OTHER_BOT_TOKEN не задан для режима 'bot', fallback на default")
await send_default()
return
other_bot = Bot(token=BACKUP_OTHER_BOT_TOKEN)
try:
for admin_id in ADMIN_ID:
try:
send_kwargs = dict(chat_id=admin_id, document=backup_input_file)
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
await other_bot.send_document(**send_kwargs)
logger.info(f"Бэкап базы данных отправлен админу через другого бота: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id} через другого бота: {e}")
await other_bot.session.close()
except Exception as e:
logger.error(f"Ошибка при отправке через другого бота: {e}, fallback на default")
await send_default()
else:
logger.error(f"Неизвестный BACKUP_SEND_MODE: {BACKUP_SEND_MODE}, fallback на default")
await send_default()
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
logger.error(f"Ошибка при отправке бэкапа: {e}")
raise
+138 -24
View File
@@ -1,4 +1,5 @@
import traceback
import os
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
@@ -8,30 +9,102 @@ from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
import subprocess
from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
from middlewares import register_middleware
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
version = "4.2"
register_middleware(dp)
def get_git_commit_number() -> str:
repo_url = "https://github.com/Vladless/Solo_bot"
cwd = os.path.abspath(os.path.dirname(__file__))
if not os.path.isdir(os.path.join(cwd, ".git")):
cwd = "/root/Prod/Solo_bot"
env = os.environ.copy()
env["GIT_DIR"] = os.path.join(cwd, ".git")
env["GIT_WORK_TREE"] = cwd
try:
local_number = subprocess.check_output(
["git", "rev-list", "--count", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
local_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
if branch == "HEAD":
describe = subprocess.check_output(
["git", "describe", "--tags", "--exact-match"],
cwd=cwd,
env=env,
stderr=subprocess.DEVNULL
).decode().strip()
if describe.startswith("v") or "release" in describe.lower():
branch = "main"
else:
branch = "dev"
except Exception:
branch = "dev"
except Exception as e:
return f"\n(Требуется обновление через CLI: {e})"
try:
remote_commit = subprocess.check_output(
["git", "ls-remote", "origin", f"refs/heads/{branch}"],
cwd=cwd,
env=env
).decode()
remote_hash = remote_commit.split()[0]
remote_number = subprocess.check_output(
["git", "rev-list", "--count", remote_hash],
cwd=cwd,
env=env
).decode().strip()
if local_hash == remote_hash:
return "\n(Актуальная версия)"
return (
f"\n(commit <a href=\"{repo_url}/commit/{local_hash}\">"
f"#{local_number}</a> / actual commit "
f"<a href=\"{repo_url}/commit/{remote_hash}\">#{remote_number}</a>)"
)
except Exception:
return ""
version = f"v4.3-Release{get_git_commit_number()}"
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
@dp.errors(ExceptionTypeFilter(Exception))
async def errors_handler(
event: ErrorEvent,
bot: Bot,
) -> bool:
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
if isinstance(event.exception, TelegramForbiddenError):
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
return True
@@ -40,30 +113,51 @@ async def errors_handler(
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid" in error_message
"query is too old and response timeout expired or query ID is invalid"
in error_message
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
logger.warning("Отправляем стартовое меню.")
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
@@ -74,19 +168,39 @@ async def errors_handler(
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
except TelegramBadRequest as exception:
logger.warning(f"Failed to send error details: {exception}")
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
except Exception as exception:
logger.error(f"Unexpected error in error handler: {exception}")
logger.error(f"Неожиданная ошибка в error handler: {exception}")
return True
+110 -43
View File
@@ -4,7 +4,6 @@ import subprocess
import sys
import requests
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.table import Table
@@ -12,7 +11,6 @@ from rich.text import Text
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
@@ -28,13 +26,17 @@ 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'
IS_ROOT_DIR = PROJECT_DIR == "/root"
if IS_ROOT_DIR:
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
console.print(
"[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]"
)
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
console.print("[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]")
console.print(
"[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]"
)
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
@@ -42,7 +44,9 @@ SERVICE_NAME = BOT_SERVICE
def is_service_exists(service_name):
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
result = subprocess.run(
["systemctl", "list-unit-files", service_name], capture_output=True, text=True
)
return service_name in result.stdout
@@ -73,12 +77,14 @@ def fix_permissions():
"""Устанавливает корректные права на файлы проекта"""
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
try:
user = os.getenv('SUDO_USER') or os.getenv('USER')
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", "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]")
@@ -90,17 +96,21 @@ def install_rsync_if_needed():
os.system("sudo apt update && sudo apt install -y rsync")
def clean_project_dir_safe(update_buttons=False):
def clean_project_dir_safe(update_buttons=False, update_img=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"),
os.path.join(PROJECT_DIR, ".git"),
}
if not update_buttons:
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
if not update_img:
preserved_paths.add(os.path.join(PROJECT_DIR, "img"))
for root, _, files in os.walk(os.path.join(PROJECT_DIR, "img")):
for file in files:
preserved_paths.add(os.path.join(root, file))
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
for file in files:
@@ -118,6 +128,8 @@ def clean_project_dir_safe(update_buttons=False):
dir_path = os.path.join(root, dir)
if os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "handlers"):
continue
if not update_img and os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "img"):
continue
try:
os.rmdir(dir_path)
except Exception:
@@ -135,11 +147,15 @@ def install_dependencies():
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
try:
if not os.path.exists("venv"):
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
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
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
shell=True,
check=True,
)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
@@ -186,7 +202,9 @@ def update_from_beta():
remote_version = get_remote_version(branch="dev")
if local_version and remote_version:
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
console.print(
f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]"
)
if local_version == remote_version:
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
return
@@ -195,12 +213,13 @@ 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)
update_img = Confirm.ask("[yellow]🖼 Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
@@ -209,18 +228,25 @@ def update_from_beta():
os.chdir(PROJECT_DIR)
console.print("[cyan]📅 Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons)
exclude_options = "--exclude=img"
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py"
exclude_options += "--exclude=handlers/buttons.py "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
fix_permissions()
@@ -231,7 +257,9 @@ def update_from_beta():
def update_from_release():
update_cli_launcher()
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
if not Confirm.ask(
"[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"
):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
@@ -240,13 +268,16 @@ def update_from_release():
return
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
update_img = Confirm.ask("[yellow]🖼 Обновлять папку img?[/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]
@@ -258,27 +289,39 @@ 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]"):
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"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
shell=True,
check=True,
)
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons)
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = "--exclude=img"
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py"
exclude_options += "--exclude=handlers/buttons.py "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
fix_permissions()
@@ -291,10 +334,11 @@ def update_from_release():
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
console.print(
"[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]"
)
return
table = Table(title="Выберите способ обновления", title_style="bold green")
@@ -314,7 +358,9 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI v0.1.7", title_style="bold magenta", header_style="bold blue")
table = Table(
title="Solobot CLI v0.2.0", 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)")
@@ -335,9 +381,11 @@ def update_cli_launcher():
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:
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)
@@ -353,30 +401,49 @@ def main():
try:
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 10)])
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]"):
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]"):
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]"):
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"])
subprocess.run(
[
"sudo",
"journalctl",
"-u",
SERVICE_NAME,
"-n",
"80",
"--no-pager",
]
)
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "6":
-1483
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
from .bans import *
from .coupons import *
from .db import async_session_maker
from .gifts import *
from .hot_leads import *
from .init_db import *
from .keys import *
from .notifications import *
from .payments import *
from .referrals import *
from .servers import *
from .statistics import *
from .tariffs import *
from .temporary_data import *
from .tracking_sources import *
from .users import *
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import BlockedUser
async def create_blocked_user(session: AsyncSession, tg_id: int):
stmt = (
insert(BlockedUser)
.values(tg_id=tg_id)
.on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
)
await session.execute(stmt)
await session.commit()
+121
View File
@@ -0,0 +1,121 @@
from datetime import datetime
from sqlalchemy import case, delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Coupon, CouponUsage
from logger import logger
async def create_coupon(
session: AsyncSession, code: str, amount: int, usage_limit: int, days: int = None
) -> bool:
try:
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
if exists:
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
return False
await session.execute(
insert(Coupon).values(
code=code,
amount=amount,
usage_limit=usage_limit,
usage_count=0,
is_used=False,
days=days,
)
)
await session.commit()
logger.info(f"[Coupon] ✅ Купон {code} успешно создан.")
return True
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"[Coupon] ❌ Ошибка при создании купона {code}: {e}")
return False
async def get_coupon_by_code(session: AsyncSession, code: str) -> Coupon | None:
stmt = select(Coupon).where(Coupon.code == code)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_coupons(
session: AsyncSession, page: int = 1, per_page: int = 10
) -> dict:
offset = (page - 1) * per_page
stmt = select(Coupon).order_by(Coupon.id.desc()).offset(offset).limit(per_page)
result = await session.execute(stmt)
coupons = result.scalars().all()
count_stmt = select(func.count()).select_from(Coupon)
total = await session.scalar(count_stmt)
pages = -(-total // per_page)
return {
"coupons": [c.to_dict() for c in coupons],
"total": total,
"pages": pages,
"current_page": page,
}
async def delete_coupon(session: AsyncSession, code: str) -> bool:
result = await session.execute(select(Coupon).where(Coupon.code == code))
coupon = result.scalar_one_or_none()
if not coupon:
logger.info(f"❌ Купон {code} не найден")
return False
await session.execute(delete(CouponUsage).where(CouponUsage.coupon_id == coupon.id))
await session.delete(coupon)
await session.commit()
logger.info(f"🗑 Купон {code} удалён вместе с его использованиями")
return True
async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int):
try:
stmt = insert(CouponUsage).values(
coupon_id=coupon_id, user_id=user_id, used_at=datetime.utcnow()
)
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Купон {coupon_id} использован пользователем {user_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении использования купона: {e}")
await session.rollback()
async def check_coupon_usage(
session: AsyncSession, coupon_id: int, user_id: int
) -> bool:
stmt = select(CouponUsage).where(
CouponUsage.coupon_id == coupon_id, CouponUsage.user_id == user_id
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
try:
await session.execute(
update(Coupon)
.where(Coupon.id == coupon_id)
.values(
usage_count=Coupon.usage_count + 1,
is_used=case(
(Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False
),
)
)
await session.commit()
logger.info(f"🔁 Обновлён счётчик купона {coupon_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
await session.rollback()
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
from config import DATABASE_URL
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
async_session_maker = async_sessionmaker(
bind=engine, expire_on_commit=False, class_=AsyncSession
)
Base = declarative_base()
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Gift
from logger import logger
async def store_gift_link(
session: AsyncSession,
gift_id: str,
sender_tg_id: int,
selected_months: int,
expiry_time: datetime,
gift_link: str,
tariff_id: int | None = None,
):
try:
stmt = insert(Gift).values(
gift_id=gift_id,
sender_tg_id=sender_tg_id,
recipient_tg_id=None,
selected_months=selected_months,
expiry_time=expiry_time,
gift_link=gift_link,
created_at=datetime.utcnow(),
is_used=False,
tariff_id=tariff_id,
)
await session.execute(stmt)
await session.commit()
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id})")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
await session.rollback()
return False
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment
async def get_hot_leads(session: AsyncSession):
"""
Возвращает пользователей, у которых есть успешные оплаты, но нет активных ключей.
"""
subquery = (
select(Key.tg_id)
.where(Key.expiry_time > func.extract("epoch", func.now()) * 1000)
.distinct()
)
stmt = (
select(Payment.tg_id)
.distinct()
.where(Payment.amount > 0)
.where(~Payment.tg_id.in_(subquery))
)
result = await session.execute(stmt)
return [row.tg_id for row in result]
+7
View File
@@ -0,0 +1,7 @@
from database.db import engine
from database.models import Base
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+197
View File
@@ -0,0 +1,197 @@
from datetime import datetime
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, User
from logger import logger
async def store_key(
session: AsyncSession,
tg_id: int,
client_id: str,
email: str,
expiry_time: int,
key: str,
server_id: str,
remnawave_link: str = None,
tariff_id: int = None,
):
try:
exists = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
)
if exists.scalar_one_or_none():
logger.info(
f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}"
)
return
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=int(datetime.utcnow().timestamp() * 1000),
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
)
session.add(new_key)
await session.commit()
logger.info(
f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}"
)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
async def get_keys(session: AsyncSession, tg_id: int):
result = await session.execute(select(Key).where(Key.tg_id == tg_id))
return result.scalars().all()
async def get_all_keys(session: AsyncSession):
result = await session.execute(select(Key))
return result.scalars().all()
async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
stmt = select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
stmt = (
select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
)
result = await session.execute(stmt)
row = result.first()
if not row:
return None
key, user = row
expiry_date = datetime.utcfromtimestamp(key.expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "<b>Ключ истек.</b>"
elif time_left.days > 0:
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
return {
"key": key.key,
"remnawave_link": key.remnawave_link,
"server_id": key.server_id,
"created_at": key.created_at,
"expiry_time": key.expiry_time,
"client_id": key.client_id,
"tg_id": user.tg_id,
"email": key.email,
"is_frozen": key.is_frozen,
"balance": user.balance,
"alias": key.alias,
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
"days_left_message": days_left_message,
"link": key.key or key.remnawave_link,
"cluster_name": key.server_id,
"location_name": key.server_id,
"tariff_id": key.tariff_id,
}
async def get_key_count(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(
select(func.count()).select_from(Key).where(Key.tg_id == tg_id)
)
return result.scalar() or 0
async def delete_key(session: AsyncSession, identifier: int | str):
stmt = delete(Key).where(
Key.tg_id == identifier
if str(identifier).isdigit()
else Key.client_id == identifier
)
await session.execute(stmt)
await session.commit()
logger.info(f"Ключ с идентификатором {identifier} удалён")
async def update_key_expiry(
session: AsyncSession, client_id: str, new_expiry_time: int
):
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(expiry_time=new_expiry_time, notified=False, notified_24h=False)
)
await session.commit()
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
async def get_client_id_by_email(session: AsyncSession, email: str):
result = await session.execute(select(Key.client_id).where(Key.email == email))
return result.scalar_one_or_none()
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.client_id == client_id)
.values(notified=True)
)
await session.commit()
async def mark_key_as_frozen(
session: AsyncSession, tg_id: int, client_id: str, time_left: int
):
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = TRUE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
),
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
)
async def mark_key_as_unfrozen(
session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int
):
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = FALSE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
),
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
)
async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: int):
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(tariff_id=tariff_id)
)
await session.commit()
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
+201
View File
@@ -0,0 +1,201 @@
from datetime import datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
Base = declarative_base()
class DictLikeMixin:
def __getitem__(self, key):
return getattr(self, key)
def get(self, key, default=None):
return getattr(self, key, default)
def to_dict(self):
return {
column.name: getattr(self, column.name) for column in self.__table__.columns
}
class User(DictLikeMixin, Base):
__tablename__ = "users"
tg_id = Column(BigInteger, primary_key=True)
username = Column(String)
first_name = Column(String)
last_name = Column(String)
language_code = Column(String)
is_bot = Column(Boolean, default=False)
balance = Column(Float, default=0.0)
trial = Column(Integer, default=0)
source_code = Column(String, ForeignKey("tracking_sources.code"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
class Key(DictLikeMixin, Base):
__tablename__ = "keys"
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=False)
client_id = Column(String, primary_key=True)
email = Column(String, unique=True)
created_at = Column(BigInteger)
expiry_time = Column(BigInteger)
key = Column(String)
server_id = Column(String)
remnawave_link = Column(String)
tariff_id = Column(Integer, ForeignKey("tariffs.id"))
is_frozen = Column(Boolean, default=False)
alias = Column(String)
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
class Tariff(DictLikeMixin, Base):
__tablename__ = "tariffs"
id = Column(Integer, primary_key=True)
name = Column(String)
group_code = Column(String)
duration_days = Column(Integer)
price_rub = Column(Integer)
traffic_limit = Column(BigInteger, nullable=True)
device_limit = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
class Server(DictLikeMixin, Base):
__tablename__ = "servers"
id = Column(Integer, primary_key=True, autoincrement=True)
cluster_name = Column(String)
server_name = Column(String, unique=True)
api_url = Column(String)
subscription_url = Column(String)
inbound_id = Column(String)
panel_type = Column(String)
max_keys = Column(Integer)
tariff_group = Column(String)
enabled = Column(Boolean, default=True)
class Payment(DictLikeMixin, Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True)
tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
amount = Column(Float)
payment_system = Column(String)
status = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
class Coupon(DictLikeMixin, Base):
__tablename__ = "coupons"
id = Column(Integer, primary_key=True)
code = Column(String, unique=True)
amount = Column(Integer)
usage_limit = Column(Integer)
usage_count = Column(Integer, default=0)
is_used = Column(Boolean, default=False)
days = Column(Integer, nullable=True)
class CouponUsage(DictLikeMixin, Base):
__tablename__ = "coupon_usages"
coupon_id = Column(Integer, ForeignKey("coupons.id"), primary_key=True)
user_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
class Referral(DictLikeMixin, Base):
__tablename__ = "referrals"
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
reward_issued = Column(Boolean, default=False)
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
notification_type = Column(String, primary_key=True)
last_notification_time = Column(DateTime, default=datetime.utcnow)
class Gift(DictLikeMixin, Base):
__tablename__ = "gifts"
gift_id = Column(String, primary_key=True)
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
selected_months = Column(Integer)
expiry_time = Column(DateTime)
gift_link = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
is_used = Column(Boolean, default=False)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
class ManualBan(DictLikeMixin, Base):
__tablename__ = "manual_bans"
tg_id = Column(BigInteger, primary_key=True)
banned_at = Column(DateTime(timezone=True), default=datetime.utcnow)
reason = Column(Text)
banned_by = Column(BigInteger)
until = Column(DateTime(timezone=True), nullable=True)
class TemporaryData(DictLikeMixin, Base):
__tablename__ = "temporary_data"
tg_id = Column(BigInteger, primary_key=True)
state = Column(String)
data = Column(JSON)
updated_at = Column(DateTime, default=datetime.utcnow)
class BlockedUser(DictLikeMixin, Base):
__tablename__ = "blocked_users"
tg_id = Column(BigInteger, primary_key=True)
class TrackingSource(DictLikeMixin, Base):
__tablename__ = "tracking_sources"
id = Column(Integer, primary_key=True)
name = Column(String)
code = Column(String, unique=True)
type = Column(String)
created_by = Column(BigInteger)
created_at = Column(DateTime, default=datetime.utcnow)
class Log(DictLikeMixin, Base):
__tablename__ = "logs"
id = Column(Integer, primary_key=True)
event_type = Column(String)
tg_id = Column(BigInteger)
details = Column(JSON)
created_at = Column(DateTime, default=datetime.utcnow)
+148
View File
@@ -0,0 +1,148 @@
from datetime import datetime, timedelta
from sqlalchemy import and_, delete, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Notification, User
from logger import logger
async def add_notification(session: AsyncSession, tg_id: int, notification_type: str):
try:
stmt = (
insert(Notification)
.values(
tg_id=tg_id,
notification_type=notification_type,
last_notification_time=datetime.utcnow(),
)
.on_conflict_do_update(
index_elements=[Notification.tg_id, Notification.notification_type],
set_={"last_notification_time": datetime.utcnow()},
)
)
await session.execute(stmt)
await session.commit()
logger.info(
f"✅ Добавлено уведомление {notification_type} для пользователя {tg_id}"
)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении уведомления: {e}")
await session.rollback()
async def delete_notification(
session: AsyncSession, tg_id: int, notification_type: str
):
await session.execute(
delete(Notification).where(
Notification.tg_id == tg_id,
Notification.notification_type == notification_type,
)
)
await session.commit()
logger.info(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
async def check_notification_time(
session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12
) -> bool:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
result = await session.execute(stmt)
last_time = result.scalar_one_or_none()
if not last_time:
return True
return datetime.utcnow() - last_time > timedelta(hours=hours)
async def get_last_notification_time(
session: AsyncSession, tg_id: int, notification_type: str
) -> int | None:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
result = await session.execute(stmt)
ts = result.scalar_one_or_none()
if ts:
return int(ts.timestamp() * 1000)
return None
async def check_notifications_bulk(
session: AsyncSession,
notification_type: str,
hours: int,
tg_ids: list[int] = None,
emails: list[str] = None,
) -> list[dict]:
from sqlalchemy import and_, func, select
from database.models import User, Key, Notification, BlockedUser
try:
now = datetime.utcnow()
subq_last_notification = (
select(
Notification.tg_id,
func.max(Notification.last_notification_time).label("last_notification_time")
)
.where(Notification.notification_type == notification_type)
.group_by(Notification.tg_id)
.subquery()
)
stmt = (
select(
User.tg_id,
Key.email,
User.username,
User.first_name,
User.last_name,
subq_last_notification.c.last_notification_time,
)
.outerjoin(Key, Key.tg_id == User.tg_id)
.outerjoin(subq_last_notification, subq_last_notification.c.tg_id == User.tg_id)
)
if notification_type == "inactive_trial":
stmt = stmt.where(
and_(
User.trial.in_([0, -1]),
~User.tg_id.in_(select(BlockedUser.tg_id)),
~User.tg_id.in_(select(Key.tg_id.distinct()))
)
)
if tg_ids:
stmt = stmt.where(User.tg_id.in_(tg_ids))
if emails:
stmt = stmt.where(Key.email.in_(emails))
result = await session.execute(stmt)
users = []
for row in result:
last_time = row.last_notification_time
can_notify = not last_time or (now - last_time > timedelta(hours=hours))
if can_notify:
users.append({
"tg_id": row.tg_id,
"email": row.email,
"username": row.username,
"first_name": row.first_name,
"last_name": row.last_name,
"last_notification_time": (
int(last_time.timestamp() * 1000) if last_time else None
)
})
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
return users
except Exception as e:
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
return []
+48
View File
@@ -0,0 +1,48 @@
from datetime import datetime
from sqlalchemy import insert, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Payment
from logger import logger
async def add_payment(
session: AsyncSession, tg_id: int, amount: float, payment_system: str
):
try:
stmt = insert(Payment).values(
tg_id=tg_id,
amount=amount,
payment_system=payment_system,
status="success",
created_at=datetime.utcnow(),
)
await session.execute(stmt)
await session.commit()
logger.info(
f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}"
)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении платежа: {e}")
await session.rollback()
raise
async def get_last_payments(session: AsyncSession, tg_id: int, limit: int = 3):
try:
result = await session.execute(
select(Payment)
.where(Payment.tg_id == tg_id)
.order_by(Payment.created_at.desc())
.limit(limit)
)
payments = result.scalars().all()
logger.info(
f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}"
)
return [dict(p.__dict__) for p in payments]
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при получении платежей пользователя {tg_id}: {e}")
return []
+278
View File
@@ -0,0 +1,278 @@
from sqlalchemy import and_, desc, func, insert, select, text, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
from database.models import Referral
from logger import logger
async def add_referral(session: AsyncSession, referred_tg_id: int, referrer_tg_id: int):
try:
if referred_tg_id == referrer_tg_id:
logger.warning(f"⚠️ Попытка самореферала: {referred_tg_id}")
return
stmt = insert(Referral).values(
referred_tg_id=referred_tg_id, referrer_tg_id=referrer_tg_id
)
await session.execute(stmt)
await session.commit()
logger.info(
f"✅ Добавлена реферальная связь: {referred_tg_id}{referrer_tg_id}"
)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении реферала: {e}")
await session.rollback()
raise
async def get_referral_by_referred_id(
session: AsyncSession, referred_tg_id: int
) -> dict | None:
stmt = select(Referral).where(Referral.referred_tg_id == referred_tg_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return dict(row.__dict__) if row else None
async def get_total_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
stmt = (
select(func.count())
.select_from(Referral)
.where(Referral.referrer_tg_id == referrer_tg_id)
)
result = await session.execute(stmt)
return result.scalar()
async def get_active_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
stmt = (
select(func.count())
.select_from(Referral)
.where(
and_(
Referral.referrer_tg_id == referrer_tg_id,
Referral.reward_issued is True,
)
)
)
result = await session.execute(stmt)
return result.scalar()
async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int):
await session.execute(
update(Referral)
.where(Referral.referred_tg_id == referred_tg_id)
.values(reward_issued=True)
)
await session.commit()
async def get_total_referral_bonus(
session: AsyncSession, referrer_tg_id: int, max_levels: int
) -> float:
if CHECK_REFERRAL_REWARD_ISSUED:
bonus_cte = """
WITH RECURSIVE
referral_levels AS (
SELECT
referred_tg_id,
referrer_tg_id,
1 AS level
FROM referrals
WHERE referrer_tg_id = :tg_id AND reward_issued = TRUE
UNION
SELECT
r.referred_tg_id,
r.referrer_tg_id,
rl.level + 1
FROM referrals r
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
WHERE rl.level < :max_levels AND r.reward_issued = TRUE
),
earliest_payments AS (
SELECT DISTINCT ON (tg_id) tg_id, amount, created_at
FROM payments
WHERE status = 'success'
ORDER BY tg_id, created_at
)
"""
bonus_query = (
bonus_cte
+ f"""
SELECT
COALESCE(SUM(
CASE
{
" ".join([
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * ep.amount"
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
for level in REFERRAL_BONUS_PERCENTAGES
])
}
ELSE 0
END
), 0) AS total_bonus
FROM referral_levels rl
JOIN earliest_payments ep ON rl.referred_tg_id = ep.tg_id
WHERE rl.level <= :max_levels
"""
)
else:
bonus_cte = """
WITH RECURSIVE
referral_levels AS (
SELECT
referred_tg_id,
referrer_tg_id,
1 AS level
FROM referrals
WHERE referrer_tg_id = :tg_id
UNION
SELECT
r.referred_tg_id,
r.referrer_tg_id,
rl.level + 1
FROM referrals r
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
WHERE rl.level < :max_levels
)
"""
bonus_query = (
bonus_cte
+ f"""
SELECT
COALESCE(SUM(
CASE
{
" ".join([
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * p.amount"
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
for level in REFERRAL_BONUS_PERCENTAGES
])
}
ELSE 0
END
), 0) AS total_bonus
FROM referral_levels rl
JOIN payments p ON rl.referred_tg_id = p.tg_id
WHERE p.status = 'success' AND rl.level <= :max_levels
"""
)
result = await session.execute(
text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels}
)
total_bonus_raw = result.scalar()
total_bonus = round(float(total_bonus_raw or 0), 2)
logger.debug(f"Получена общая сумма бонусов от рефералов: {total_bonus}")
return total_bonus
async def get_referrals_by_level(
session: AsyncSession, referrer_tg_id: int, max_levels: int
) -> dict:
query = """
WITH RECURSIVE referral_levels AS (
SELECT referred_tg_id, referrer_tg_id, 1 AS level
FROM referrals
WHERE referrer_tg_id = :referrer_tg_id
UNION
SELECT r.referred_tg_id, r.referrer_tg_id, rl.level + 1
FROM referrals r
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
WHERE rl.level < :max_levels
)
SELECT level,
COUNT(*) AS level_count,
COUNT(CASE WHEN reward_issued THEN 1 END) AS active_level_count
FROM referral_levels rl
JOIN referrals r ON rl.referred_tg_id = r.referred_tg_id
GROUP BY level
ORDER BY level
"""
result = await session.execute(
text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels}
)
return {
row["level"]: {
"total": row["level_count"],
"active": row["active_level_count"],
}
for row in result.mappings()
}
async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
try:
logger.info(
f"[ReferralStats] Получение статистики для пользователя {referrer_tg_id}"
)
total_referrals = await get_total_referrals(session, referrer_tg_id)
active_referrals = await get_active_referrals(session, referrer_tg_id)
max_levels = len(REFERRAL_BONUS_PERCENTAGES)
referrals_by_level = await get_referrals_by_level(
session, referrer_tg_id, max_levels
)
total_referral_bonus = await get_total_referral_bonus(
session, referrer_tg_id, max_levels
)
return {
"total_referrals": total_referrals,
"active_referrals": active_referrals,
"referrals_by_level": referrals_by_level,
"total_referral_bonus": total_referral_bonus,
}
except Exception as e:
logger.error(
f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}"
)
raise
async def get_user_referral_count(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(
select(func.count())
.select_from(Referral)
.where(Referral.referrer_tg_id == tg_id)
)
return result.scalar_one() or 0
async def get_referral_position(session: AsyncSession, referral_count: int) -> int:
subq = (
select(Referral.referrer_tg_id)
.group_by(Referral.referrer_tg_id)
.having(func.count() > referral_count)
.subquery()
)
query = select(func.count()).select_from(subq)
result = await session.execute(query)
count = result.scalar() or 0
return count + 1
async def get_top_referrals(session: AsyncSession, limit: int = 5):
query = (
select(Referral.referrer_tg_id, func.count().label("referral_count"))
.group_by(Referral.referrer_tg_id)
.order_by(desc("referral_count"))
.limit(limit)
)
result = await session.execute(query)
return [
{"referrer_tg_id": row.referrer_tg_id, "referral_count": row.referral_count}
for row in result.all()
]
+120
View File
@@ -0,0 +1,120 @@
from sqlalchemy import delete, insert, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Server
from logger import logger
async def create_server(
session: AsyncSession,
cluster_name: str,
server_name: str,
api_url: str,
subscription_url: str,
inbound_id: str,
):
try:
stmt = insert(Server).values(
cluster_name=cluster_name,
server_name=server_name,
api_url=api_url,
subscription_url=subscription_url,
inbound_id=inbound_id,
)
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Сервер {server_name} добавлен в кластер {cluster_name}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении сервера {server_name}: {e}")
await session.rollback()
raise
async def delete_server(session: AsyncSession, server_name: str):
try:
stmt = delete(Server).where(Server.server_name == server_name)
await session.execute(stmt)
await session.commit()
logger.info(f"🗑 Сервер {server_name} удалён")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при удалении сервера {server_name}: {e}")
await session.rollback()
raise
async def get_servers(session: AsyncSession, include_enabled: bool = False) -> dict:
try:
stmt = select(Server)
result = await session.execute(stmt)
servers = result.scalars().all()
grouped = {}
for s in servers:
if not include_enabled and not s.enabled:
continue
cluster = s.cluster_name
grouped.setdefault(cluster, []).append(
{
"server_name": s.server_name,
"api_url": s.api_url,
"subscription_url": s.subscription_url,
"inbound_id": s.inbound_id,
"panel_type": s.panel_type,
"enabled": s.enabled,
"max_keys": s.max_keys,
"tariff_group": s.tariff_group,
"cluster_name": cluster,
}
)
return grouped
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении серверов: {e}")
return {}
async def get_clusters(session: AsyncSession) -> list[str]:
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
result = await session.execute(stmt)
return [r[0] for r in result.all()]
async def check_unique_server_name(
session: AsyncSession, server_name: str, cluster_name: str | None = None
) -> bool:
stmt = select(Server).where(Server.server_name == server_name)
if cluster_name:
stmt = stmt.where(Server.cluster_name == cluster_name)
result = await session.execute(stmt.limit(1))
return result.scalar_one_or_none() is None
async def check_server_name_by_cluster(
session: AsyncSession, server_name: str
) -> dict | None:
try:
result = await session.execute(
select(Server.cluster_name).where(Server.server_name == server_name)
)
row = result.first()
return {"cluster_name": row[0]} if row else None
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске кластера для сервера {server_name}: {e}")
return None
async def get_cluster_name_by_server(
session: AsyncSession, server_id_or_name: str
) -> str | None:
stmt = (
select(Server.cluster_name)
.where(
(Server.id == server_id_or_name) | (Server.server_name == server_id_or_name)
)
.limit(1)
)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return row
+169
View File
@@ -0,0 +1,169 @@
from datetime import date, datetime
from sqlalchemy import and_, func, not_, select, exists
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Referral, Tariff, User
async def count_total_users(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(User))
async def count_users_updated_today(session: AsyncSession, today: date) -> int:
return await session.scalar(
select(func.count()).select_from(User).where(User.updated_at >= today)
)
async def count_users_registered_since(session: AsyncSession, since: date) -> int:
return await session.scalar(
select(func.count()).select_from(User).where(User.created_at >= since)
)
async def count_users_registered_between(
session: AsyncSession, start: date, end: date
) -> int:
return await session.scalar(
select(func.count())
.select_from(User)
.where(User.created_at >= start, User.created_at < end)
)
async def count_total_keys(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(Key))
async def count_active_keys(session: AsyncSession) -> int:
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
return await session.scalar(
select(func.count()).select_from(Key).where(Key.expiry_time > current_time_ms)
)
async def count_trial_keys(session: AsyncSession) -> int:
subquery_success_payments = (
select(Payment.tg_id)
.where(and_(Payment.tg_id == Key.tg_id, Payment.status == "success"))
.exists()
)
return await session.scalar(
select(func.count()).select_from(Key).where(not_(subquery_success_payments))
)
async def get_tariff_distribution(
session: AsyncSession, include_unbound: bool = False
) -> tuple[list[tuple[int, int]], list[dict]]:
result = await session.execute(
select(Key.tariff_id, func.count(Key.client_id))
.where(Key.tariff_id.isnot(None))
.group_by(Key.tariff_id)
)
tariff_counts = result.all()
if not include_unbound:
return tariff_counts
result = await session.execute(
select(Key.expiry_time)
.where(Key.tariff_id.is_(None))
)
no_tariff_keys = [{"expiry_time": row[0]} for row in result.all()]
return tariff_counts, no_tariff_keys
async def get_tariff_names(
session: AsyncSession, tariff_ids: list[int]
) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(
select(Tariff.id, Tariff.name).where(Tariff.id.in_(tariff_ids))
)
return dict(result.all())
async def get_tariff_groups(
session: AsyncSession, tariff_ids: list[int]
) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(
select(Tariff.id, Tariff.group_code).where(Tariff.id.in_(tariff_ids))
)
return dict(result.all())
async def get_tariff_durations(
session: AsyncSession, tariff_ids: list[int]
) -> dict[int, int]:
if not tariff_ids:
return {}
result = await session.execute(
select(Tariff.id, Tariff.duration_days).where(Tariff.id.in_(tariff_ids))
)
return dict(result.all())
async def count_total_referrals(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(Referral))
async def sum_payments_since(session: AsyncSession, since: date) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.created_at >= since,
Payment.payment_system.notin_(["referral", "coupon"])
)
)
)
return round(float(result), 2)
async def sum_payments_between(session: AsyncSession, start: date, end: date) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.created_at >= start,
Payment.created_at < end,
Payment.payment_system.notin_(["referral", "coupon"])
)
)
)
return round(float(result), 2)
async def sum_total_payments(session: AsyncSession) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
Payment.payment_system.notin_(["referral", "coupon"])
)
)
return round(float(result), 2)
async def count_hot_leads(session: AsyncSession) -> int:
subquery_active_keys = (
select(Key.tg_id)
.where(Key.expiry_time > int(datetime.utcnow().timestamp() * 1000))
.distinct()
)
stmt = (
select(Payment.tg_id)
.where(Payment.status == "success")
.where(not_(exists(subquery_active_keys.where(Key.tg_id == Payment.tg_id))))
.distinct()
)
result = await session.execute(select(func.count()).select_from(stmt.subquery()))
return result.scalar()
+130
View File
@@ -0,0 +1,130 @@
from datetime import datetime
from sqlalchemy import delete, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Server, Tariff
from logger import logger
async def get_tariffs(
session: AsyncSession, tariff_id: int = None, group_code: str = None
):
try:
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
elif group_code:
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code)
)
else:
result = await session.execute(select(Tariff))
return [dict(r.__dict__) for r in result.scalars().all()]
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
return []
async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
try:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
return dict(tariff.__dict__) if tariff else None
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифа по ID {tariff_id}: {e}")
return None
async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str):
try:
server_row = await session.execute(
select(Server.tariff_group)
.where(Server.cluster_name == cluster_name)
.limit(1)
)
row = server_row.first()
if not row:
server_row = await session.execute(
select(Server.tariff_group)
.where(Server.server_name == cluster_name)
.limit(1)
)
row = server_row.first()
if not row or not row[0]:
return []
group_code = row[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.id)
)
return [dict(r.__dict__) for r in result.scalars().all()]
except SQLAlchemyError as e:
logger.error(
f"[TARIFF] Ошибка при получении тарифов для кластера {cluster_name}: {e}"
)
return []
async def create_tariff(session: AsyncSession, data: dict):
try:
data["created_at"] = datetime.utcnow()
data["updated_at"] = datetime.utcnow()
stmt = insert(Tariff).values(**data).returning(Tariff)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one()
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при создании тарифа: {e}")
await session.rollback()
return None
async def update_tariff(session: AsyncSession, tariff_id: int, updates: dict):
if not updates:
return False
try:
updates["updated_at"] = datetime.utcnow()
await session.execute(
update(Tariff).where(Tariff.id == tariff_id).values(**updates)
)
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при обновлении тарифа ID={tariff_id}: {e}")
await session.rollback()
return False
async def delete_tariff(session: AsyncSession, tariff_id: int):
try:
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при удалении тарифа ID={tariff_id}: {e}")
await session.rollback()
return False
async def check_tariff_exists(session: AsyncSession, tariff_id: int):
try:
result = await session.execute(
select(Tariff)
.where(Tariff.id == tariff_id, Tariff.is_active.is_(True))
)
tariff = result.scalar_one_or_none()
if tariff:
logger.info(f"[TARIFF] Тариф {tariff_id} найден в БД: {tariff.group_code}")
return True
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
return False
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
return False
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import TemporaryData
from logger import logger
async def create_temporary_data(
session: AsyncSession, tg_id: int, state: str, data: dict
):
try:
stmt = (
insert(TemporaryData)
.values(tg_id=tg_id, state=state, data=data, updated_at=datetime.utcnow())
.on_conflict_do_update(
index_elements=[TemporaryData.tg_id],
set_={"state": state, "data": data, "updated_at": datetime.utcnow()},
)
)
await session.execute(stmt)
await session.commit()
logger.info(f"📝 Временные данные сохранены для {tg_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении временных данных для {tg_id}: {e}")
await session.rollback()
async def get_temporary_data(session: AsyncSession, tg_id: int) -> dict | None:
stmt = select(TemporaryData).where(TemporaryData.tg_id == tg_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row:
return {"state": row.state, "data": row.data}
return None
async def clear_temporary_data(session: AsyncSession, tg_id: int):
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
await session.commit()
logger.info(f"🗑 Временные данные очищены для {tg_id}")
+134
View File
@@ -0,0 +1,134 @@
from sqlalchemy import func, insert, select, not_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Payment, TrackingSource, User
from logger import logger
async def create_tracking_source(
session: AsyncSession, name: str, code: str, type_: str, created_by: int
):
try:
stmt = insert(TrackingSource).values(
name=name,
code=code,
type=type_,
created_by=created_by,
)
await session.execute(stmt)
await session.commit()
logger.info(f"🆕 Источник трафика {code} создан")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при создании источника {code}: {e}")
await session.rollback()
async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
registrations_subq = (
select(func.count(func.distinct(User.tg_id)))
.where(User.source_code == TrackingSource.code)
.correlate(TrackingSource)
.scalar_subquery()
)
trials_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == TrackingSource.code) & (User.trial == 1))
.correlate(TrackingSource)
.scalar_subquery()
)
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == TrackingSource.code) & (Payment.status == "success")
)
.correlate(TrackingSource)
.scalar_subquery()
)
query = select(
TrackingSource.code,
TrackingSource.name,
TrackingSource.created_at,
registrations_subq.label("registrations"),
trials_subq.label("trials"),
payments_subq.label("payments"),
).order_by(TrackingSource.created_at.desc())
result = await session.execute(query)
rows = result.all()
return [
{
"code": r.code,
"name": r.name,
"created_at": r.created_at,
"registrations": r.registrations or 0,
"trials": r.trials or 0,
"payments": r.payments or 0,
}
for r in rows
]
async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict | None:
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
.where(User.source_code == code)
.scalar_subquery()
)
trial_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.trial == 1))
.scalar_subquery()
)
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral"]))
)
.scalar_subquery()
)
amount_subq = (
select(func.coalesce(func.sum(Payment.amount), 0))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral"]))
)
.scalar_subquery()
)
query = select(
TrackingSource.name,
TrackingSource.code,
TrackingSource.created_at,
reg_subq.label("registrations"),
trial_subq.label("trials"),
payments_subq.label("payments"),
amount_subq.label("total_amount"),
).where(TrackingSource.code == code)
result = await session.execute(query)
row = result.first()
if not row:
return None
return {
"name": row.name,
"code": row.code,
"created_at": row.created_at,
"registrations": row.registrations or 0,
"trials": row.trials or 0,
"payments": row.payments or 0,
"total_amount": float(row.total_amount or 0),
}
+209
View File
@@ -0,0 +1,209 @@
from datetime import datetime
from sqlalchemy import delete, exists, or_, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.keys import delete_key
from database.models import (
BlockedUser,
CouponUsage,
Gift,
Notification,
Payment,
Referral,
TemporaryData,
User,
)
from logger import logger
async def add_user(
session: AsyncSession,
tg_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language_code: str = None,
is_bot: bool = False,
source_code: str = None,
):
try:
stmt = (
insert(User)
.values(
tg_id=tg_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
is_bot=is_bot,
source_code=source_code,
)
.on_conflict_do_nothing(index_elements=[User.tg_id])
)
await session.execute(stmt)
await session.commit()
logger.info(
f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})"
)
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}")
await session.rollback()
raise
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
try:
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
current = result.scalar_one_or_none() or 0
new_balance = current + amount
await session.execute(
update(User).where(User.tg_id == tg_id).values(balance=new_balance)
)
await session.commit()
logger.info(
f"[DB] Баланс пользователя {tg_id} обновлён: {current}{new_balance}"
)
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
await session.rollback()
async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
stmt = select(exists().where(User.tg_id == tg_id))
result = await session.execute(stmt)
return result.scalar()
async def get_balance(session: AsyncSession, tg_id: int) -> float:
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
balance = result.scalar_one_or_none()
return round(balance, 1) if balance is not None else 0.0
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
try:
await session.execute(
update(User).where(User.tg_id == tg_id).values(balance=balance)
)
await session.commit()
except SQLAlchemyError as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
await session.rollback()
async def update_trial(session: AsyncSession, tg_id: int, status: int):
try:
await session.execute(
update(User).where(User.tg_id == tg_id).values(trial=status)
)
await session.commit()
logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}")
await session.rollback()
async def get_trial(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(select(User.trial).where(User.tg_id == tg_id))
trial = result.scalar_one_or_none()
return trial or 0
async def upsert_user(
session: AsyncSession,
tg_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language_code: str = None,
is_bot: bool = False,
only_if_exists: bool = False,
) -> dict | None:
try:
if only_if_exists:
result = await session.execute(select(User).where(User.tg_id == tg_id))
user = result.scalar_one_or_none()
if not user:
return None
await session.execute(
update(User)
.where(User.tg_id == tg_id)
.values(
username=username or user.username,
first_name=first_name or user.first_name,
last_name=last_name or user.last_name,
language_code=language_code or user.language_code,
is_bot=is_bot,
updated_at=datetime.utcnow(),
)
)
else:
await session.execute(
insert(User)
.values(
tg_id=tg_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
is_bot=is_bot,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
.on_conflict_do_update(
index_elements=[User.tg_id],
set_={
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": language_code,
"is_bot": is_bot,
"updated_at": datetime.utcnow(),
},
)
)
await session.commit()
result = await session.execute(select(User).where(User.tg_id == tg_id))
return dict(result.scalar_one().__dict__)
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
await session.rollback()
raise
async def delete_user_data(session: AsyncSession, tg_id: int):
try:
await session.execute(delete(Notification).where(Notification.tg_id == tg_id))
await session.execute(delete(Gift).where(Gift.sender_tg_id == tg_id))
await session.execute(
update(Gift)
.where(Gift.recipient_tg_id == tg_id)
.values(recipient_tg_id=None)
)
await session.execute(delete(Payment).where(Payment.tg_id == tg_id))
await session.execute(
delete(Referral).where(
or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id)
)
)
await session.execute(delete(CouponUsage).where(CouponUsage.user_id == tg_id))
await delete_key(session, tg_id)
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
await session.execute(delete(User).where(User.tg_id == tg_id))
await session.commit()
logger.info(f"[DB] Данные пользователя {tg_id} полностью удалены")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
await session.rollback()
raise
async def mark_trial_extended(tg_id: int, session: AsyncSession):
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
await session.commit()
-1
View File
@@ -15,7 +15,6 @@ from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
router = Router(name="handlers_main_router")
router.include_routers(
+2 -1
View File
@@ -13,9 +13,9 @@ from .restart import router as restart_router
from .sender import router as sender_router
from .servers import router as servers_router
from .stats import router as stats_router
from .tariffs import router as tariffs_router
from .users import router as users_router
router = Router(name="admins_main_router")
router.include_routers(
@@ -31,4 +31,5 @@ router.include_routers(
restart_router,
bans_router,
ads_router,
tariffs_router,
)
+73 -37
View File
@@ -1,15 +1,13 @@
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 sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import USERNAME_BOT
from database import (
create_tracking_source,
get_all_tracking_sources,
get_tracking_source_stats,
)
from database import create_tracking_source, get_tracking_source_stats
from database.models import TrackingSource, User
from filters.admin import IsAdminFilter
from logger import logger
@@ -23,7 +21,6 @@ from .keyboard import (
build_cancel_input_kb,
)
router = Router()
@@ -34,7 +31,9 @@ class AdminAdsState(StatesGroup):
@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())
await callback_query.message.edit_text(
text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb()
)
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
@@ -51,12 +50,15 @@ async def handle_ads_name_input(message: Message, state: FSMContext):
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()
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):
async def handle_ads_code_input(
message: Message, state: FSMContext, session: AsyncSession
):
code = message.text.strip()
data = await state.get_data()
name = data["name"]
@@ -64,9 +66,13 @@ async def handle_ads_code_input(message: Message, state: FSMContext, session):
try:
await create_tracking_source(
name=name, code=code_with_prefix, type_="utm", created_by=message.from_user.id, session=session
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)
stats = await get_tracking_source_stats(session, code_with_prefix)
if not stats:
await message.answer("❌ Источник не найден или не содержит данных.")
return
@@ -77,44 +83,59 @@ async def handle_ads_code_input(message: Message, state: FSMContext, session):
)
except Exception as e:
logger.error(f"Ошибка при создании ссылки: {e}")
logger.error(f"Ошибка при создании ссылки: {e}", exc_info=True)
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):
async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession):
try:
ads = await get_all_tracking_sources(session)
result = await session.execute(
select(TrackingSource).order_by(TrackingSource.created_at.desc())
)
ads = result.scalars().all()
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("Произошла ошибка при получении списка.")
logger.error(f"Ошибка при получении списка UTM: {e}", exc_info=True)
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):
async def handle_ads_view(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
code = callback_data.code
try:
stats = await get_tracking_source_stats(code, session)
stats = await get_tracking_source_stats(session, code)
if not stats:
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
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
text=msg, reply_markup=build_ads_stats_kb(code)
)
except Exception as e:
logger.error(f"Ошибка при просмотре статистики: {e}")
logger.error(f"Ошибка при просмотре статистики: {e}", exc_info=True)
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):
@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>?",
@@ -123,17 +144,37 @@ async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
async def handle_ads_delete(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
async def handle_ads_delete(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
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())
await session.execute(
update(User).where(User.source_code == code).values(source_code=None)
)
await session.execute(delete(TrackingSource).where(TrackingSource.code == code))
await session.commit()
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("❌ Не удалось удалить ссылку.")
@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()
)
def format_ads_stats(stats: dict, username_bot: str) -> str:
return (
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
@@ -142,14 +183,9 @@ def format_ads_stats(stats: dict, username_bot: str) -> str:
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"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n\n"
f"💰 <b>Финансовая информация:</b>\n"
f" 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n"
f"└ 💸 <b>Сумма:</b> <b>{round(stats.get('total_amount', 0), 2)} ₽</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())
+27 -8
View File
@@ -12,14 +12,20 @@ class AdminAdsCallback(CallbackData, prefix="admin_ads"):
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.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:
def build_ads_list_kb(
ads: list, current_page: int, total_pages: int
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for ad in ads:
@@ -33,14 +39,18 @@ def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineK
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
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(),
callback_data=AdminAdsCallback(
action="list", code=f"{current_page + 1}"
).pack(),
)
)
if pagination_buttons:
@@ -52,7 +62,10 @@ def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineK
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🗑️ Удалить", callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack())
builder.button(
text="🗑️ Удалить",
callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack(),
)
builder.row(build_admin_back_btn("ads"))
return builder.as_markup()
@@ -66,12 +79,18 @@ def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
code=code,
).pack(),
)
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="view", 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())
builder.button(
text="❌ Отмена",
callback_data=AdminAdsCallback(action="cancel_input", code="none").pack(),
)
return builder.as_markup()
+11 -7
View File
@@ -6,7 +6,6 @@ from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
@@ -18,14 +17,19 @@ async def handle_backups(callback_query: CallbackQuery):
kb = build_admin_back_kb("management")
await callback_query.message.edit_text(
text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
text="💾 Инициализация резервного копирования базы данных...",
reply_markup=kb,
)
exception = await backup_database()
try:
exception = await backup_database()
if exception:
text = f"❌ Ошибка при создании резервной копии: {exception}"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
if exception:
text = f"❌ Ошибка при создании резервной копии:\n<code>{exception}</code>"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
except Exception as e:
text = f"❌ Непредвиденная ошибка: <code>{e}</code>"
await callback_query.message.edit_text(text=text, reply_markup=kb)
+93 -31
View File
@@ -1,57 +1,57 @@
from typing import Any
import csv
import io
from aiogram import F, Router
from aiogram.types import BufferedInputFile, CallbackQuery
from sqlalchemy import delete, text
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_user_data
from database.models import ManualBan
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_bans_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans"),
IsAdminFilter(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter())
async def handle_bans(callback_query: CallbackQuery):
text = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
await callback_query.message.edit_text(
text=text,
reply_markup=build_bans_kb(),
text_ = (
"🚫 <b>Управление банами</b>\n\n"
"📛 <b>Забанившие бота</b> — пользователи, которые заблокировали бота вручную.\n"
"🔒 <b>Ручной бан</b> — пользователи, которых вы забанили через админку.\n\n"
"⬇ Выберите нужный раздел:"
)
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_export"),
IsAdminFilter(),
AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter()
)
async def handle_bans_export(callback_query: CallbackQuery, session: Any):
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("management")
try:
banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
import csv
import io
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
banned_users = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "blocked_at"])
writer.writerow(["tg_id"])
for user in banned_users:
writer.writerow([user["tg_id"], user["blocked_at"]])
writer.writerow([user.tg_id])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
document = BufferedInputFile(
file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv"
)
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт пользователей, заблокировавших бота в CSV",
caption="📥 Экспорт пользователей, заблокировавших бота (CSV)",
)
except Exception as e:
await callback_query.message.answer(
@@ -61,15 +61,16 @@ async def handle_bans_export(callback_query: CallbackQuery, session: Any):
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_banned"),
IsAdminFilter(),
AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter()
)
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any):
async def handle_bans_delete_banned(
callback_query: CallbackQuery, session: AsyncSession
):
kb = build_admin_back_kb("bans")
try:
blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
blocked_ids = [record["tg_id"] for record in blocked_users]
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
blocked_users = result.all()
blocked_ids = [user.tg_id for user in blocked_users]
if not blocked_ids:
await callback_query.message.answer(
@@ -81,7 +82,11 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
for tg_id in blocked_ids:
await delete_user_data(session, tg_id)
await session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids)
await session.execute(
text("DELETE FROM blocked_users WHERE tg_id = ANY(:blocked_ids)"),
{"blocked_ids": blocked_ids},
)
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
@@ -92,3 +97,60 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
text=f"❗ Произошла ошибка при удалении записей: {e}",
reply_markup=kb,
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter()
)
async def handle_manual_bans_export(
callback_query: CallbackQuery, session: AsyncSession
):
build_admin_back_kb("bans")
try:
result = await session.execute(
text("SELECT tg_id, banned_at, reason, until FROM manual_bans")
)
rows = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "banned_at", "reason", "until"])
for user in rows:
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
csv_output.seek(0)
document = BufferedInputFile(
file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv"
)
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт вручную забаненных пользователей",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Ошибка при экспорте: {e}",
reply_markup=build_admin_back_kb("bans"),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter()
)
async def handle_delete_manual_banned(
callback_query: CallbackQuery, session: AsyncSession
):
try:
await session.execute(delete(ManualBan))
await session.commit()
await callback_query.message.edit_text(
"🗑️ Вручную забаненные пользователи удалены.",
reply_markup=build_bans_kb(),
)
logger.info("[BANS] Очищены записи из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
await callback_query.message.edit_text(
"❌ Ошибка при удалении вручную забаненных пользователей."
)
+22 -4
View File
@@ -4,10 +4,28 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_bans_kb() -> InlineKeyboardMarkup:
def build_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(text="📄 Выгрузить в CSV", callback_data=AdminPanelCallback(action="bans_export").pack())
builder.button(text="🗑️ Удалить из БД", callback_data=AdminPanelCallback(action="bans_delete_banned").pack())
builder.row(build_admin_back_btn("management"))
builder.button(
text="📛 Забанившие бота",
callback_data=AdminPanelCallback(action="bans_export").pack(),
)
builder.button(
text="📛 Забаненные вручную",
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
)
builder.button(
text="🗑️ Удалить забанивших",
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
)
builder.button(
text="🗑️ Очистить вручную забаненных",
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
)
builder.button(
text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack()
)
builder.adjust(1)
return builder.as_markup()
File diff suppressed because it is too large Load Diff
+96 -23
View File
@@ -2,8 +2,6 @@ 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 AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
@@ -18,16 +16,25 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
cluster_names = list(servers.keys())
for i in range(0, len(cluster_names), 2):
builder.row(*[
InlineKeyboardButton(
text=f"⚙️ {name}",
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
row_buttons = []
for name in cluster_names[i : i + 2]:
servers_in_cluster = servers[name]
all_disabled = all(not s["enabled"] for s in servers_in_cluster)
label = f"{name} (отключен)" if all_disabled else f"⚙️ {name}"
row_buttons.append(
InlineKeyboardButton(
text=label,
callback_data=AdminClusterCallback(
action="manage", data=name
).pack(),
)
)
for name in cluster_names[i : i + 2]
])
builder.row(*row_buttons)
builder.row(
InlineKeyboardButton(text=" Добавить кластер", callback_data=AdminClusterCallback(action="add").pack())
InlineKeyboardButton(
text=" Добавить кластер",
callback_data=AdminClusterCallback(action="add").pack(),
)
)
builder.row(build_admin_back_btn())
@@ -35,14 +42,18 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
return builder.as_markup()
def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
def build_manage_cluster_kb(
cluster_servers: list, cluster_name: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🌍 {server['server_name']}",
callback_data=AdminServerCallback(action="manage", data=server["server_name"]).pack(),
callback_data=AdminServerCallback(
action="manage", data=server["server_name"]
).pack(),
)
)
@@ -53,7 +64,15 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
)
)
builder.row(build_admin_back_btn("clusters"))
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(
action="manage", data=cluster_name
).pack(),
)
)
return builder.as_markup()
@@ -69,7 +88,9 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder.row(
InlineKeyboardButton(
text="🌐 Доступность",
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
callback_data=AdminClusterCallback(
action="availability", data=cluster_name
).pack(),
)
)
builder.row(
@@ -81,41 +102,65 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап",
callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
callback_data=AdminClusterCallback(
action="backup", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Добавить время",
callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
callback_data=AdminClusterCallback(
action="add_time", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✏️ Сменить название",
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
callback_data=AdminClusterCallback(
action="rename", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(
action="set_tariff", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
def build_sync_cluster_kb(
cluster_servers: list, cluster_name: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🔄 Синхронизировать {server['server_name']}",
callback_data=AdminClusterCallback(action="sync-server", data=server["server_name"]).pack(),
callback_data=AdminClusterCallback(
action="sync-server", data=server["server_name"]
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📍 Синхронизировать кластер",
callback_data=AdminClusterCallback(action="sync-cluster", data=cluster_name).pack(),
callback_data=AdminClusterCallback(
action="sync-cluster", data=cluster_name
).pack(),
)
)
@@ -126,7 +171,35 @@ def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKey
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.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()
def build_tariff_group_selection_kb(
cluster_name: str, groups: list[tuple[int, str]]
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(
action="apply_tariff_group", data=f"{cluster_name}|{group_id}"
).pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminClusterCallback(
action="manage", data=cluster_name
).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
+64 -32
View File
@@ -1,10 +1,5 @@
import html
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
@@ -19,19 +14,19 @@ from aiogram.types import (
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
from database import (
create_coupon,
delete_coupon,
get_all_coupons,
)
from database import create_coupon, delete_coupon, get_all_coupons
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.utils import format_days
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb, format_coupons_list
from .keyboard import (
AdminCouponDeleteCallback,
build_coupons_kb,
build_coupons_list_kb,
format_coupons_list,
)
router = Router()
@@ -47,7 +42,9 @@ class AdminCouponsState(StatesGroup):
IsAdminFilter(),
)
async def handle_coupons(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
await callback_query.message.edit_text(
text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb()
)
@router.callback_query(
@@ -67,7 +64,9 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
@router.callback_query(F.data == "coupon_type_balance")
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
async def handle_balance_coupon_selection(
callback_query: CallbackQuery, state: FSMContext
):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
@@ -81,7 +80,9 @@ async def handle_balance_coupon_selection(callback_query: CallbackQuery, state:
@router.callback_query(F.data == "coupon_type_days")
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
async def handle_days_coupon_selection(
callback_query: CallbackQuery, state: FSMContext
):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
@@ -95,7 +96,9 @@ async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSM
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
async def handle_balance_coupon_input(
message: Message, state: FSMContext, session: Any
):
text = message.text.strip()
parts = text.split()
@@ -123,7 +126,7 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
@@ -144,7 +147,9 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
await message.answer(
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
)
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
@@ -176,7 +181,7 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
return
try:
await create_coupon(coupon_code, 0, usage_limit, session, days=days)
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
@@ -197,7 +202,9 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
await message.answer(
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
)
@router.callback_query(
@@ -211,43 +218,66 @@ async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
await update_coupons_list(callback_query.message, session, page)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
await callback_query.message.edit_text("Произошла ошибка при получении списка купонов.")
await callback_query.message.edit_text(
"Произошла ошибка при получении списка купонов."
)
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
@router.callback_query(
AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter()
)
async def handle_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
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(),
)
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
@router.callback_query(
AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter()
)
async def confirm_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
coupon_code = callback_data.coupon_code
confirm = callback_data.confirm
if confirm:
try:
result = await delete_coupon(coupon_code, session)
result = await delete_coupon(session, coupon_code)
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
@@ -310,7 +340,9 @@ 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(),
)
+23 -5
View File
@@ -17,13 +17,27 @@ class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
def build_coupons_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=" Создать купон", callback_data=AdminPanelCallback(action="coupons_create").pack())
builder.button(text="Купоны", callback_data=AdminPanelCallback(action="coupons_list").pack())
builder.row(
InlineKeyboardButton(
text=" Создать купон",
callback_data=AdminPanelCallback(action="coupons_create").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="Купоны",
callback_data=AdminPanelCallback(action="coupons_list").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
def build_coupons_list_kb(
coupons: list, current_page: int, total_pages: int
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for coupon in coupons:
@@ -38,14 +52,18 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
pagination_buttons.append(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
callback_data=AdminPanelCallback(
action="coupons_list", page=current_page - 1
).pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(action="coupons_list", page=current_page + 1).pack(),
callback_data=AdminPanelCallback(
action="coupons_list", page=current_page + 1
).pack(),
)
)
if pagination_buttons:
+29 -7
View File
@@ -8,13 +8,35 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_management_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="💾 Создать резервную копию", callback_data=AdminPanelCallback(action="backups").pack())
builder.button(text="🚫 Заблокировавшие бота", callback_data=AdminPanelCallback(action="bans").pack())
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.button(
text="💾 Создать резервную копию",
callback_data=AdminPanelCallback(action="backups").pack(),
)
builder.button(
text="📛 Управление банами",
callback_data=AdminPanelCallback(action="bans").pack(),
)
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)
+36 -20
View File
@@ -2,15 +2,16 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from asyncpg import Connection
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key
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
from middlewares import maintenance
router = Router()
@@ -19,7 +20,9 @@ class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
@router.callback_query(
AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()
)
async def handle_management(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤖 Управление ботом",
@@ -27,7 +30,9 @@ async def handle_management(callback_query: CallbackQuery):
)
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
@router.callback_query(
AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter()
)
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
"""Запрашивает у администратора новый домен."""
await state.set_state(AdminManagementStates.waiting_for_new_domain)
@@ -37,10 +42,14 @@ async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
@router.message(AdminManagementStates.waiting_for_new_domain)
async def process_new_domain(message: Message, state: FSMContext, session: Connection):
async def process_new_domain(
message: Message, state: FSMContext, session: AsyncSession
):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
logger.info(
f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'"
)
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
@@ -53,35 +62,42 @@ async def process_new_domain(message: Message, state: FSMContext, session: Conne
new_domain_url = f"https://{new_domain}"
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
query = """
UPDATE keys
SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT)
WHERE key NOT LIKE $1 || '%'
"""
try:
await session.execute(query, new_domain_url)
stmt = (
update(Key)
.where(~Key.key.startswith(new_domain_url))
.values(key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url))
)
await session.execute(stmt)
await session.commit()
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin"))
await message.answer(
f"❌ Ошибка при обновлении домена: {e}",
reply_markup=build_admin_back_kb("admin"),
)
return
try:
sample = await session.fetchrow("SELECT key FROM keys LIMIT 1")
logger.info(f"[DomainChange] Пример обновленной записи: {sample}")
sample = await session.execute(select(Key.key).limit(1))
example = sample.scalar()
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
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())
await callback.message.edit_reply_markup(reply_markup=build_management_kb())
+42 -12
View File
@@ -19,28 +19,56 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
def build_panel_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack())
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="management").pack()),
builder.button(
text="👤 Поиск пользователя",
callback_data=AdminPanelCallback(action="search_user").pack(),
)
builder.button(
text="🔑 Поиск по названию ключа",
callback_data=AdminPanelCallback(action="search_key").pack(),
)
builder.row(
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟 Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
InlineKeyboardButton(
text="🖥 Серверы", callback_data=AdminPanelCallback(action="clusters").pack()
),
InlineKeyboardButton(
text="🤖 Управление",
callback_data=AdminPanelCallback(action="management").pack(),
),
)
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
InlineKeyboardButton(
text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()
),
InlineKeyboardButton(
text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()
),
)
builder.row(
InlineKeyboardButton(
text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()
)
)
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, 2, 2, 1)
builder.adjust(1, 1, 2, 2, 1, 2, 1)
return builder.as_markup()
def build_restart_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="✅ Да, перезагрузить", callback_data=AdminPanelCallback(action="restart_confirm").pack())
builder.button(
text="✅ Да, перезагрузить",
callback_data=AdminPanelCallback(action="restart_confirm").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
@@ -61,4 +89,6 @@ def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
return InlineKeyboardButton(
text=text, callback_data=AdminPanelCallback(action=action).pack()
)
+15 -6
View File
@@ -10,22 +10,27 @@ from logger import logger
from .keyboard import AdminPanelCallback, build_panel_kb
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
await state.clear()
if callback_query.message.text:
try:
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
await callback_query.message.edit_text(
text=text,
reply_markup=build_panel_kb(),
disable_web_page_preview=True,
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
logger.warning(
"🔄 Попытка редактировать сообщение без изменений — пропущено."
)
else:
raise
else:
@@ -34,7 +39,11 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
await callback_query.message.answer(
text=text,
reply_markup=build_panel_kb(),
disable_web_page_preview=True,
)
@router.callback_query(F.data == "admin", IsAdminFilter())
@@ -44,7 +53,7 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
@router.message(Command("admin"), IsAdminFilter())
async def handle_admin_message(message: Message, state: FSMContext):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
await state.clear()
await message.answer(text=text, reply_markup=build_panel_kb())
+31 -25
View File
@@ -1,5 +1,9 @@
import asyncio
import os
import subprocess
import sys
import psutil
from aiogram import F, Router
from aiogram.types import CallbackQuery
@@ -7,36 +11,38 @@ from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart"),
IsAdminFilter(),
AdminPanelCallback.filter(F.action == "restart"), IsAdminFilter()
)
async def handle_restart(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤔 Вы уверены, что хотите перезагрузить бота?",
reply_markup=build_restart_kb(),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart_confirm"),
IsAdminFilter(),
)
async def handle_restart_confirm(callback_query: CallbackQuery):
async def handle_restart_confirm(
callback_query: CallbackQuery, callback_data: AdminPanelCallback
):
kb = build_admin_back_kb()
await callback_query.message.edit_text("🔄 Перезапускаем бота...", reply_markup=kb)
asyncio.create_task(restart_bot())
async def restart_bot():
await asyncio.sleep(1)
try:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
capture_output=True,
text=True,
)
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
except subprocess.CalledProcessError:
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
)
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception as e:
await callback_query.message.edit_text(text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", reply_markup=kb)
print(f"[Restart] Ошибка при перезапуске: {e}")
os._exit(1)
+30 -7
View File
@@ -13,19 +13,39 @@ class AdminSenderCallback(CallbackData, prefix="admin_sender"):
def build_sender_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👥 Все пользователи", callback_data=AdminSenderCallback(type="all").pack()))
builder.row(
InlineKeyboardButton(text="С подпиской", callback_data=AdminSenderCallback(type="subscribed").pack()),
InlineKeyboardButton(text="❌ Без подписки", callback_data=AdminSenderCallback(type="unsubscribed").pack()),
InlineKeyboardButton(
text="👥 Все пользователи",
callback_data=AdminSenderCallback(type="all").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📍 Не использовавшие триал", callback_data=AdminSenderCallback(type="untrial").pack()
text="С подпиской",
callback_data=AdminSenderCallback(type="subscribed").pack(),
),
InlineKeyboardButton(
text="❌ Без подписки",
callback_data=AdminSenderCallback(type="unsubscribed").pack(),
),
)
builder.row(
InlineKeyboardButton(
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())
InlineKeyboardButton(
text="🔥 Горячие лиды",
callback_data=AdminSenderCallback(type="hotleads").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📢 Кластер",
callback_data=AdminSenderCallback(type="cluster-select").pack(),
)
)
builder.row(build_admin_back_btn())
@@ -37,7 +57,10 @@ def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
for cluster in clusters:
name = cluster["cluster_name"]
builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
builder.button(
text=f"🌐 {name}",
callback_data=AdminSenderCallback(type="cluster", data=name).pack(),
)
builder.adjust(2)
builder.row(build_admin_back_btn())
+68 -63
View File
@@ -1,18 +1,19 @@
from datetime import datetime
from typing import Any
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Server, User
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
router = Router()
@@ -50,8 +51,10 @@ async def handle_sender_callback_text(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server.cluster_name).distinct())
clusters = result.mappings().all()
await callback_query.message.answer(
"✍️ Выберите кластер для рассылки сообщений:",
reply_markup=build_clusters_kb(clusters),
@@ -59,13 +62,14 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session: Any):
"""
Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
"""
async def handle_message_input(message: Message, state: FSMContext, session):
text_message = message.html_text if message.text else None
photo = message.photo[-1].file_id if message.photo else None
photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
photo_url = (
message.caption
if message.photo and message.caption and message.caption.startswith("http")
else None
)
if not text_message and message.caption:
text_message = message.caption
@@ -76,85 +80,86 @@ 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_ms = int(datetime.utcnow().timestamp() * 1000)
now = int(datetime.utcnow().timestamp() * 1000)
query = None
if send_to == "subscribed":
tg_ids = await session.fetch(
"""
SELECT DISTINCT u.tg_id
FROM users u
JOIN keys k ON u.tg_id = k.tg_id
WHERE k.expiry_time > $1
""",
now,
)
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
elif send_to == "unsubscribed":
tg_ids = await session.fetch(
"""
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
""",
now,
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id))
elif send_to == "untrial":
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users WHERE tg_id NOT IN (SELECT tg_id FROM keys)")
subquery = select(Key.tg_id)
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery))
elif send_to == "cluster":
cluster_name = state_data.get("cluster_name")
tg_ids = await session.fetch(
"""
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,
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
)
elif send_to == "hotleads":
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
"""
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(~User.tg_id.in_(subquery))
)
else:
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users")
query = select(distinct(User.tg_id))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
total_users = len(tg_ids)
success_count = 0
text = f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
await message.answer(
f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
)
await message.answer(text=text)
for record in tg_ids:
tg_id = record["tg_id"]
for tg_id in tg_ids:
try:
if photo or photo_url:
await message.bot.send_photo(
chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
chat_id=tg_id,
photo=photo or photo_url,
caption=text_message,
)
else:
await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
await message.bot.send_message(
chat_id=tg_id, text=text_message, parse_mode="HTML"
)
success_count += 1
except Exception as e:
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
text = (
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f" <b>Доставлено:</b> {success_count}\n"
f" <b>Не доставлено:</b> {total_users - success_count}"
await message.answer(
text=(
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f" <b>Доставлено:</b> {success_count}\n"
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
),
reply_markup=build_admin_back_kb("sender"),
)
await message.answer(text=text, reply_markup=build_admin_back_kb("sender"))
await state.clear()
+23 -9
View File
@@ -10,27 +10,41 @@ class AdminServerCallback(CallbackData, prefix="admin_server"):
data: str
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
from ..clusters.keyboard import AdminClusterCallback
def build_manage_server_kb(
server_name: str, cluster_name: str, enabled: bool
) -> InlineKeyboardMarkup:
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()
text=toggle_text,
callback_data=AdminServerCallback(
action=toggle_action, 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()
text="📈 Задать лимит",
callback_data=AdminServerCallback(action="set_limit", data=server_name).pack(),
)
builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_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=f"cluster_servers|{cluster_name}",
)
builder.adjust(1)
return builder.as_markup()
+156 -70
View File
@@ -1,21 +1,18 @@
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 sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers
from database.models import Key, Server
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminServerCallback,
build_manage_server_kb,
)
from .keyboard import AdminServerCallback, build_manage_server_kb
router = Router()
@@ -24,13 +21,25 @@ 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):
@router.callback_query(
AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()
)
async def handle_server_manage(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
session: AsyncSession,
):
server_name = callback_data.data
servers = await get_servers(include_enabled=True)
servers = await get_servers(session=session, 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)
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
(None, None),
)
if server:
@@ -50,28 +59,32 @@ async def handle_server_manage(callback_query: CallbackQuery, callback_data: Adm
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=server.get("enabled", True)
),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@router.callback_query(AdminServerCallback.filter(F.action == "delete"), IsAdminFilter())
@router.callback_query(
AdminServerCallback.filter(F.action == "delete"), IsAdminFilter()
)
async def process_callback_delete_server(
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext, session: Any
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
state: FSMContext,
session: AsyncSession,
):
from ..clusters.clusters_handler import AdminClusterStates
server_name = callback_data.data
servers = await get_servers(session)
servers_dict = await get_servers(session)
cluster_name = None
for c_name, server_list in servers.items():
for server in server_list:
if server["server_name"] == server_name:
cluster_name = c_name
break
if cluster_name:
for c_name, server_list in servers_dict.items():
if any(s["server_name"] == server_name for s in server_list):
cluster_name = c_name
break
if not cluster_name:
@@ -81,32 +94,41 @@ async def process_callback_delete_server(
)
return
keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", server_name)
stmt_keys_count = select(func.count()).where(Key.server_id == server_name)
result = await session.execute(stmt_keys_count)
keys_count = result.scalar_one()
if keys_count > 0:
await state.update_data(server_name=server_name, cluster_name=cluster_name)
all_servers = await session.fetch(
"""
SELECT server_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.server_name) as key_count
FROM servers
WHERE server_name != $1
""",
server_name,
subq = (
select(func.count())
.where(Key.server_id == Server.server_name)
.correlate(Server)
.scalar_subquery()
)
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(
Server.server_name != server_name
)
result = await session.execute(stmt_all_servers)
all_servers = result.all()
if all_servers:
builder = InlineKeyboardBuilder()
for server in all_servers:
for s_name, key_count in all_servers:
builder.row(
InlineKeyboardButton(
text=f"{server['server_name']} ({server['key_count']})",
callback_data=f"transfer_to_server|{server['server_name']}|{server_name}",
text=f"{s_name} ({key_count})",
callback_data=f"transfer_to_server|{s_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(),
)
)
@@ -117,42 +139,62 @@ async def process_callback_delete_server(
await state.set_state(AdminClusterStates.waiting_for_server_transfer)
return
remaining_servers = await session.fetchval(
"SELECT COUNT(*) FROM servers WHERE cluster_name = $1 AND server_name != $2", cluster_name, server_name
stmt_remaining = select(func.count()).where(
(Server.cluster_name == cluster_name) & (Server.server_name != server_name)
)
result = await session.execute(stmt_remaining)
remaining_servers = result.scalar_one()
if remaining_servers == 0:
other_clusters = await session.fetch(
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1", cluster_name
stmt_other_clusters = (
select(Server.cluster_name)
.distinct()
.where(Server.cluster_name != cluster_name)
)
result = await session.execute(stmt_other_clusters)
other_clusters = result.scalars().all()
if other_clusters:
cluster_keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", cluster_name)
stmt_cluster_keys = select(func.count()).where(
Key.server_id == cluster_name
)
result = await session.execute(stmt_cluster_keys)
cluster_keys_count = result.scalar_one()
if cluster_keys_count > 0:
await state.update_data(server_name=server_name, cluster_name=cluster_name)
all_clusters = await session.fetch(
"""
SELECT cluster_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.cluster_name) as key_count
FROM servers
WHERE cluster_name != $1
GROUP BY cluster_name
""",
cluster_name,
await state.update_data(
server_name=server_name, cluster_name=cluster_name
)
subq_cluster = (
select(func.count())
.where(Key.server_id == Server.cluster_name)
.correlate(Server)
.scalar_subquery()
)
stmt_all_clusters = (
select(Server.cluster_name, subq_cluster.label("key_count"))
.where(Server.cluster_name != cluster_name)
.group_by(Server.cluster_name)
)
result = await session.execute(stmt_all_clusters)
all_clusters = result.all()
builder = InlineKeyboardBuilder()
for cluster in all_clusters:
for cl_name, key_count in all_clusters:
builder.row(
InlineKeyboardButton(
text=f"{cluster['cluster_name']} ({cluster['key_count']})",
callback_data=f"transfer_to_cluster|{cluster['cluster_name']}|{cluster_name}|{server_name}",
text=f"{cl_name} ({key_count})",
callback_data=f"transfer_to_cluster|{cl_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(),
)
)
@@ -163,36 +205,57 @@ async def process_callback_delete_server(
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
return
await session.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
stmt_delete = delete(Server).where(
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
)
await session.execute(stmt_delete)
await session.commit()
await callback_query.message.edit_text(
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
reply_markup=build_admin_back_kb("clusters"),
)
else:
await session.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
stmt_delete = delete(Server).where(
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
)
await session.execute(stmt_delete)
await session.commit()
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):
@router.callback_query(
AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter()
)
async def toggle_server_enabled(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
session: AsyncSession,
):
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)
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(enabled=new_status)
)
await session.commit()
servers = await get_servers(include_enabled=True)
servers = await get_servers(session=session, 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)
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
(None, None),
)
if not server:
@@ -212,22 +275,30 @@ async def toggle_server_enabled(callback_query: CallbackQuery, callback_data: Ad
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=new_status),
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):
@router.callback_query(
AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter()
)
async def ask_server_limit(
callback_query: 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(
await callback_query.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):
async def save_server_limit(
message: types.Message, state: FSMContext, session: AsyncSession
):
try:
limit = int(message.text.strip())
if limit < 0:
@@ -237,11 +308,23 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
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)
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(max_keys=new_value)
)
await session.commit()
servers = await get_servers(session=session, 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)
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
(None, None),
)
if not server:
@@ -261,7 +344,10 @@ async def save_server_limit(message: types.Message, state: FSMContext, session:
)
await message.answer(
text, reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True))
text,
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=server.get("enabled", True)
),
)
await state.clear()
+7 -3
View File
@@ -6,20 +6,24 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_stats_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack())
builder.button(
text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack()
)
builder.button(
text="📥 Выгрузить пользователей в CSV",
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
)
builder.button(
text="📥 Выгрузить оплаты в CSV", callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack()
text="📥 Выгрузить оплаты в CSV",
callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack(),
)
builder.button(
text="📥 Выгрузить подписки в CSV",
callback_data=AdminPanelCallback(action="stats_export_keys_csv").pack(),
)
builder.button(
text="📥 Выгрузить горящих лидов", callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack()
text="📥 Выгрузить горящих лидов",
callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
+231 -131
View File
@@ -1,201 +1,301 @@
from datetime import datetime
from typing import Any
from datetime import datetime, timedelta
import pytz
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from collections import Counter
from bot import bot
from config import ADMIN_ID
from database import (
count_active_keys,
count_total_keys,
count_total_referrals,
count_total_users,
count_trial_keys,
count_users_registered_between,
count_users_registered_since,
count_users_updated_today,
get_tariff_distribution,
get_tariff_names,
get_tariff_groups,
get_tariff_durations,
sum_payments_between,
sum_payments_since,
sum_total_payments,
count_hot_leads
)
from filters.admin import IsAdminFilter
from logger import logger
from utils.csv_export import export_hot_leads_csv, export_keys_csv, export_payments_csv, export_users_csv
from utils.csv_export import (
export_hot_leads_csv,
export_keys_csv,
export_payments_csv,
export_users_csv,
)
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_stats_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats"),
IsAdminFilter(),
)
async def handle_stats(callback_query: CallbackQuery, session: Any):
@router.callback_query(AdminPanelCallback.filter(F.action == "stats"), IsAdminFilter())
async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
try:
total_users = await session.fetchval("SELECT COUNT(*) FROM users")
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
total_payments_today = int(
await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE")
)
total_payments_week = int(
await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
)
total_payments_month = int(
await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
)
total_payments_last_month = int(
await session.fetchval(
"""
SELECT COALESCE(SUM(amount), 0)
FROM payments
WHERE created_at >= date_trunc('month', CURRENT_DATE - interval '1 month')
AND created_at < date_trunc('month', CURRENT_DATE)
"""
)
)
total_payments_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
all_keys = await session.fetch("SELECT created_at, expiry_time FROM keys")
def count_subscriptions_by_duration(keys):
periods = {"trial": 0, "1": 0, "3": 0, "6": 0, "12": 0}
for key in keys:
try:
duration_days = (key["expiry_time"] - key["created_at"]) / (1000 * 60 * 60 * 24)
if duration_days <= 29:
periods["trial"] += 1
elif duration_days <= 89:
periods["1"] += 1
elif duration_days <= 179:
periods["3"] += 1
elif duration_days <= 359:
periods["6"] += 1
else:
periods["12"] += 1
except Exception as e:
logger.error(f"Error processing key duration: {e}")
continue
return periods
subs_all_time = count_subscriptions_by_duration(all_keys)
registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
registrations_week = await session.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
registrations_month = await session.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
active_keys = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
int(datetime.utcnow().timestamp() * 1000),
)
expired_keys = total_keys - active_keys
now = datetime.utcnow()
today = now.date()
moscow_tz = pytz.timezone("Europe/Moscow")
total_users = await count_total_users(session)
users_updated_today = await count_users_updated_today(session, today)
registrations_today = await count_users_registered_since(session, today)
registrations_yesterday = await count_users_registered_between(
session, today - timedelta(days=1), today
)
registrations_week = await count_users_registered_since(
session, today - timedelta(days=today.weekday())
)
registrations_month = await count_users_registered_since(
session, today.replace(day=1)
)
last_month_start = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
registrations_last_month = await count_users_registered_between(
session, last_month_start, today.replace(day=1)
)
total_keys = await count_total_keys(session)
active_keys = await count_active_keys(session)
expired_keys = total_keys - active_keys
trial_keys_count = await count_trial_keys(session)
tariff_counts, no_tariff_keys = await get_tariff_distribution(session, include_unbound=True)
tariff_names = await get_tariff_names(session, [tid for tid, _ in tariff_counts])
tariff_groups = await get_tariff_groups(session, [tid for tid, _ in tariff_counts])
tariff_durations = await get_tariff_durations(session, [tid for tid, _ in tariff_counts])
grouped_tariffs = {}
for tid, count in tariff_counts:
group = tariff_groups.get(tid, "unknown")
if group not in grouped_tariffs:
grouped_tariffs[group] = []
grouped_tariffs[group].append((tid, count))
tariff_stats_text = ""
duration_buckets = Counter()
now_ts = int(datetime.utcnow().timestamp() * 1000)
for key in no_tariff_keys:
duration_days = round((key["expiry_time"] - now_ts) / (1000 * 60 * 60 * 24))
if 25 <= duration_days <= 35:
bucket = "Без тарифа: 1 мес"
elif 80 <= duration_days <= 100:
bucket = "Без тарифа: 3 мес"
elif 170 <= duration_days <= 200:
bucket = "Без тарифа: 6 мес"
elif 350 <= duration_days <= 380:
bucket = "Без тарифа: 12 мес"
else:
bucket = "Без тарифа: прочее"
duration_buckets[bucket] += 1
bucket_order = {
"Без тарифа: 1 мес": 1,
"Без тарифа: 3 мес": 2,
"Без тарифа: 6 мес": 3,
"Без тарифа: 12 мес": 4,
"Без тарифа: прочее": 5
}
sorted_buckets = sorted(
duration_buckets.items(),
key=lambda x: bucket_order.get(x[0], 999)
)
for name, count in sorted_buckets:
tariff_stats_text += f"{name}: <b>{count}</b>\n"
for group, tariffs in grouped_tariffs.items():
tariff_stats_text += f"Тариф {group}\n"
sorted_tariffs = sorted(
tariffs,
key=lambda x: tariff_durations.get(x[0], 0)
)
for tid, count in sorted_tariffs:
name = tariff_names.get(tid, f"ID {tid}")
tariff_stats_text += f"{name}: <b>{count}</b>\n"
tariff_stats_text = (
"└ По тарифам и срокам:\n" + tariff_stats_text
if tariff_stats_text
else "└ Нет данных по тарифам\n"
)
total_referrals = await count_total_referrals(session)
total_payments_today = await sum_payments_since(session, today)
total_payments_yesterday = await sum_payments_between(
session, today - timedelta(days=1), today
)
total_payments_week = await sum_payments_since(
session, today - timedelta(days=today.weekday())
)
total_payments_month = await sum_payments_since(session, today.replace(day=1))
total_payments_last_month = await sum_payments_between(
session, last_month_start, today.replace(day=1)
)
total_payments_all_time = await sum_total_payments(session)
hot_leads_count = await count_hot_leads(session)
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
hot_leads_count = await session.fetchval("""
SELECT COUNT(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
""")
stats_message = (
"📊 <b>Статистика проекта</b>\n\n"
"👤 <b>Пользователи:</b>\n"
f"📊 <b>Статистика проекта</b>\n\n"
f"👤 <b>Пользователи:</b>\n"
f"<blockquote>"
f"├ 🗓️ За день: <b>{registrations_today}</b>\n"
f"├ 🗓️ Вчера: <b>{registrations_yesterday}</b>\n"
f"├ 📆 За неделю: <b>{registrations_week}</b>\n"
f"├ 🗓️ За месяц: <b>{registrations_month}</b>\n"
f"└ 🌐 Всего: <b>{total_users}</b>\n\n"
"💡 <b>Активность:</b>\n"
f"├ 📅 За прошлый месяц: <b>{registrations_last_month}</b>\n"
f"└ 🌐 Всего: <b>{total_users}</b>\n"
f"</blockquote>\n"
f"💡 <b>Активность:</b>\n"
f"└ 👥 Сегодня были активны: <b>{users_updated_today}</b>\n\n"
"🤝 <b>Реферальная система:</b>\n"
f"🤝 <b>Реферальная система:</b>\n"
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
"🔐 <b>Подписки:</b>\n"
f"🔐 <b>Подписки:</b>\n"
f"<blockquote>"
f"├ 📦 Всего сгенерировано: <b>{total_keys}</b>\n"
f"├ ✅ Активных: <b>{active_keys}</b>\n"
f"├ ❌ Просроченных: <b>{expired_keys}</b>\n"
f"└ 📋 По срокам:\n"
f" • 🎁 Триал: <b>{subs_all_time['trial']}</b>\n"
f" • 🗓️ 1 мес: <b>{subs_all_time['1']}</b>\n"
f" • 🗓️ 3 мес: <b>{subs_all_time['3']}</b>\n"
f" • 🗓️ 6 мес: <b>{subs_all_time['6']}</b>\n"
f" • 🗓️ 12 мес: <b>{subs_all_time['12']}</b>\n\n"
"💰 <b>Финансы:</b>\n"
f"├ 🧪 Триальных: <b>{trial_keys_count}</b>\n"
f"{tariff_stats_text}"
f"</blockquote>\n"
f"💰 <b>Финансы:</b>\n"
f"<blockquote>"
f"├ 📅 За день: <b>{total_payments_today} ₽</b>\n"
f"├ 📆 Вчера: <b>{total_payments_yesterday} ₽</b>\n"
f"├ 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
f"├ 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
f"├ 📆 За прошлый месяц: <b>{total_payments_last_month} ₽</b>\n"
f"└ 🏦 Всего: <b>{total_payments_all_time} ₽</b>\n\n"
f"🔥 <b>Горящие лиды</b>: <b>{hot_leads_count}</b> (платили, но не продлили)\n\n"
f"├ 📆 Прошлый месяц: <b>{total_payments_last_month} ₽</b>\n"
f"└ 🏦 Всего: <b>{total_payments_all_time} ₽</b>\n"
f"</blockquote>\n"
f"🔥 <b>Горячие лиды: {hot_leads_count}</b>\n"
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
await callback_query.message.edit_text(
text=stats_message, reply_markup=build_stats_kb()
)
except TelegramBadRequest as e:
if "message is not modified" not in str(e):
logger.error(f"Error in user_stats_menu: {e}")
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
await callback_query.answer("Произошла ошибка при получении статистики", show_alert=True)
await callback_query.answer(
"Произошла ошибка при получении статистики", show_alert=True
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
IsAdminFilter(),
AdminPanelCallback.filter(F.action == "stats_export_users_csv"), IsAdminFilter()
)
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
async def handle_export_users_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_users_csv(session)
await callback_query.message.answer_document(document=export, caption="📥 Экспорт пользователей в CSV")
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт пользователей в CSV"
)
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
await callback_query.message.edit_text(text=f"Произошла ошибка при экспорте: {e}", reply_markup=kb)
logger.error(f"Ошибка при экспорте пользователей: {e}")
await callback_query.message.edit_text(text=f"Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
IsAdminFilter(),
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), IsAdminFilter()
)
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
async def handle_export_payments_csv(
callback_query: CallbackQuery, session: AsyncSession
):
kb = build_admin_back_kb("stats")
try:
export = await export_payments_csv(session)
await callback_query.message.answer_document(document=export, caption="📥 Экспорт платежей в CSV")
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт платежей в CSV"
)
except Exception as e:
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
await callback_query.message.edit_text(text=f"Произошла ошибка при экспорте: {e}", reply_markup=kb)
logger.error(f"Ошибка при экспорте платежей: {e}")
await callback_query.message.edit_text(text=f"Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"),
IsAdminFilter(),
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"), IsAdminFilter()
)
async def handle_export_hot_leads_csv(callback_query: CallbackQuery, session: Any):
async def handle_export_hot_leads_csv(
callback_query: CallbackQuery, session: AsyncSession
):
kb = build_admin_back_kb("stats")
try:
export = await export_hot_leads_csv(session)
await callback_query.message.answer_document(document=export, caption="📥 Экспорт горящих лидов")
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт горящих лидов"
)
except Exception as e:
logger.error(f"Ошибка при экспорте 'горящих лидов': {e}")
await callback_query.message.edit_text(text=f"Произошла ошибка при экспорте: {e}", reply_markup=kb)
logger.error(f"Ошибка при экспорте горящих лидов: {e}")
await callback_query.message.edit_text(text=f"Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"),
IsAdminFilter(),
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"), IsAdminFilter()
)
async def handle_export_keys_csv(callback_query: CallbackQuery, session: Any):
async def handle_export_keys_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_keys_csv(session)
await callback_query.message.answer_document(document=export, caption="📥 Экспорт подписок в CSV")
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт подписок в CSV"
)
except Exception as e:
logger.error(f"Ошибка при экспорте подписок в CSV: {e}")
await callback_query.message.edit_text(text=f"Произошла ошибка при экспорте: {e}", reply_markup=kb)
logger.error(f"Ошибка при экспорте подписок: {e}")
await callback_query.message.edit_text(text=f"Ошибка: {e}", reply_markup=kb)
async def send_daily_stats_report(session: AsyncSession):
try:
now_moscow = datetime.now(pytz.timezone("Europe/Moscow"))
update_time = now_moscow.strftime("%d.%m.%y %H:%M")
today = (
pytz.timezone("Europe/Moscow")
.localize(datetime.combine((now_moscow - timedelta(days=1)).date(), datetime.min.time()))
.astimezone(pytz.UTC)
.replace(tzinfo=None)
)
registrations_today = await count_users_registered_since(session, today)
payments_today = await sum_payments_since(session, today)
active_keys = await count_active_keys(session)
text = (
"🗓️ <b>Сводка за день</b>\n\n"
f"👤 Новых пользователей: <b>{registrations_today}</b>\n"
f"💰 Оплачено: <b>{payments_today} ₽</b>\n"
f"🔐 Активных ключей: <b>{active_keys}</b>\n\n"
f"⏱️ <i>{update_time} МСК</i>"
)
for admin_id in ADMIN_ID:
await bot.send_message(admin_id, text)
except Exception as e:
logger.error(f"[Stats] Ошибка при отправке статистики: {e}")
@router.message(F.text == "Сводка", IsAdminFilter())
async def test_stats_command(message: Message, session: AsyncSession):
await send_daily_stats_report(session)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .tariffs_handler import router
+162
View File
@@ -0,0 +1,162 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback
class AdminTariffCallback(CallbackData, prefix="tariff"):
action: str
def build_tariff_menu_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🆕 Новый тариф",
callback_data=AdminTariffCallback(action="create").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📋 Мои тарифы",
callback_data=AdminTariffCallback(action="list").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()
)
)
return builder.as_markup()
def build_cancel_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="❌ Отменить", callback_data="cancel_tariff_creation"
)
]
]
)
def build_tariff_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group in groups:
builder.button(
text=group,
callback_data=AdminTariffCallback(action=f"group|{group}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminPanelCallback(action="tariffs").pack()
)
)
return builder.as_markup()
def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if tariffs:
group_code = tariffs[0]["group_code"]
else:
group_code = "unknown"
for t in tariffs:
title = f"{t['name']}{t['price_rub']}"
builder.row(
InlineKeyboardButton(
text=title,
callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить тариф",
callback_data=AdminTariffCallback(action=f"create|{group_code}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack()
)
)
return builder.as_markup()
def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="✏️ Редактировать",
callback_data=AdminTariffCallback(
action=f"edit|{tariff_id}"
).pack(),
),
InlineKeyboardButton(
text="🗑 Удалить",
callback_data=AdminTariffCallback(
action=f"delete|{tariff_id}"
).pack(),
),
],
[
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action="list").pack(),
)
],
]
)
def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="📝 Название", callback_data=f"edit_field|{tariff_id}|name"
)
],
[
InlineKeyboardButton(
text="📅 Длительность",
callback_data=f"edit_field|{tariff_id}|duration_days",
)
],
[
InlineKeyboardButton(
text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub"
)
],
[
InlineKeyboardButton(
text="📦 Трафик (ГБ или 0)",
callback_data=f"edit_field|{tariff_id}|traffic_limit",
)
],
[
InlineKeyboardButton(
text="📱 Лимит устройств",
callback_data=f"edit_field|{tariff_id}|device_limit",
)
],
[
InlineKeyboardButton(
text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}"
)
],
[InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
)]
]
)
+506
View File
@@ -0,0 +1,506 @@
from datetime import datetime
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
)
from sqlalchemy import delete, distinct, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import create_tariff
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminTariffCallback,
build_cancel_kb,
build_edit_tariff_fields_kb,
build_single_tariff_kb,
build_tariff_groups_kb,
build_tariff_list_kb,
build_tariff_menu_kb,
)
router = Router()
class TariffCreateState(StatesGroup):
group = State()
name = State()
duration = State()
price = State()
traffic = State()
confirm_more = State()
device_limit = State()
class TariffEditState(StatesGroup):
choosing_field = State()
editing_value = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()
)
async def handle_tariff_menu(callback_query: CallbackQuery):
text = (
"<b>💸 Управление тарифами</b>\n\n"
"Вы можете выполнить следующие действия:\n\n"
"<b>🆕 Создать тариф</b>\n"
"• Установите длительность (в днях)\n"
"• Задайте цену (в рублях)\n"
"• Задайте лимит устройств (hwid/ip_limit)\n"
"• Укажите лимит трафика (в ГБ)\n\n"
"<b>📋 Редактировать тарифы</b>\n"
"• Просматривайте список текущих тарифов\n"
"• Изменяйте параметры или удаляйте при необходимости"
)
await callback_query.message.edit_text(
text=text, reply_markup=build_tariff_menu_kb()
)
@router.callback_query(
AdminTariffCallback.filter(F.action == "create"), IsAdminFilter()
)
async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffCreateState.group)
await callback.message.edit_text(
"📁 Введите <b>код группы</b>, в которую вы хотите добавить тариф.\n\n"
"Например: <code>basic</code>, <code>vip</code>, <code>business</code>\n\n"
"<b>Специальные группы:</b>\n"
"• <code>discounts</code> — тарифы со скидкой\n"
"• <code>discounts_max</code> — тарифы с максимальной скидкой\n"
"• <code>gifts</code> — тарифы для подарков",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.group, IsAdminFilter())
async def process_tariff_group(message: Message, state: FSMContext):
group_code = message.text.strip().lower()
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await message.answer(
"📝 Введите <b>название тарифа</b>\n\n"
"Например: <i>30 дней</i> или <i>1 месяц</i>\n\n"
"<i>Это название будет отображаться пользователю при выборе тарифа</i>",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.name, IsAdminFilter())
async def process_tariff_name(message: Message, state: FSMContext):
await state.update_data(name=message.text.strip())
await state.set_state(TariffCreateState.duration)
await message.answer(
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.duration, IsAdminFilter())
async def process_tariff_duration(message: Message, state: FSMContext):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректное количество дней (целое число больше 0):"
)
return
await state.update_data(duration_days=days)
await state.set_state(TariffCreateState.price)
await message.answer(
"💰 Введите <b>цену тарифа в рублях</b> (например: <i>150</i>)\n\n"
"<i>Будет показано клиенту при выборе тарифа</i>",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.price, IsAdminFilter())
async def process_tariff_price(message: Message, state: FSMContext):
try:
price = int(message.text.strip())
if price < 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректную цену (целое число 0 или больше):")
return
await state.update_data(price_rub=price)
await state.set_state(TariffCreateState.traffic)
await message.answer(
"📦 Введите <b>лимит трафика в ГБ</b> (например: <i>100</i>, 0 — безлимит):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.traffic, IsAdminFilter())
async def process_tariff_traffic(message: Message, state: FSMContext):
try:
traffic = int(message.text.strip())
if traffic < 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректный лимит трафика (целое число 0 или больше):"
)
return
await state.update_data(traffic_limit=traffic if traffic > 0 else None)
await state.set_state(TariffCreateState.device_limit)
await message.answer(
"📱 Введите <b>лимит устройств (HWID)</b> для тарифа (например: <i>3</i>, 0 — безлимит):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.device_limit, IsAdminFilter())
async def process_tariff_device_limit(
message: Message, state: FSMContext, session: AsyncSession
):
try:
device_limit = int(message.text.strip())
if device_limit < 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректный лимит устройств (целое число 0 или больше):"
)
return
data = await state.get_data()
new_tariff = await create_tariff(
session,
{
"name": data["name"],
"group_code": data["group_code"],
"duration_days": data["duration_days"],
"price_rub": data["price_rub"],
"traffic_limit": data["traffic_limit"],
"device_limit": device_limit if device_limit > 0 else None,
},
)
await state.set_state(TariffCreateState.confirm_more)
await message.answer(
f"✅ Тариф <b>{new_tariff.name}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
"➕ Хотите добавить ещё один тариф в эту группу?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
InlineKeyboardButton(
text="❌ Нет", callback_data="done_tariff_group"
),
]
]
),
)
@router.callback_query(F.data == "add_more_tariff", IsAdminFilter())
async def handle_add_more_tariff(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffCreateState.name)
await callback.message.edit_text(
"📝 Введите <b>название следующего тарифа</b>:", reply_markup=build_cancel_kb()
)
@router.callback_query(F.data == "done_tariff_group", IsAdminFilter())
async def handle_done_tariff_group(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text(
"✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb()
)
@router.callback_query(F.data == "cancel_tariff_creation", IsAdminFilter())
async def cancel_tariff_creation(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text(
"❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb()
)
@router.callback_query(AdminTariffCallback.filter(F.action == "list"), IsAdminFilter())
async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(
select(distinct(Tariff.group_code))
.where(Tariff.group_code.isnot(None))
.order_by(Tariff.group_code)
)
groups = [row[0] for row in result.fetchall()]
if not groups:
await callback.message.edit_text(
"❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb()
)
return
special_groups = {
"discounts": "🔻 Скидки",
"discounts_max": "🔻 Макс. скидки",
"gifts": "🎁 Подарки",
}
text = "<b>📋 Выберите тарифную группу:</b>\n\n"
text += "<b>Специальные группы:</b>\n"
for code, label in special_groups.items():
status = "✅ создана" if code in groups else "❌ не создана"
text += f"{label} — <code>{code}</code> — <b>{status}</b>\n"
text += "\n"
await callback.message.edit_text(text, reply_markup=build_tariff_groups_kb(groups))
def tariff_to_dict(tariff: Tariff) -> dict:
return {
"id": tariff.id,
"name": tariff.name,
"price_rub": tariff.price_rub,
"group_code": tariff.group_code,
}
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter()
)
async def show_tariffs_in_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
):
group_code = callback_data.action.split("|", 1)[1]
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
)
tariffs = result.scalars().all()
if not tariffs:
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
return
tariff_dicts = [tariff_to_dict(t) for t in tariffs]
await callback.message.edit_text(
f"<b>📦 Тарифы группы: {group_code}</b>",
reply_markup=build_tariff_list_kb(tariff_dicts),
)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter()
)
async def view_tariff(
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
):
tariff_id = int(callback_data.action.split("|", 1)[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter()
)
async def confirm_tariff_deletion(
callback: CallbackQuery, callback_data: AdminTariffCallback
):
tariff_id = int(callback_data.action.split("|", 1)[1])
await callback.message.edit_text(
"⚠️ Вы уверены, что хотите <b>удалить</b> этот тариф?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"
),
InlineKeyboardButton(
text="❌ Отмена", callback_data=f"view|{tariff_id}"
),
]
]
),
)
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
tariff_id = int(callback.data.split("|", 1)[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
group_code = tariff.group_code
await session.execute(
update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None)
)
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code)
)
remaining_tariffs = result.scalars().all()
if not remaining_tariffs:
await session.execute(
update(Server)
.where(Server.tariff_group == group_code)
.values(tariff_group=None)
)
await session.commit()
await callback.message.edit_text(
"🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb()
)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter()
)
async def start_edit_tariff(
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
):
tariff_id = int(callback_data.action.split("|")[1])
await state.update_data(tariff_id=tariff_id)
await state.set_state(TariffEditState.choosing_field)
await callback.message.edit_text(
"<b>✏️ Что вы хотите изменить?</b>",
reply_markup=build_edit_tariff_fields_kb(tariff_id),
)
@router.callback_query(F.data.startswith("edit_field|"), IsAdminFilter())
async def ask_new_value(callback: CallbackQuery, state: FSMContext):
_, _tariff_id, field = callback.data.split("|")
await state.update_data(field=field)
await state.set_state(TariffEditState.editing_value)
field_names = {
"name": "название тарифа",
"duration_days": "длительность в днях",
"price_rub": "цену в рублях",
"traffic_limit": "лимит трафика в ГБ (0 — безлимит)",
"device_limit": "лимит устройств (0 — безлимит)",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
reply_markup=build_cancel_kb(),
)
@router.message(TariffEditState.editing_value, IsAdminFilter())
async def apply_edit(message: Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
tariff_id = data["tariff_id"]
field = data["field"]
value = message.text.strip()
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await message.answer("❌ Тариф не найден.")
await state.clear()
return
if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]:
try:
num = int(value)
if num < 0:
raise ValueError
if field in ["traffic_limit", "device_limit"]:
value = num if num > 0 else None
else:
value = num
except ValueError:
await message.answer("❌ Введите корректное число.")
return
setattr(tariff, field, value)
tariff.updated_at = datetime.utcnow()
await session.commit()
await state.clear()
text, markup = render_tariff_card(tariff)
await message.answer(text=text, reply_markup=markup)
@router.callback_query(F.data.startswith("toggle_active|"), IsAdminFilter())
async def toggle_tariff_status(callback: CallbackQuery, session: AsyncSession):
tariff_id = int(callback.data.split("|")[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
tariff.is_active = not tariff.is_active
await session.commit()
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter()
)
async def start_tariff_creation_existing_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
):
group_code = callback_data.action.split("|", 1)[1]
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await callback.message.edit_text(
f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:",
reply_markup=build_cancel_kb(),
)
def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
traffic_text = (
f"{tariff.traffic_limit} ГБ" if tariff.traffic_limit else "Безлимит"
)
device_text = (
f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
)
text = (
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
f"📁 Группа: <code>{tariff.group_code}</code>\n"
f"📅 Длительность: <b>{tariff.duration_days} дней</b>\n"
f"💰 Стоимость: <b>{tariff.price_rub}₽</b>\n"
f"📦 Трафик: <b>{traffic_text}</b>\n"
f"📱 Устройств: <b>{device_text}</b>\n"
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
)
return text, build_single_tariff_kb(tariff.id)
+225 -69
View File
@@ -3,10 +3,14 @@ from datetime import datetime, timezone
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from config import RENEWAL_PRICES, TOTAL_GB
from config import HWID_RESET_BUTTON
from database import get_clusters
from database.models import Key, Server, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days
from ..panel.keyboard import build_admin_back_btn
@@ -26,41 +30,65 @@ class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
edit: bool = False
def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
def build_user_edit_kb(
tg_id: int, key_records: list, is_banned: bool = False
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
current_time = datetime.now(tz=timezone.utc)
builder.button(
text=" Создать ключ",
callback_data=AdminUserEditorCallback(action="users_create_key", tg_id=tg_id).pack(),
callback_data=AdminUserEditorCallback(
action="users_create_key", tg_id=tg_id
).pack(),
)
for record in key_records:
email = record["email"]
expiry = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
email = record.email
expiry = datetime.fromtimestamp(record.expiry_time / 1000, tz=timezone.utc)
days = (expiry - current_time).days
builder.button(
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=str(email)).pack(),
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=str(email)
).pack(),
)
builder.button(
text="✉️ Сообщение", callback_data=AdminUserEditorCallback(action="users_send_message", tg_id=tg_id).pack()
text="✉️ Сообщение",
callback_data=AdminUserEditorCallback(
action="users_send_message", tg_id=tg_id
).pack(),
)
builder.button(
text="💸 Изменить баланс",
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
callback_data=AdminUserEditorCallback(
action="users_balance_edit", tg_id=tg_id
).pack(),
)
builder.button(
text="🤝 Выгрузить рефералов",
callback_data=AdminUserEditorCallback(action="users_export_referrals", tg_id=tg_id).pack(),
callback_data=AdminUserEditorCallback(
action="users_export_referrals", tg_id=tg_id
).pack(),
)
builder.button(
text="♻️ Восстановить триал",
callback_data=AdminUserEditorCallback(action="users_trial_restore", tg_id=tg_id).pack(),
callback_data=AdminUserEditorCallback(
action="users_trial_restore", tg_id=tg_id
).pack(),
)
builder.button(
text="❌ Удалить клиента", callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack()
text="❌ Удалить клиента",
callback_data=AdminUserEditorCallback(
action="users_delete_user", tg_id=tg_id
).pack(),
)
builder.button(
text="✅ Разблокировать" if is_banned else "🚫 Заблокировать",
callback_data=AdminUserEditorCallback(
action="users_unban" if is_banned else "users_ban", tg_id=tg_id
).pack(),
)
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
builder.row(build_admin_back_btn())
@@ -71,74 +99,148 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_balance_edit", tg_id=tg_id
).pack(),
)
return builder.as_markup()
def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
async def build_users_balance_kb(
session: AsyncSession, tg_id: int
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month, amount in RENEWAL_PRICES.items():
builder.button(
text=f"+ {amount}Р ({month} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=amount).pack(),
for amount in [100, 250, 500, 1000]:
builder.row(
InlineKeyboardButton(
text=f"+ {amount}",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id, data=amount
).pack(),
),
InlineKeyboardButton(
text=f"- {amount}",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id, data=-amount
).pack(),
),
)
builder.button(
text=f"- {amount}Р ({month} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=-amount).pack(),
builder.row(
InlineKeyboardButton(
text="💵 Добавить",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id
).pack(),
),
InlineKeyboardButton(
text="💵 Вычесть",
callback_data=AdminUserEditorCallback(
action="users_balance_take", tg_id=tg_id
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(
action="users_balance_set", tg_id=tg_id
).pack(),
)
builder.button(
text="💵 Добавить", callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack()
)
builder.button(
text="💵 Вычесть", callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack()
)
builder.button(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email, edit=True
).pack(),
)
return builder.as_markup()
def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
async def build_users_key_expiry_kb(
session: AsyncSession, tg_id: int, email: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month in RENEWAL_PRICES.keys():
month = int(month)
builder.button(
text=f"+ {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=month).pack(),
result = await session.execute(select(Key.server_id, Key.tariff_id).where(Key.email == email))
row = result.first()
server_id, tariff_id = (row if row else (None, None))
if tariff_id:
result = await session.execute(select(Tariff.group_code).where(Tariff.id == tariff_id))
row = result.first()
if row and row[0]:
group_code = row[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
)
tariffs = result.scalars().all()
unique_durations = set()
for tariff in tariffs:
days = tariff.duration_days
if days < 1 or days in unique_durations:
continue
unique_durations.add(days)
label = format_days(days)
builder.row(
InlineKeyboardButton(
text=f"+ {label}",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email, month=days
).pack(),
),
InlineKeyboardButton(
text=f"- {label}",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email, month=-days
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="⏳ Добавить дни",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email
).pack(),
),
InlineKeyboardButton(
text="⏳ Вычесть дни",
callback_data=AdminUserKeyEditorCallback(
action="take", tg_id=tg_id, data=email
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(
action="set", tg_id=tg_id, data=email
).pack(),
)
builder.button(
text=f"- {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=-month).pack(),
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack(),
)
builder.button(
text="⏳ Добавить дни", callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Вычесть дни", callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
)
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
@@ -146,7 +248,9 @@ def build_user_delete_kb(tg_id: int):
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Да, удалить!",
callback_data=AdminUserEditorCallback(action="users_delete_user_confirm", tg_id=tg_id).pack(),
callback_data=AdminUserEditorCallback(
action="users_delete_user_confirm", tg_id=tg_id
).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(1)
@@ -156,7 +260,10 @@ def build_user_delete_kb(tg_id: int):
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack(),
)
builder.adjust(1)
return builder.as_markup()
@@ -164,6 +271,7 @@ def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="⏳ Время истечения",
callback_data=AdminUserEditorCallback(
@@ -172,34 +280,73 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
)
builder.button(
text="🔄 Перевыпустить",
callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=key_details["tg_id"]).pack(),
callback_data=AdminUserEditorCallback(
action="users_update_key", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="📦 Тариф",
callback_data=AdminUserEditorCallback(
action="users_renew", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="❌ Удалить",
callback_data=AdminUserEditorCallback(action="users_delete_key", data=email, tg_id=key_details["tg_id"]).pack(),
callback_data=AdminUserEditorCallback(
action="users_delete_key", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="📊 Трафик",
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
callback_data=AdminUserEditorCallback(
action="users_traffic", data=email, tg_id=key_details["tg_id"]
).pack(),
)
if TOTAL_GB > 0:
builder.button(
text="♻️ Сбросить трафик",
callback_data=AdminUserEditorCallback(
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
).pack(),
)
if HWID_RESET_BUTTON:
builder.button(
text="♻️ Сбросить трафик",
text="💻 HWID",
callback_data=AdminUserEditorCallback(
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
action="users_hwid_menu", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.row(build_editor_back_btn(key_details["tg_id"], True))
builder.adjust(1)
return builder.as_markup()
def build_hwid_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="♻️ Сбросить HWID",
callback_data=AdminUserEditorCallback(
action="users_hwid_reset", data=email, tg_id=tg_id
).pack(),
)
builder.button(
text="🔙 Назад",
callback_data=AdminUserEditorCallback(
action="users_key_edit", data=email, tg_id=tg_id
).pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=AdminUserEditorCallback(action="users_delete_key_confirm", data=email, tg_id=tg_id).pack(),
callback_data=AdminUserEditorCallback(
action="users_delete_key_confirm", data=email, tg_id=tg_id
).pack(),
)
)
builder.row(build_editor_back_btn(tg_id))
@@ -211,7 +358,9 @@ def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
return build_editor_singleton_kb(BACK, tg_id, edit)
def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
def build_editor_singleton_kb(
text: str, tg_id: int, edit: bool = False
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(build_editor_btn(text, tg_id, edit))
return builder.as_markup()
@@ -223,16 +372,23 @@ def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButto
def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton:
return InlineKeyboardButton(
text=text, callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=edit).pack()
text=text,
callback_data=AdminUserEditorCallback(
action="users_editor", tg_id=tg_id, edit=edit
).pack(),
)
async def build_cluster_selection_kb(session, tg_id: int, email: str, action: str) -> InlineKeyboardMarkup:
async def build_cluster_selection_kb(
session, tg_id: int, email: str, action: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
clusters = await get_clusters(session)
for cluster_id in clusters:
builder.button(text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}")
builder.button(
text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}"
)
builder.button(text=BACK, callback_data=f"edit_user_key|{tg_id}|{email}")
builder.adjust(1)
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -10,14 +10,15 @@ CANCEL = "❌ Отмена"
# Профиль
ADD_SUB = " Подписка"
ADD_SUB = " Добавить новую подписку"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
BALANCE = "💵 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
INSTRUCTIONS = "📘 Инструкции"
TOP_FIVE = "🏆 Топ-5"
TRIAL_SUB = "🎁 Пробная подписка"
MY_SUB = "🔐 Моя подписка"
# Меню Оплат и баланса
@@ -30,7 +31,12 @@ YOOMONEY = "💳 ЮМани: перевод по карте"
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
STARS = "⭐ Оплата Звездами"
ROBOKASSA = "⭐ RoboKassa"
DISCOUNT_TARIFF = "🔥 Получить скидку"
MAX_DISCOUNT_TARIFF = "⚡ Получить максимальную скидку"
# Кнопки подписки на канал
SUB_CHANELL = "📢 Подписаться"
SUB_CHANELL_DONE = "✅ Я подписался"
# Подарки
@@ -73,6 +79,7 @@ MANUAL_INSTRUCTIONS = "📖 Ручная установка"
RENEW_KEY = "🔄 Продлить подписку"
TV_CONTINUE = "▶ Продолжить"
TV_INSTRUCTIONS = "📖 Полная инструкция"
HWID_BUTTON = "♻️ Сбросить привязку"
# Кнопки касс
+60 -33
View File
@@ -1,6 +1,5 @@
import random
import secrets
from typing import Any
from aiogram import F, Router
@@ -13,23 +12,50 @@ from logger import logger
from .utils import edit_or_send_message
router = Router()
async def generate_captcha(message: Message, state: FSMContext):
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
wrong_emojis = random.sample(
[e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3
)
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:
@@ -43,35 +69,36 @@ 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
async def check_captcha(
callback: CallbackQuery, state: FSMContext, session: Any, admin: bool
):
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"],
)
+129 -65
View File
@@ -1,15 +1,15 @@
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 sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import ADMIN_ID
from database import (
@@ -22,6 +22,8 @@ from database import (
update_balance,
update_coupon_usage_count,
update_key_expiry,
add_payment,
get_tariff_by_id,
)
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
@@ -45,7 +47,9 @@ router = Router()
@router.callback_query(F.data == "activate_coupon")
@router.message(F.text == "/activate_coupon")
async def handle_activate_coupon(callback_query_or_message: Message | CallbackQuery, state: FSMContext):
async def handle_activate_coupon(
callback_query_or_message: Message | CallbackQuery, state: FSMContext
):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -69,48 +73,68 @@ async def process_coupon_code(message: Message, state: FSMContext, session: Any)
await activate_coupon(message, state, session, coupon_code=coupon_code)
async def activate_coupon(message: Message, state: FSMContext, session: Any, coupon_code: str, admin: bool = False):
async def activate_coupon(
message: Message,
state: FSMContext,
session: AsyncSession,
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)
coupon = await get_coupon_by_code(session, coupon_code)
if not coupon_record:
if not coupon:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input")
)
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
return
if coupon_record["usage_count"] >= coupon_record["usage_limit"] or coupon_record["is_used"]:
if coupon.usage_count >= coupon.usage_limit or coupon.is_used:
await message.answer("❌ Лимит активаций купона исчерпан.")
await state.clear()
return
user_id = message.chat.id
user = user_data or message.from_user or message.chat
user_id = user["tg_id"] if isinstance(user, dict) else user.id
usage = await check_coupon_usage(coupon_record["id"], user_id, session)
usage = await check_coupon_usage(session, coupon.id, user_id)
if usage:
await message.answer(COUPON_ALREADY_USED_MSG)
await state.clear()
return
user_exists = await check_user_exists(user_id)
user_exists = await check_user_exists(session, user_id)
if not user_exists:
from_user = message.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,
)
if isinstance(user, dict):
await add_user(session=session, **user)
else:
await add_user(
session=session,
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),
)
if coupon_record["amount"] > 0:
if coupon.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 update_balance(session, user_id, coupon.amount)
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, user_id)
await add_payment(
session,
tg_id=user_id,
amount=coupon.amount,
payment_system="coupon"
)
await message.answer(
f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей."
)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
@@ -118,10 +142,10 @@ async def activate_coupon(message: Message, state: FSMContext, session: Any, cou
await state.clear()
return
if coupon_record["days"] is not None and coupon_record["days"] > 0:
if coupon.days:
try:
keys = await get_keys(user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
keys = await get_keys(session, user_id)
active_keys = [k for k in keys if not k.is_frozen]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
@@ -130,18 +154,20 @@ async def activate_coupon(message: Message, state: FSMContext, session: Any, cou
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
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")
key_display = html.escape((key.alias or key.email).strip())
expiry_date = datetime.fromtimestamp(
key.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']}")
builder.button(
text=key_display,
callback_data=f"extend_key|{key.client_id}|{coupon.id}",
)
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
@@ -149,7 +175,7 @@ async def activate_coupon(message: Message, state: FSMContext, session: Any, cou
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)
await state.update_data(coupon_id=coupon.id, user_id=user_id)
except Exception as e:
logger.error(f"Ошибка при обработке купона на дни: {e}")
await message.answer("❌ Ошибка при активации купона.")
@@ -161,52 +187,79 @@ async def activate_coupon(message: Message, state: FSMContext, session: Any, cou
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
async def handle_key_extension(
callback_query: CallbackQuery,
state: FSMContext,
session: AsyncSession,
admin: bool = False,
):
from database.models import Coupon, Key
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
tg_id = callback_query.from_user.id
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("❌ Купон недействителен или лимит исчерпан.")
result = await session.execute(select(Coupon).where(Coupon.id == coupon_id))
coupon = result.scalar_one_or_none()
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)
usage = await check_coupon_usage(session, coupon.id, tg_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
result = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
key = result.scalar_one_or_none()
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)
current_expiry = key.expiry_time
new_expiry = max(now_ms, current_expiry) + (coupon.days * 86400 * 1000)
tariff = None
if key.tariff_id:
tariff = await get_tariff_by_id(session, key.tariff_id)
total_gb = int(tariff["traffic_limit"]) if tariff and tariff.get("traffic_limit") else 0
device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else None
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
cluster_id=key.server_id,
email=key.email,
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=total_gb,
hwid_device_limit=device_limit,
session=session
)
await update_key_expiry(client_id, new_expiry, session)
await update_key_expiry(session, client_id, new_expiry)
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, tg_id)
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"
)
alias = key.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}📆."
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon.days)}⏳ до {expiry_date}📆."
)
await process_callback_view_profile(
callback_query.message, state, admin, session
)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
@@ -215,14 +268,25 @@ async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext,
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
async def cancel_coupon_activation(
callback_query: CallbackQuery,
state: FSMContext,
admin: bool = False,
session: AsyncSession = None,
):
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await process_callback_view_profile(callback_query.message, state, admin, session)
await state.clear()
@router.callback_query(F.data == "exit_coupon_input")
async def handle_exit_coupon_input(callback_query: CallbackQuery, state: FSMContext):
async def handle_exit_coupon_input(
callback_query: CallbackQuery,
state: FSMContext,
session: AsyncSession = None,
):
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)
await process_callback_view_profile(
callback_query.message, state, admin=is_admin, session=session
)
+15 -3
View File
@@ -1,7 +1,13 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
LabeledPrice,
Message,
PreCheckoutQuery,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RUB_TO_XTR
@@ -25,7 +31,11 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
builder.row(
InlineKeyboardButton(
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
)
)
builder.row(
InlineKeyboardButton(
text="💰 Ввести сумму доната",
@@ -67,7 +77,9 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
if message.text.isdigit():
amount = int(message.text)
if amount // RUB_TO_XTR <= 0:
await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:")
await message.answer(
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
)
return
await state.update_data(amount=amount)
+21
View File
@@ -0,0 +1,21 @@
from aiogram import F, Router
from aiogram.types import InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import SUPPORT_CHAT_URL
from handlers.buttons import MAIN_MENU, SUPPORT
from handlers.texts import FALLBACK_MESSAGE
fallback_router = Router()
@fallback_router.message(F.text)
async def handle_unhandled_messages(message: Message):
keyboard = InlineKeyboardBuilder()
keyboard.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
keyboard.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await message.answer(
FALLBACK_MESSAGE,
reply_markup=keyboard.as_markup(),
)
-1
View File
@@ -4,7 +4,6 @@ from aiogram import Router
from .instructions import router as instructions_router
router = Router(name="instructions_main_router")
router.include_routers(
+24 -22
View File
@@ -1,13 +1,8 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
Message,
)
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
@@ -34,15 +29,14 @@ from handlers.buttons import (
from handlers.texts import (
CHOOSE_DEVICE_TEXT,
CONNECT_TV_TEXT,
INSTRUCTIONS,
INSTRUCTION_MACOS,
INSTRUCTION_PC,
INSTRUCTIONS,
KEY_MESSAGE,
SUBSCRIPTION_DETAILS_TEXT,
)
from handlers.utils import edit_or_send_message
router = Router()
@@ -72,7 +66,7 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
@router.callback_query(F.data.startswith("connect_pc|"))
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
if not record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -85,8 +79,12 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}"))
builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}")
)
builder.row(
InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
@@ -100,14 +98,16 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("windows_menu|"))
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}"))
builder.row(
InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}")
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -122,14 +122,16 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("macos_menu|"))
async def process_macos_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}"))
builder.row(
InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}")
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -142,11 +144,13 @@ async def process_macos_menu(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("connect_tv|"))
async def process_connect_tv(callback_query: CallbackQuery):
async def process_connect_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}"))
builder.row(
InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -162,13 +166,11 @@ async def process_connect_tv(callback_query: CallbackQuery):
@router.callback_query(F.data.startswith("continue_tv|"))
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
subscription_link = record["key"]
record = await get_key_details(session, key_name)
subscription_link = record.get("key") or record.get("remnawave_link")
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=TV_INSTRUCTIONS, url="https://vpn4tv.com/quick-guide.html"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -177,4 +179,4 @@ async def process_continue_tv(callback_query: CallbackQuery, session: Any):
text=message_text,
reply_markup=builder.as_markup(),
media_path=None,
)
)
+8 -2
View File
@@ -9,7 +9,13 @@ 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, view_router, renew_router, freeze_router, connect_router, key_mode_router)
router.include_routers(
keys_router,
view_router,
renew_router,
freeze_router,
connect_router,
key_mode_router,
)
+91 -58
View File
@@ -1,16 +1,21 @@
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 sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, INSTRUCTIONS_BUTTON
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
INSTRUCTIONS_BUTTON,
)
from database.models import Key
from handlers.buttons import (
ANDROID,
BACK,
@@ -33,7 +38,6 @@ from handlers.texts import (
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@@ -43,12 +47,24 @@ async def handle_connect_device(callback_query: CallbackQuery):
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=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}"))
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(
target_message=callback_query.message,
@@ -62,31 +78,25 @@ async def handle_connect_device(callback_query: CallbackQuery):
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery):
async def process_callback_connect_phone(
callback_query: CallbackQuery, session: AsyncSession
):
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:
result = await session.execute(select(Key.key).where(Key.email == email))
row = result.scalar_one_or_none()
if not row:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
key_link = row
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)
@@ -100,35 +110,37 @@ async def process_callback_connect_phone(callback_query: CallbackQuery):
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=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
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):
async def process_callback_connect_ios(
callback_query: CallbackQuery, session: AsyncSession
):
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:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
if not key_link:
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)
@@ -136,8 +148,12 @@ async def process_callback_connect_ios(callback_query: CallbackQuery):
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=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -149,35 +165,40 @@ async def process_callback_connect_ios(callback_query: CallbackQuery):
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(callback_query: CallbackQuery):
async def process_callback_connect_android(
callback_query: CallbackQuery, session: AsyncSession
):
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:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
if not key_link:
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}"))
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=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
@@ -189,17 +210,27 @@ async def process_callback_connect_android(callback_query: CallbackQuery):
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSession):
try:
key_name = callback_query.data.split("|")[1]
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
stmt = select(Key).where(Key.email == key_name)
result = await session.execute(stmt)
record = result.scalars().first()
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr_data = record.key or record.remnawave_link
if not qr_data:
await callback_query.message.answer(
"❌ У этой подписки отсутствует ссылка для подключения."
)
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(record["key"])
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
@@ -207,12 +238,14 @@ async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record['email']}.png"
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=BACK, callback_data=f"view_key|{record.email}")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
+109 -84
View File
@@ -1,26 +1,14 @@
import time
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import text
from config import (
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 database import get_key_details, mark_key_as_frozen, mark_key_as_unfrozen
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
@@ -28,13 +16,15 @@ from handlers.texts import (
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
)
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
async def process_callback_unfreeze_subscription(
callback_query: CallbackQuery, session: Any
):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
@@ -58,7 +48,9 @@ async def process_callback_unfreeze_subscription(callback_query: CallbackQuery,
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
async def process_callback_unfreeze_subscription_confirm(
callback_query: CallbackQuery, session: Any
):
"""
Размораживает (включает) подписку.
"""
@@ -66,7 +58,7 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
@@ -75,62 +67,98 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
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
result = await toggle_client_on_cluster(
cluster_id, email, client_id, enable=True, session=session
)
if result["status"] != "success":
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(
callback_query.message, text_error, builder.as_markup()
)
return
new_expiry_time = now_ms + leftover
await session.execute(
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
logger.info(f"[Unfreeze Debug] expiry_time из БД: {leftover}")
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time)
await session.commit()
from database.servers import get_servers
servers = await get_servers(session)
cluster_servers = servers.get(cluster_id, [])
tariff = None
for srv in cluster_servers:
result = await session.execute(
text(
"""
SELECT t.*
FROM tariffs t
JOIN servers s ON s.tariff_group = t.group_code
WHERE s.server_name = :server_name
ORDER BY t.duration_days DESC
LIMIT 1
"""
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,
),
{"server_name": srv["server_name"]},
)
added_days = max(leftover / (1000 * 86400), 0.01)
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
row = result.mappings().first()
if row:
tariff = row
break
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(),
await session.commit()
if not tariff:
logger.info(
"[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения."
)
base_bytes = 15 * 1024
hwid_limit = 1
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(),
)
base_bytes = int(tariff.get("traffic_limit") or 0)
hwid_limit = int(tariff.get("device_limit") or 0)
added_days = max(leftover / (1000 * 86400), 0.01)
total_gb = int((added_days / 30) * base_bytes)
logger.info(
f"[Unfreeze Debug] Запуск renew_key_in_cluster с expiry={new_expiry_time}, gb={total_gb}, hwid={hwid_limit}"
)
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,
session=session,
hwid_device_limit=hwid_limit,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(callback_query.message, text_ok, 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):
async def process_callback_freeze_subscription(
callback_query: CallbackQuery, session: Any
):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
@@ -158,7 +186,9 @@ async def process_callback_freeze_subscription(callback_query: CallbackQuery, se
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
async def process_callback_freeze_subscription_confirm(
callback_query: CallbackQuery, session: Any
):
"""
Замораживает (отключает) подписку.
"""
@@ -166,7 +196,7 @@ async def process_callback_freeze_subscription_confirm(callback_query: CallbackQ
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
@@ -175,7 +205,9 @@ async def process_callback_freeze_subscription_confirm(callback_query: CallbackQ
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
result = await toggle_client_on_cluster(
cluster_id, email, client_id, enable=False, session=session
)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
@@ -183,33 +215,26 @@ async def process_callback_freeze_subscription_confirm(callback_query: CallbackQ
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,
)
await mark_key_as_frozen(session, record["tg_id"], client_id, time_left)
await session.commit()
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{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=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
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}"))
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,
+2 -6
View File
@@ -5,12 +5,8 @@ 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
from .key_discount_mode import router as discount_router
router = Router(name="key_mode_router")
router.include_routers(
create_router,
cluster_router,
country_router,
)
router.include_routers(create_router, cluster_router, country_router, discount_router)
+128 -43
View File
@@ -1,41 +1,48 @@
import uuid
from datetime import datetime
import pytz
from aiogram import Router
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message, WebAppInfo
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 config import CONNECT_PHONE_BUTTON, SUPPORT_CHAT_URL, TRIAL_CONFIG
from database import (
get_key_details,
get_tariff_by_id,
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.buttons import (
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
MY_SUB,
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,
format_days,
format_months,
)
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@@ -47,11 +54,19 @@ async def key_cluster_mode(
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
target_message = None
safe_to_edit = False
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
target_message = message_or_query.message
safe_to_edit = True
elif isinstance(message_or_query, Message):
target_message = message_or_query
safe_to_edit = True
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
@@ -60,11 +75,42 @@ async def key_cluster_mode(
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}")
data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False)
key_record = await get_key_details(email, session)
device_limit = 0
traffic_limit_gb = None
if is_trial:
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
traffic_limit_gb = TRIAL_CONFIG.get("traffic_limit_gb", 100)
elif plan:
tariff = await get_tariff_by_id(session, plan)
if tariff:
if tariff.get("device_limit") is not None:
device_limit = int(tariff["device_limit"])
if tariff.get("traffic_limit") is not None:
traffic_limit_gb = int(tariff["traffic_limit"])
least_loaded_cluster = await get_least_loaded_cluster(session)
await create_key_on_cluster(
cluster_id=least_loaded_cluster,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
plan=plan,
session=session,
hwid_limit=device_limit,
traffic_limit_bytes=traffic_limit_gb,
is_trial=is_trial,
)
logger.info(
f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}"
)
key_record = await get_key_details(session, email)
if not key_record:
raise ValueError(f"Ключ не найден после создания: {email}")
@@ -72,41 +118,49 @@ async def key_cluster_mode(
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 is_trial:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
await update_trial(session, tg_id, 1)
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}")
if data.get("tariff_id"):
tariff = await get_tariff_by_id(session, data["tariff_id"])
if tariff:
await update_balance(session, tg_id, -tariff["price_rub"])
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
error_message = (
"❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
target_message=target_message,
text=error_message,
reply_markup=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),
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
)
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(
text=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}"),
@@ -114,22 +168,53 @@ async def key_cluster_mode(
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
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} 📅")
tariff_info = None
if plan:
tariff_info = await get_tariff_by_id(session, plan)
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 1)
)
else:
tariff_duration = ""
if tariff_info and tariff_info.get("duration_days", 0) > 0:
duration_days = tariff_info["duration_days"]
if duration_days >= 30:
months = duration_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(duration_days)
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0
)
default_media_path = "img/pic.jpg"
if target_message:
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
+325 -181
View File
@@ -1,28 +1,28 @@
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
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from sqlalchemy import func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import (
DATABASE_URL,
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_CONFIG,
)
from database import (
add_user,
@@ -33,18 +33,31 @@ from database import (
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 database.models import Key, Server, Tariff
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
MY_SUB
)
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,
format_days,
format_months,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_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")
@@ -54,30 +67,63 @@ async def key_country_mode(
tg_id: int,
expiry_time: datetime,
state: FSMContext,
session: Any,
session: AsyncSession,
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
target_message = None
safe_to_edit = False
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 state and plan:
await state.update_data(tariff_id=plan)
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
target_message = message_or_query.message
safe_to_edit = True
elif isinstance(message_or_query, Message):
target_message = message_or_query
safe_to_edit = True
least_loaded_cluster = await get_least_loaded_cluster(session)
if not least_loaded_cluster:
logger.error("❌ Не удалось определить наименее загруженный кластер")
text = "❌ Нет доступных кластеров для создания ключа."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
result = await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
).where(Server.cluster_name == least_loaded_cluster)
)
servers = result.mappings().all()
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,
)
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
available_servers = []
tasks = [asyncio.create_task(check_server_availability(server)) for server in servers]
tasks = [
asyncio.create_task(check_server_availability(server, session))
for server in servers
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
@@ -85,35 +131,45 @@ async def key_country_mode(
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,
logger.warning(
f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}"
)
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
logger.info(f"[Country Selection] Доступные серверы для выбора: {available_servers}")
logger.info(
f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}"
)
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
for server_name in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
callback_data = f"select_country|{server_name}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
callback_data = f"select_country|{server_name}|{ts}"
builder.row(InlineKeyboardButton(text=server_name, 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,
)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
else:
await bot.send_message(
chat_id=tg_id,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("change_location|"))
@@ -125,7 +181,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
record = await get_key_details(session, old_key_name)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
@@ -135,20 +191,36 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
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,
servers = (
(
await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
)
.mappings()
.all()
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
await callback_query.answer(
"❌ Доступных серверов в кластере не найдено", show_alert=True
)
return
available_servers = []
@@ -162,7 +234,7 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
"enabled": server.get("enabled", True),
"max_keys": server.get("max_keys"),
}
task = asyncio.create_task(check_server_availability(server_info))
task = asyncio.create_task(check_server_availability(server_info, session))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -172,7 +244,9 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
await callback_query.answer(
"❌ Нет доступных серверов для смены локации", show_alert=True
)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
@@ -181,7 +255,9 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
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}"))
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")
)
await edit_or_send_message(
target_message=callback_query.message,
@@ -190,12 +266,18 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any):
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("Ошибка смены локации. Попробуйте снова.", show_alert=True)
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):
async def handle_country_selection(
callback_query: CallbackQuery, session: Any, state: FSMContext
):
"""
Обрабатывает выбор страны.
Формат callback data:
@@ -211,7 +293,9 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any,
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
await callback_query.message.answer(
"❌ Некорректное время истечения. Попробуйте снова."
)
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
@@ -222,7 +306,15 @@ async def handle_country_selection(callback_query: CallbackQuery, session: Any,
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)
await finalize_key_creation(
tg_id,
expiry_time,
selected_country,
state,
session,
callback_query,
old_key_name,
)
async def finalize_key_creation(
@@ -230,31 +322,29 @@ async def finalize_key_creation(
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
session: AsyncSession,
callback_query: CallbackQuery,
old_key_name: str = None,
tariff_id: int | None = 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
from_user = callback_query.from_user
if not await check_user_exists(session, tg_id):
await add_user(
session=session,
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)
old_key_details = await get_key_details(session, old_key_name)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
@@ -263,76 +353,85 @@ async def finalize_key_creation(
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
tariff_id = old_key_details.get("tariff_id") or tariff_id
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
remna = None
traffic_limit_bytes = None
device_limit = 0
data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False)
if is_trial:
traffic_limit_bytes = int(TRIAL_CONFIG.get("traffic_limit_gb", 100)) * 1024**3
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
elif data.get("tariff_id") or tariff_id:
tariff_id = data.get("tariff_id") or tariff_id
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if tariff:
if tariff.traffic_limit is not None:
traffic_limit_bytes = int(tariff.traffic_limit) * 1024**3
if tariff.device_limit is not None:
device_limit = int(tariff.device_limit)
public_link = None
remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
selected_country,
result = await session.execute(
select(Server).where(Server.server_name == selected_country)
)
server_info = result.scalar_one_or_none()
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
raise ValueError(f"Сервер {selected_country} не найден")
panel_type = server_info["panel_type"].lower()
panel_type = server_info.panel_type.lower()
cluster_info = await check_server_name_by_cluster(session, server_info.server_name)
if not cluster_info:
raise ValueError(f"Кластер для сервера {server_info.server_name} не найден")
public_link = None
remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
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")
old_server_id = old_key_details["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,
)
result = await session.execute(select(Server).where(Server.server_name == old_server_id))
old_server_info = result.scalar_one_or_none()
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,
)
if old_server_info.panel_type.lower() == "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,
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.values(key=None)
)
logger.info(f"[Delete] Удалён клиент {email} с 3x-ui сервера {old_server_id}")
elif old_panel_type == "remnawave":
remna = RemnawaveAPI(old_server_info["api_url"])
elif old_server_info.panel_type.lower() == "remnawave":
remna = RemnawaveAPI(old_server_info.api_url)
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
await session.execute(
"UPDATE keys SET remnawave_link = NULL WHERE tg_id = $1 AND email = $2",
tg_id,
email,
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.values(remnawave_link=None)
)
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}")
logger.warning(f"[Delete] Ошибка при удалении клиента: {e}")
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
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})")
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data = {
@@ -340,83 +439,80 @@ async def finalize_key_creation(
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": [server_info["inbound_id"]],
"activeUserInbounds": [server_info.inbound_id],
}
if traffic_limit_bytes:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if device_limit:
user_data["hwidDeviceLimit"] = device_limit
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,
update(Key).where(Key.tg_id == tg_id, Key.email == email).values(client_id=client_id)
)
if panel_type == "3x-ui":
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
server_info={
"api_url": server_info.api_url,
"inbound_id": server_info.inbound_id,
"server_name": server_info.server_name,
"panel_type": server_info.panel_type,
},
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
session=session,
plan=tariff_id,
is_trial=is_trial,
)
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,
)
update_data = {"server_id": selected_country}
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,
)
update_data["key"] = public_link
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,
)
update_data["remnawave_link"] = remnawave_link
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.email == email).values(**update_data))
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,
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_timestamp,
key=public_link,
remnawave_link=remnawave_link,
server_id=selected_country,
tariff_id=tariff_id,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
session.add(new_key)
if is_trial:
trial_status = await get_trial(session, tg_id)
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)
await update_trial(session, tg_id, 1)
if tariff_id:
result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id))
row = result.scalar_one_or_none()
if row:
await update_balance(session, tg_id, -row)
await session.commit()
except Exception as e:
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
@@ -424,23 +520,70 @@ async def finalize_key_creation(
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}"))
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if (panel_type == "remnawave" or is_full_remnawave) and (public_link or remnawave_link):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=public_link or remnawave_link),
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
)
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=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")
)
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
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} 📅")
tariff_info = None
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff_info = result.scalar_one_or_none()
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 1)
)
else:
tariff_duration = ""
if tariff_info and tariff_info.get("duration_days", 0) > 0:
duration_days = tariff_info["duration_days"]
if duration_days >= 30:
months = duration_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(duration_days)
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0
)
await edit_or_send_message(
target_message=callback_query.message,
@@ -453,7 +596,7 @@ async def finalize_key_creation(
await state.clear()
async def check_server_availability(server_info: dict, session: Any = None) -> bool:
async def check_server_availability(server_info: dict, session: AsyncSession) -> 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)
@@ -463,32 +606,33 @@ async def check_server_availability(server_info: dict, session: Any = None) -> b
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)
result = await session.execute(
select(func.count())
.select_from(Key)
.where(Key.server_id == server_name)
)
key_count = result.scalar()
if key_count >= max_keys:
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {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}")
except SQLAlchemyError 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)
await asyncio.wait_for(
remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0
)
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True
+85 -55
View File
@@ -2,16 +2,15 @@ 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 math import ceil
from config import (
NOTIFY_EXTRA_DAYS,
RENEWAL_PRICES,
TRIAL_TIME,
TRIAL_CONFIG,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
@@ -21,27 +20,26 @@ from database import (
check_user_exists,
create_temporary_data,
get_balance,
get_tariff_by_id,
get_tariffs_for_cluster,
get_trial,
)
from handlers.buttons import (
MAIN_MENU,
PAYMENT,
)
from handlers.buttons import MAIN_MENU, PAYMENT
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message
from handlers.utils import edit_or_send_message, get_least_loaded_cluster
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")
@@ -52,7 +50,9 @@ class Form(FSMContext):
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
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)
@@ -63,83 +63,112 @@ async def handle_key_creation(
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)
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
base_days = TRIAL_CONFIG["duration_days"]
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}.")
total_days = base_days + extra_days
expiry_time = current_time + timedelta(days=total_days)
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
),
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
cluster_name = await get_least_loaded_cluster(session)
tariffs = await get_tariffs_for_cluster(session, cluster_name)
if not tariffs:
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
),
text="❌ Нет доступных тарифов для выбора.",
reply_markup=None,
)
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}% 🔥)"
for t in tariffs:
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
text=f"{t['name']}{t['price_rub']}",
callback_data=f"select_tariff_plan|{t['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
target_message = (
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else 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("🚫 Неверный тарифный план.")
@router.callback_query(F.data.startswith("select_tariff_plan|"))
async def select_tariff_plan(
callback_query: CallbackQuery, session: Any, state: FSMContext
):
tg_id = callback_query.from_user.id
tariff_id = int(callback_query.data.split("|")[1])
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
balance = await get_balance(session, tg_id)
price_rub = tariff["price_rub"]
if balance < price_rub:
required_amount = ceil(price_rub - balance)
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"tariff_id": tariff_id,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
@@ -148,12 +177,13 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
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"))
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key")
)
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
@@ -161,8 +191,8 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
)
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))
await state.update_data(tariff_id=tariff_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=tariff_id)
async def create_key(
@@ -174,13 +204,12 @@ async def create_key(
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 not await check_user_exists(session, 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,
@@ -201,6 +230,7 @@ async def create_key(
session=session,
message_or_query=message_or_query,
old_key_name=old_key_name,
plan=plan,
)
else:
await key_cluster_mode(
@@ -0,0 +1,99 @@
from datetime import datetime, timedelta
from aiogram import F, Router
from aiogram.types import CallbackQuery
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from database import get_tariffs
from database.models import Notification
from handlers.notifications.notify_kb import build_tariffs_keyboard
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
from logger import logger
from .key_create import select_tariff_plan
router = Router()
@router.callback_query(F.data == "hot_lead_discount")
async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(
select(Notification.last_notification_time).where(
Notification.tg_id == tg_id,
Notification.notification_type == "hot_lead_step_2",
)
)
last_time = result.scalar_one_or_none()
if not last_time:
await callback.message.edit_text("❌ Скидка недоступна.")
return
now = datetime.utcnow()
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
return
tariffs = await get_tariffs(session=session, group_code="discounts")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
@router.callback_query(F.data.startswith("discount_tariff|"))
async def handle_discount_tariff_selection(callback: CallbackQuery, session, state):
try:
tariff_id = int(callback.data.split("|")[1])
fake_callback = CallbackQuery.model_construct(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
message=callback.message,
data=f"select_tariff_plan|{tariff_id}",
)
await select_tariff_plan(fake_callback, session=session, state=state)
except Exception as e:
logger.error(f"Ошибка при выборе скидочного тарифа: {e}")
await callback.message.answer("❌ Произошла ошибка при выборе тарифа.")
@router.callback_query(F.data == "hot_lead_final_discount")
async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(
select(Notification.last_notification_time).where(
Notification.tg_id == tg_id,
Notification.notification_type == "hot_lead_step_3",
)
)
last_time = result.scalar_one_or_none()
if not last_time:
await callback.message.edit_text("❌ Скидка недоступна.")
return
now = datetime.utcnow()
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
return
tariffs = await get_tariffs(session, group_code="discounts_max")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF_MAX,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
+317 -179
View File
@@ -1,226 +1,364 @@
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 sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from math import ceil
import pytz
from bot import bot
from config import (
DATABASE_URL,
RENEWAL_PRICES,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from config import USE_NEW_PAYMENT_FLOW
from database import (
check_server_name_by_cluster,
create_temporary_data,
get_balance,
get_key_by_server,
get_key_details,
get_tariff_by_id,
get_tariffs,
update_balance,
update_key_expiry,
check_tariff_exists,
)
from handlers.buttons import (
BACK,
MAIN_MENU,
PAYMENT,
)
from handlers.keys.key_utils import (
renew_key_in_cluster,
)
from database.models import Server, Key
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.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
DISCOUNTS,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
SUCCESS_RENEWAL_MSG,
get_renewal_message,
)
from handlers.utils import edit_or_send_message, format_months
from handlers.buttons import MY_SUB
from handlers.utils import edit_or_send_message, format_days, format_months, get_russian_month
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
async def process_callback_renew_key(
callback_query: CallbackQuery, session: AsyncSession
):
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:
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("<b>Ключ не найден.</b>")
return
client_id = record["client_id"]
expiry_time = record["expiry_time"]
server_id = record["server_id"]
tariff_id = record.get("tariff_id")
logger.info(f"[RENEW] Получение тарифов для server_id={server_id}")
try:
server_id_int = int(server_id)
filter_condition = or_(
Server.id == server_id_int,
Server.server_name == server_id,
Server.cluster_name == server_id,
)
except ValueError:
filter_condition = or_(
Server.server_name == server_id,
Server.cluster_name == server_id,
)
row = await session.execute(
select(Server.tariff_group).where(filter_condition).limit(1)
)
row = row.first()
if not row or not row[0]:
logger.warning(f"[RENEW] Тарифная группа не найдена для server_id={server_id}")
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
return
cluster_group = row[0]
selected_tariffs = []
target_group = cluster_group
if tariff_id:
if await check_tariff_exists(session, tariff_id):
current_tariff = await get_tariff_by_id(session, tariff_id)
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts"]:
target_group = current_tariff["group_code"]
tariffs = await get_tariffs(session, group_code=target_group)
if not tariffs:
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
return
selected_tariffs = [t for t in tariffs if t["is_active"]]
if not selected_tariffs:
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
return
builder = InlineKeyboardBuilder()
for t in selected_tariffs:
button_text = f"{t['name']}{t['price_rub']}"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{t['id']}|{client_id}",
)
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}")
)
balance = await get_balance(session, tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime(
"%Y-%m-%d %H:%M:%S"
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"Ошибка в process_callback_renew_key: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
logger.error(
f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {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)
tg_id = callback_query.from_user.id
tariff_id, client_id = callback_query.data.split("|")[1:]
tariff_id = int(tariff_id)
try:
record = await get_key_by_server(tg_id, client_id, session)
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff or not tariff["is_active"]:
await callback_query.message.answer("❌ Тариф не найден или отключён.")
return
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
duration_days = tariff["duration_days"]
cost = tariff["price_rub"]
total_gb = tariff["traffic_limit"] or 0
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:
record = await get_key_by_server(session, tg_id, client_id)
if not record:
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()
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
if expiry_time <= current_time:
new_expiry_time = int(
current_time + timedelta(days=duration_days).total_seconds() * 1000
)
else:
new_expiry_time = int(
expiry_time + timedelta(days=duration_days).total_seconds() * 1000
)
balance = round(await get_balance(session, tg_id), 2)
cost = round(cost, 2)
if balance < cost:
required_amount = ceil(cost - balance)
logger.info(f"[RENEW] Недостаточно средств: {required_amount}")
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"tariff_id": tariff_id,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
)
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(
required_amount=required_amount
),
reply_markup=builder.as_markup(),
)
return
logger.info(
f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней"
)
await complete_key_renewal(
session,
tg_id,
client_id,
email,
new_expiry_time,
total_gb,
cost,
callback_query,
tariff_id,
)
except Exception as e:
logger.error(
f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}"
)
async def resolve_cluster_name(
session: AsyncSession, server_or_cluster: str
) -> str | None:
result = await session.execute(
select(Server).where(Server.cluster_name == server_or_cluster).limit(1)
)
server = result.scalars().first()
if server:
return server_or_cluster
result = await session.execute(
select(Server.cluster_name)
.where(Server.server_name == server_or_cluster)
.limit(1)
)
row = result.scalar()
return row
async def complete_key_renewal(
session: AsyncSession,
tg_id: int,
client_id: str,
email: str,
new_expiry_time: int,
total_gb: int,
cost: float,
callback_query: CallbackQuery | None,
tariff_id: int,
):
try:
logger.info(
f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)"
)
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
return
if tariff["duration_days"] % 30 == 0:
months_formatted = format_months(tariff["duration_days"] // 30)
else:
months_formatted = format_days(tariff["duration_days"])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
formatted_expiry_date = datetime.fromtimestamp(
new_expiry_time / 1000, tz=moscow_tz
).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz))
)
response_message = get_renewal_message(
tariff_name=months_formatted,
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date
)
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
else:
await bot.send_message(
tg_id, response_message, reply_markup=builder.as_markup()
)
key_info = await get_key_details(session, email)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
return
server_or_cluster = key_info["server_id"]
cluster_id = await resolve_cluster_name(session, server_or_cluster)
if not cluster_id:
logger.error(f"[Error] Кластер для {server_or_cluster} не найден.")
return
await renew_key_in_cluster(
cluster_id,
email,
client_id,
new_expiry_time,
total_gb,
session,
hwid_device_limit=tariff["device_limit"],
)
await update_key_expiry(session, client_id, new_expiry_time)
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(tariff_id=tariff_id)
)
await update_balance(session, tg_id, -cost)
logger.info(
f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})"
)
except Exception as e:
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
+538 -280
View File
File diff suppressed because it is too large Load Diff
+290 -158
View File
@@ -1,30 +1,29 @@
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 sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
HWID_RESET_BUTTON,
QRCODE,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
)
from database import (
get_key_details,
get_keys,
)
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
from database.models import Key
from handlers.buttons import (
ADD_SUB,
ALIAS,
@@ -34,6 +33,7 @@ from handlers.buttons import (
CONNECT_PHONE,
DELETE,
FREEZE,
HWID_BUTTON,
MAIN_MENU,
PC_BUTTON,
QR,
@@ -42,23 +42,18 @@ from handlers.buttons import (
TV_BUTTON,
UNFREEZE,
)
from handlers.texts import (
FROZEN_SUBSCRIPTION_MSG,
NO_SUBSCRIPTIONS_MSG,
key_message,
)
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,
format_months,
get_russian_month,
handle_error,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
@@ -68,19 +63,26 @@ class RenameKeyState(StatesGroup):
@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):
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
tg_id = callback_query_or_message.from_user.id
try:
records = await get_keys(target_message.chat.id, session)
records = await get_keys(session, tg_id)
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
target_message=target_message,
text=response_message,
reply_markup=inline_keyboard,
media_path=image_path,
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
@@ -97,26 +99,34 @@ def build_keys_response(records):
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")
alias = record.alias
email = record.email
client_id = record.client_id
expiry_time = record.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)
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}")
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>"
response_message += (
"</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
)
else:
response_message = NO_SUBSCRIPTIONS_MSG
@@ -134,7 +144,9 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(
InlineKeyboardButton(text=BACK, callback_data="cancel_and_back_to_view_keys")
)
await edit_or_send_message(
target_message=callback.message,
@@ -143,12 +155,25 @@ async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
)
@router.callback_query(F.data == "cancel_and_back_to_view_keys")
async def cancel_and_back(
callback: CallbackQuery, state: FSMContext, session: AsyncSession
):
await state.clear()
await callback.answer()
await process_callback_or_message_view_keys(callback, session)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
async def handle_new_alias_input(
message: Message, state: FSMContext, session: AsyncSession
):
alias = message.text.strip()
if len(alias) > 10:
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
await message.answer(
"❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод."
)
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
@@ -162,11 +187,12 @@ async def handle_new_alias_input(message: Message, state: FSMContext, session: A
try:
await session.execute(
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
alias,
message.chat.id,
client_id,
update(Key)
.where(Key.tg_id == message.chat.id, Key.client_id == client_id)
.values(alias=alias)
)
await session.commit()
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
@@ -178,143 +204,249 @@ async def handle_new_alias_input(message: Message, state: FSMContext, session: A
@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
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
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
async def render_key_info(
message: Message, session: Any, key_name: str, image_path: str
):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
if is_frozen:
builder.row(
InlineKeyboardButton(
text=UNFREEZE,
callback_data=f"unfreeze_subscription|{key_name}",
)
record = await get_key_details(session, key_name)
if not record:
await message.answer("<b>Информация о подписке не найдена.</b>")
return
is_frozen = record["is_frozen"]
record["email"]
client_id = record.get("client_id")
remnawave_link = record.get("remnawave_link")
key = record.get("key")
final_link = key or remnawave_link
builder = InlineKeyboardBuilder()
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,
target_message=message,
text=FROZEN_SUBSCRIPTION_MSG,
reply_markup=builder.as_markup(),
media_path=image_path,
)
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
return
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')} года"
hwid_count = 0
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
if is_full_remnawave and client_id:
servers = await get_servers(session)
remna_server = next(
(
srv
for cl in servers.values()
for srv in cl
if srv.get("panel_type") == "remnawave"
),
None,
)
if remna_server:
api = RemnawaveAPI(remna_server["api_url"])
if await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
devices = await api.get_user_hwid_devices(client_id)
hwid_count = len(devices or [])
tariff_name = ""
traffic_limit = 0
device_limit = 0
tariff = None
if record.get("tariff_id"):
tariff = await get_tariff_by_id(session, record["tariff_id"])
if tariff:
tariff_name = tariff["name"]
traffic_limit = tariff.get("traffic_limit", 0)
device_limit = tariff.get("device_limit", 0)
tariff_duration = ""
if tariff and tariff.get("duration_days", 0) > 0:
duration_days = tariff["duration_days"]
if duration_days >= 30:
months = duration_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(duration_days)
response_message = key_message(
final_link,
formatted_expiry_date,
days_left_message,
server_name,
server_name if USE_COUNTRY_SELECTION else None,
hwid_count=hwid_count if device_limit is not None else 0,
tariff_name=tariff_duration,
traffic_limit=traffic_limit,
device_limit=device_limit
)
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
if is_full_remnawave and final_link:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}")
)
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 HWID_RESET_BUTTON and hwid_count > 0:
builder.row(
InlineKeyboardButton(
text=HWID_BUTTON,
callback_data=f"reset_hwid|{key_name}",
)
)
if QRCODE:
builder.row(InlineKeyboardButton(text=QR, callback_data=f"show_qr|{key_name}"))
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=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=message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
@router.callback_query(F.data.startswith("reset_hwid|"))
async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
if not record:
await callback_query.answer("❌ Ключ не найден.", show_alert=True)
return
client_id = record.get("client_id")
if not client_id:
await callback_query.answer(
"❌ У ключа отсутствует client_id.", show_alert=True
)
return
servers = await get_servers(session=session)
remna_server = next(
(
srv
for cl in servers.values()
for srv in cl
if srv.get("panel_type") == "remnawave"
),
None,
)
if not remna_server:
await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True)
return
api = RemnawaveAPI(remna_server["api_url"])
if not await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await callback_query.answer(
"❌ Авторизация в Remnawave не удалась.", show_alert=True
)
return
devices = await api.get_user_hwid_devices(client_id)
if not devices:
await callback_query.answer("✅ Устройства не были привязаны.", show_alert=True)
else:
deleted = 0
for device in devices:
if await api.delete_user_hwid_device(client_id, device["hwid"]):
deleted += 1
await callback_query.answer(
f"✅ Устройства сброшены ({deleted})", show_alert=True
)
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
+42 -48
View File
@@ -1,39 +1,25 @@
import asyncio
from typing import Any
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from database import (
delete_key,
get_key_details,
get_servers,
)
from handlers.buttons import (
APPLY,
BACK,
CANCEL,
)
from handlers.keys.key_utils import (
delete_key_from_cluster,
update_subscription,
)
from database import delete_key, get_key_details, get_servers
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import delete_key_from_cluster, update_subscription
from handlers.keys.key_view import process_callback_view_key
from handlers.texts import (
DELETE_KEY_CONFIRM_MSG,
KEY_DELETED_MSG_SIMPLE,
)
from handlers.texts import DELETE_KEY_CONFIRM_MSG, KEY_DELETED_MSG_SIMPLE
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("update_subscription|"))
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
async def process_callback_update_subscription(
callback_query: CallbackQuery, session: AsyncSession
):
tg_id = callback_query.message.chat.id
email = callback_query.data.split("|")[1]
@@ -48,7 +34,9 @@ async def process_callback_update_subscription(callback_query: CallbackQuery, se
await process_callback_view_key(callback_query, session)
except Exception as e:
logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}")
await handle_error(tg_id, callback_query, f"Ошибка при обновлении подписки: {e}")
await handle_error(
tg_id, callback_query, f"Ошибка при обновлении подписки: {e}"
)
@router.callback_query(F.data.startswith("delete_key|"))
@@ -59,8 +47,7 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
inline_keyboard=[
[
types.InlineKeyboardButton(
text=APPLY,
callback_data=f"confirm_delete|{client_id}",
text=APPLY, callback_data=f"confirm_delete|{client_id}"
)
],
[types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
@@ -72,49 +59,56 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
)
else:
await callback_query.message.edit_text(text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard)
await callback_query.message.edit_text(
text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
)
except Exception as e:
logger.error(f"Ошибка при обработке запроса на удаление ключа {client_id}: {e}")
logger.error(f"Ошибка при подготовке удаления ключа {client_id}: {e}")
@router.callback_query(F.data.startswith("confirm_delete|"))
async def process_callback_confirm_delete(callback_query: CallbackQuery, session: Any):
async def process_callback_confirm_delete(
callback_query: CallbackQuery, session: AsyncSession
):
email = callback_query.data.split("|")[1]
try:
record = await get_key_details(email, session)
record = await get_key_details(session, email)
if record:
client_id = record["client_id"]
server_id = record["server_id"]
response_message = KEY_DELETED_MSG_SIMPLE
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
back_button = types.InlineKeyboardButton(
text=BACK, callback_data="view_keys"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await delete_key(client_id, session)
await delete_key(session, client_id)
await edit_or_send_message(
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
target_message=callback_query.message,
text=response_message,
reply_markup=keyboard,
)
servers = await get_servers(session)
try:
await delete_key_from_cluster(server_id, email, client_id, session)
except Exception as e:
logger.error(
f"Ошибка при удалении ключа {client_id} с сервера {server_id}: {e}"
)
async def delete_key_from_servers():
try:
tasks = []
for cluster_id, _cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
asyncio.create_task(delete_key_from_servers())
await delete_key(client_id, session)
else:
response_message = "Ключ не найден или уже удален."
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
back_button = types.InlineKeyboardButton(
text=BACK, callback_data="view_keys"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await edit_or_send_message(
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
target_message=callback_query.message,
text=response_message,
reply_markup=keyboard,
)
except Exception as e:
logger.error(e)
logger.error(f"Ошибка при подтверждении удаления ключа: {e}")
await handle_error(callback_query.message.chat.id, callback_query, str(e))
+167 -87
View File
@@ -6,61 +6,84 @@ import time
import urllib.parse
import aiohttp
import asyncpg
from aiohttp import web
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
DATABASE_URL,
PROJECT_NAME,
RANDOM_SUBSCRIPTIONS,
SUPERNODE,
SUPPORT_CHAT_URL,
TOTAL_GB,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
USERNAME_BOT,
)
from database import get_key_details, get_servers
from database.models import Server
from handlers.utils import convert_to_bytes
from logger import logger
async def fetch_url_content(url: str, identifier: str) -> list[str]:
async def fetch_url_content(
url: str, identifier: str
) -> tuple[list[str], dict[str, str]]:
try:
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()
return base64.b64decode(content).decode("utf-8").split("\n")
return []
except Exception:
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"Error fetching URL {url}: {e}")
return [], {}
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]:
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]
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
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))
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, include_remnawave_key: str = None) -> list[str]:
async def get_subscription_urls(
server_id: str, email: str, session: AsyncSession, include_remnawave_key: str = None
) -> list[str]:
urls = []
if USE_COUNTRY_SELECTION:
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
if server_data and server_data["subscription_url"]:
urls.append(f"{server_data['subscription_url']}/{email}")
result = await session.execute(
select(Server.subscription_url).where(Server.server_name == server_id)
)
server_data = result.scalar()
if server_data:
urls.append(f"{server_data}/{email}")
else:
servers = await get_servers(conn)
servers = await get_servers(session)
cluster_servers = servers.get(server_id, [])
for server in cluster_servers:
if url := server.get("subscription_url"):
@@ -72,38 +95,67 @@ async def get_subscription_urls(server_id: str, email: str, conn, include_remnaw
return urls
def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None) -> str:
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
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
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}"
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}")
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}")
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 download == 0 and total == 0 and not country_remaining:
download = 1
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:
@@ -136,7 +188,10 @@ def format_time_left(expiry_time_ms: int | None) -> str:
def prepare_headers(
user_agent: str, project_name: str, subscription_info: str, subscription_userinfo: str
user_agent: str,
project_name: str,
subscription_info: str,
subscription_userinfo: str,
) -> dict[str, str]:
if "Happ" in user_agent:
encoded_project_name = f"{project_name}"
@@ -145,9 +200,11 @@ def prepare_headers(
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"support-url": SUPPORT_CHAT_URL,
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce": "base64:"
+ base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"profile-web-page-url": f"https://t.me/{USERNAME_BOT}",
"subscription-userinfo": subscription_userinfo,
}
@@ -157,7 +214,8 @@ def prepare_headers(
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"),
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
elif "v2raytun" in user_agent:
@@ -167,9 +225,12 @@ def prepare_headers(
"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": "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"),
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
else:
encoded_project_name = f"{project_name}\n{subscription_info}"
@@ -177,7 +238,8 @@ def prepare_headers(
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
}
@@ -188,41 +250,59 @@ async def handle_subscription(request: web.Request) -> web.Response:
if not email or not tg_id:
return web.Response(text="❌ Неверные параметры запроса.", status=400)
conn = await asyncpg.connect(DATABASE_URL)
try:
client_data = await get_key_details(email, conn)
if not client_data:
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
sessionmaker = request.app["sessionmaker"]
stored_tg_id = client_data.get("tg_id")
server_id = client_data["server_id"]
async with sessionmaker() as session:
try:
key = await get_key_details(session, email)
if not key:
return web.Response(
text="❌ Клиент с таким email не найден.", status=404
)
if int(tg_id) != int(stored_tg_id):
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
if int(tg_id) != int(key["tg_id"]):
return web.Response(
text="❌ Неверные данные. Получите свой ключ в боте.", status=403
)
expiry_time_ms = client_data.get("expiry_time")
time_left = format_time_left(expiry_time_ms)
expiry_time_ms = key["expiry_time"]
server_id = key["server_id"]
remnawave_link = key["remnawave_link"]
urls = await get_subscription_urls(
server_id, email, conn, include_remnawave_key=client_data.get("remnawave_link")
)
time_left = format_time_left(expiry_time_ms)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
urls = await get_subscription_urls(
server_id, email, session, include_remnawave_key=remnawave_link
)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
query_string = request.query_string
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]
cleaned_subscriptions = [
clean_subscription_line(line) for line in combined_subscriptions
]
base64_encoded = base64.b64encode("\n".join(cleaned_subscriptions).encode("utf-8")).decode("utf-8")
subscription_info = f"📄 Подписка: {email} - {time_left}"
base64_encoded = base64.b64encode(
"\n".join(cleaned_subscriptions).encode("utf-8")
).decode("utf-8")
subscription_info = f"📄 Подписка: {email}{time_left}"
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(
cleaned_subscriptions, expiry_time_ms, headers_list
)
headers = prepare_headers(
user_agent, PROJECT_NAME, subscription_info, subscription_userinfo
)
return web.Response(text=base64_encoded, headers=headers)
finally:
await conn.close()
return web.Response(text=base64_encoded, headers=headers)
except Exception as e:
logger.error(f"Ошибка в handle_subscription: {e}", exc_info=True)
return web.Response(text=f"❌ Ошибка сервера: {e}", status=500)
-1
View File
@@ -5,7 +5,6 @@ from aiogram import Router
from .general_notifications import router as general_notifications_router
from .special_notifications import router as special_notifications_router
router = Router(name="notifications_main_router")
router.include_routers(general_notifications_router, special_notifications_router)
+424 -217
View File
@@ -1,23 +1,18 @@
import asyncio
from datetime import datetime, timedelta
import asyncpg
import pytz
from aiogram import Bot, Router
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from config import (
DATABASE_URL,
NOTIFICATION_TIME,
NOTIFY_DELETE_DELAY,
NOTIFY_DELETE_KEY,
NOTIFY_HOT_LEADS,
NOTIFY_INACTIVE_TRAFFIC,
NOTIFY_MAXPRICE,
NOTIFY_RENEW,
NOTIFY_RENEW_EXPIRED,
RENEWAL_PRICES,
TOTAL_GB,
TRIAL_TIME_DISABLE,
)
from database import (
@@ -29,126 +24,163 @@ from database import (
get_all_keys,
get_balance,
get_last_notification_time,
get_tariffs_for_cluster,
update_balance,
update_key_expiry,
update_key_tariff,
check_tariff_exists,
get_tariff_by_id,
)
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,
KEY_EXPIRED_DELAY_HOURS_MSG,
KEY_EXPIRED_DELAY_MINUTES_MSG,
KEY_EXPIRED_DELAY_MSG,
KEY_EXPIRED_NO_DELAY_MSG,
KEY_EXPIRY_10H,
KEY_EXPIRY_24H,
KEY_RENEWED,
KEY_RENEWED_TEMP_MSG,
get_renewal_message,
)
from handlers.utils import format_hours, format_minutes
from handlers.utils import format_hours, format_minutes, get_russian_month, format_months, format_days
from logger import logger
from .hot_leads_notifications import notify_hot_leads
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()
async def periodic_notifications(bot: Bot):
"""
Периодическая проверка и отправка уведомлений.
Защищена от одновременного запуска с помощью asyncio.Lock.
"""
async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
while True:
if notification_lock.locked():
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
logger.warning("Уведомления уже выполняются. Пропуск...")
await asyncio.sleep(NOTIFICATION_TIME)
continue
async with notification_lock:
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
async with sessionmaker() as session:
logger.info("🔔 Запуск обработки уведомлений")
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)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_10h = int(
(datetime.now(moscow_tz) + timedelta(hours=10)).timestamp()
* 1000
)
threshold_24h = int(
(datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000
)
logger.info("Запуск обработки уведомлений")
try:
keys = await get_all_keys(session=session)
keys = [k for k in keys if not k.is_frozen]
except Exception as e:
logger.error(f"Ошибка при получении ключей: {e}")
keys = []
try:
keys = await get_all_keys(session=conn)
keys = [k for k in keys if not k["is_frozen"]]
except Exception as e:
logger.error(f"Ошибка при получении ключей: {e}")
keys = []
if not TRIAL_TIME_DISABLE:
try:
await notify_inactive_trial_users(bot, session)
except Exception as e:
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
if not TRIAL_TIME_DISABLE:
await notify_inactive_trial_users(bot, conn)
await asyncio.sleep(0.5)
try:
await notify_24h_keys(
bot, session, current_time, threshold_24h, keys
)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
await asyncio.sleep(1)
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
await asyncio.sleep(1)
await handle_expired_keys(bot, conn, current_time, keys)
await asyncio.sleep(0.5)
if NOTIFY_INACTIVE_TRAFFIC:
await notify_users_no_traffic(bot, conn, current_time, keys)
await asyncio.sleep(0.5)
try:
await notify_10h_keys(
bot, session, current_time, threshold_10h, keys
)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
logger.info("Завершена обработка уведомлений")
try:
await handle_expired_keys(bot, session, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в handle_expired_keys: {e}")
if NOTIFY_INACTIVE_TRAFFIC:
try:
await notify_users_no_traffic(
bot, session, current_time, keys
)
except Exception as e:
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
if NOTIFY_HOT_LEADS:
try:
await notify_hot_leads(bot, session)
except Exception as e:
logger.error(f"Ошибка в notify_hot_leads: {e}")
logger.info("✅ Уведомления завершены")
except Exception as e:
logger.error(f"Ошибка в periodic_notifications: {e}")
finally:
if conn:
await conn.close()
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):
async def notify_24h_keys(
bot: Bot,
session: AsyncSession,
current_time: int,
threshold_time_24h: int,
keys: list,
):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
"""
logger.info("Начало проверки подписок, истекающих через 24 часа.")
expiring_keys = [
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_24h
key
for key in keys
if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.get("email", "") for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
users = await check_notifications_bulk(
session, "key_24h", 24, tg_ids=tg_ids, emails=emails
)
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", "")
email = key.email or ""
notification_id = f"{email}_key_24h"
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
can_notify = await check_notification_time(
session, tg_id, notification_id, hours=24
)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
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")
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
f"⏳ Осталось времени: {format_hours(hours_left)}"
if hours_left > 0
else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -161,59 +193,104 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
)
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,
session,
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)
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_24h.jpg",
"keyboard": keyboard,
})
await add_notification(tg_id, notification_id, session=conn)
messages.append(
{
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_24h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}
)
if messages:
await send_messages_with_limit(bot, messages)
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 24 часа.")
results = await send_messages_with_limit(bot, messages, session=session)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(session, tg_id, msg["notification_id"])
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)
async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_10h: int, keys: list):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 10 часов.
"""
async def notify_10h_keys(
bot: Bot,
session: AsyncSession,
current_time: int,
threshold_time_10h: int,
keys: list,
):
logger.info("Начало проверки подписок, истекающих через 10 часов.")
expiring_keys = [
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_10h
key
for key in keys
if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.get("email", "") for key in expiring_keys]
tg_ids = [key.tg_id for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
users = await check_notifications_bulk("key_10h", 10, conn, tg_ids=tg_ids, emails=emails)
users = await check_notifications_bulk(
session, "key_10h", 10, tg_ids=tg_ids, emails=emails
)
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
tg_id = key.tg_id
email = key.email or ""
notification_id = f"{email}_key_10h"
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
can_notify = await check_notification_time(
session, tg_id, notification_id, hours=10
)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
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")
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
f"⏳ Осталось времени: {format_hours(hours_left)}"
if hours_left > 0
else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -228,69 +305,110 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
if NOTIFY_RENEW:
try:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_10h.jpg", notification_text
bot,
session,
key,
notification_id,
1,
"notify_10h.jpg",
notification_text,
)
except Exception as e:
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
logger.error(
f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}"
)
continue
else:
keyboard = build_notification_kb(email)
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_10h.jpg",
"keyboard": keyboard,
})
await add_notification(tg_id, notification_id, session=conn)
messages.append(
{
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_10h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}
)
if messages:
await send_messages_with_limit(bot, messages)
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 10 часов.")
results = await send_messages_with_limit(bot, messages, session=session)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(session, tg_id, msg["notification_id"])
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)
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
"""
Обрабатывает истекшие ключи, проверяя продление или удаление.
"""
async def handle_expired_keys(
bot: Bot,
session: AsyncSession,
current_time: int,
keys: list,
):
logger.info("Начало обработки истекших ключей.")
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
expired_keys = [
key for key in keys if key.expiry_time and key.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]
tg_ids = [key.tg_id for key in expired_keys]
emails = [key.email or "" for key in expired_keys]
users = await check_notifications_bulk(
session, "key_expired", 0, tg_ids=tg_ids, emails=emails
)
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", "")
client_id = key.get("client_id")
server_id = key.get("server_id")
tg_id = key.tg_id
email = key.email or ""
client_id = key.client_id
server_id = key.server_id
notification_id = f"{email}_key_expired"
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
last_notification_time = await get_last_notification_time(
session, tg_id, notification_id
)
if NOTIFY_RENEW_EXPIRED:
try:
balance = await get_balance(tg_id)
except Exception as e:
logger.error(f"Ошибка получения баланса для пользователя {tg_id}: {e}")
continue
balance = await get_balance(session, tg_id)
tariffs = await get_tariffs_for_cluster(session, server_id)
tariff = tariffs[0] if tariffs else None
renewal_period_months = 1
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
if balance >= renewal_cost:
try:
if tariff and balance >= tariff["price_rub"]:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
bot,
session,
key,
notification_id,
1,
"notify_expired.jpg",
get_renewal_message(
tariff_name=tariff.get("name", ""),
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0
),
)
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
continue
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
continue
if NOTIFY_DELETE_KEY:
@@ -298,7 +416,9 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
delete_after_delay = False
if last_notification_time is not None:
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= NOTIFY_DELETE_DELAY
delete_after_delay = (
current_time - last_notification_time
) / (1000 * 60) >= NOTIFY_DELETE_DELAY
logger.info(
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
f"NOTIFY_DELETE_DELAY={NOTIFY_DELETE_DELAY}"
@@ -306,139 +426,226 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
if delete_immediately or delete_after_delay:
try:
await delete_key_from_cluster(server_id, email, client_id)
await delete_key(client_id, conn)
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
await delete_key_from_cluster(server_id, email, client_id, session)
await delete_key(session, client_id)
logger.info(
f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён."
)
keyboard = build_notification_expired_kb()
messages.append({
"tg_id": tg_id,
"text": KEY_DELETED_MSG.format(email=email),
"photo": "notify_expired.jpg",
"keyboard": keyboard,
})
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 and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
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:
hours = NOTIFY_DELETE_DELAY // 60
minutes = NOTIFY_DELETE_DELAY % 60
if hours > 0:
if minutes > 0:
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
)
else:
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
email=email, hours_formatted=format_hours(hours)
)
if hours > 0 and minutes > 0:
time_formatted = f"{format_hours(hours)} и {format_minutes(minutes)}"
elif hours > 0:
time_formatted = format_hours(hours)
else:
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
email=email, minutes_formatted=format_minutes(minutes)
)
time_formatted = format_minutes(minutes)
delay_message = KEY_EXPIRED_DELAY_MSG.format(
email=email,
time_formatted=time_formatted
)
else:
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
messages.append({
"tg_id": tg_id,
"text": delay_message,
"photo": "notify_expired.jpg",
"keyboard": keyboard,
})
await add_notification(tg_id, notification_id, session=conn)
messages.append(
{
"tg_id": tg_id,
"text": delay_message,
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}
)
if messages:
await send_messages_with_limit(bot, messages)
logger.info(f"Отправлено {len(messages)} уведомлений об истекших ключах.")
results = await send_messages_with_limit(bot, messages, session=session)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
await add_notification(session, msg["tg_id"], msg["notification_id"])
if result:
sent_count += 1
logger.info(
f"📢 Уведомление об истекшем ключе {msg['email']} отправлено пользователю {msg['tg_id']}."
)
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекшем ключе {msg['email']} пользователю {msg['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,
notification_id: str,
renewal_period_months: int,
standard_photo: str,
standard_caption: str,
):
"""
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
иначе отправляет стандартное уведомление.
"""
tg_id = key.get("tg_id")
email = key.get("email", "")
tg_id = key.tg_id
email = key.email or ""
renew_notification_id = f"{email}_renew"
try:
can_renew = await check_notification_time(tg_id, renew_notification_id, hours=24, session=conn)
can_renew = await check_notification_time(
conn, tg_id, renew_notification_id, hours=24
)
if not can_renew:
logger.info(
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
)
return
balance = await get_balance(tg_id)
except Exception as e:
logger.error(f"Ошибка получения данных для пользователя {tg_id}: {e}")
return
balance = await get_balance(conn, tg_id)
server_id = key.server_id
tariff_id = key.tariff_id
if NOTIFY_MAXPRICE:
renewal_period_months = max(
(int(months) for months, price in RENEWAL_PRICES.items() if balance >= price), default=None
tariffs = await get_tariffs_for_cluster(conn, server_id)
if not tariffs:
logger.warning(
f"⛔ Нет доступных тарифов для продления подписки {email} (сервер: {server_id})"
)
return
selected_tariff = None
if not tariff_id:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
else:
if await check_tariff_exists(conn, tariff_id):
current_tariff = await get_tariff_by_id(conn, tariff_id)
if current_tariff["group_code"] in ["discounts", "discounts_max", "gifts"]:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
elif balance >= current_tariff["price_rub"]:
selected_tariff = current_tariff
else:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
if not selected_tariff:
keyboard = build_notification_kb(email)
await add_notification(conn, tg_id, notification_id)
await send_notification(
bot, tg_id, standard_photo, standard_caption, keyboard
)
return
client_id = key.client_id
current_expiry = key.expiry_time
duration_days = selected_tariff["duration_days"]
tariff_duration = ""
if duration_days > 0:
if duration_days >= 30:
months = duration_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(duration_days)
renewal_cost = selected_tariff["price_rub"]
traffic_limit = selected_tariff["traffic_limit"]
device_limit = selected_tariff["device_limit"]
total_gb = traffic_limit if traffic_limit else 0
new_expiry_time = (
current_expiry
if current_expiry > datetime.utcnow().timestamp() * 1000
else datetime.utcnow().timestamp() * 1000
) + duration_days * 24 * 60 * 60 * 1000
formatted_expiry_date = datetime.fromtimestamp(
new_expiry_time / 1000, tz=moscow_tz
).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz))
)
else:
renewal_period_months = 1 if balance >= RENEWAL_PRICES["1"] else None
if renewal_period_months:
renewal_period_months = int(renewal_period_months)
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
client_id = key.get("client_id")
server_id = key.get("server_id")
current_expiry = key.get("expiry_time")
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} на {duration_days} дней для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
)
try:
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)
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=client_id,
new_expiry_time=int(new_expiry_time),
total_gb=total_gb,
hwid_device_limit=device_limit,
session=conn
)
await update_balance(conn, tg_id, -renewal_cost)
await update_key_expiry(conn, client_id, int(new_expiry_time))
await update_key_tariff(conn, client_id, selected_tariff["id"])
await add_notification(conn, tg_id, renew_notification_id)
await delete_notification(conn, tg_id, notification_id)
await add_notification(tg_id, renew_notification_id, session=conn)
await delete_notification(tg_id, notification_id, session=conn)
renewed_message = get_renewal_message(
tariff_name=tariff_duration,
traffic_limit=selected_tariff.get("traffic_limit") if selected_tariff.get("traffic_limit") is not None else 0,
device_limit=selected_tariff.get("device_limit") if selected_tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date
)
logger.info(
f"✅ Ключ {client_id} продлён на {renewal_period_months} мес. для пользователя {tg_id}. Списано {renewal_cost}."
)
renewed_message = KEY_RENEWED.format(
email=email, months=renewal_period_months, expiry_date=formatted_expiry_date
)
keyboard = build_notification_expired_kb()
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)
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
keyboard = build_notification_expired_kb()
result = await send_notification(
bot, tg_id, "notify_expired.jpg", renewed_message, keyboard
)
if result:
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
logger.info(
f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}."
)
else:
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
logger.warning(
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
)
except Exception as e:
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {e}")
@@ -0,0 +1,129 @@
from aiogram import Bot
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS, HOT_LEAD_INTERVAL_HOURS
from database import add_notification, check_notification_time, get_hot_leads
from database.models import Notification
from handlers.buttons import MAIN_MENU
from handlers.notifications.notify_kb import build_hot_lead_kb
from handlers.notifications.notify_utils import send_notification
from handlers.texts import (
HOT_LEAD_FINAL_MESSAGE,
HOT_LEAD_LOST_OPPORTUNITY,
HOT_LEAD_MESSAGE,
)
from logger import logger
async def notify_hot_leads(bot: Bot, session: AsyncSession):
logger.info("🚀 Запуск уведомлений для горячих лидов.")
try:
leads = await get_hot_leads(session)
notified = 0
for tg_id in leads:
has_step_1 = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_1")
.exists()
)
)
if not has_step_1:
await add_notification(session, tg_id, "hot_lead_step_1")
logger.info(f"[HOT LEAD] Шаг 1 — зафиксировано без отправки: {tg_id}")
continue
has_step_2 = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_2")
.exists()
)
)
if not has_step_2:
can_send = await check_notification_time(
session,
tg_id=tg_id,
notification_type="hot_lead_step_1",
hours=HOT_LEAD_INTERVAL_HOURS,
)
if not can_send:
continue
keyboard = build_hot_lead_kb()
result = await send_notification(
bot, tg_id, None, HOT_LEAD_MESSAGE, keyboard
)
if result:
await add_notification(session, tg_id, "hot_lead_step_2")
logger.info(f"🔥 Шаг 2 — отправлено первое уведомление: {tg_id}")
notified += 1
continue
has_step_3 = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_3")
.exists()
)
)
has_expired_notification = await session.scalar(
select(
select(Notification)
.filter_by(tg_id=tg_id, notification_type="hot_lead_step_2_expired")
.exists()
)
)
if not has_step_3 and not has_expired_notification:
expired = await check_notification_time(
session,
tg_id=tg_id,
notification_type="hot_lead_step_2",
hours=DISCOUNT_ACTIVE_HOURS,
)
if expired:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
)
result = await send_notification(
bot, tg_id, None, HOT_LEAD_LOST_OPPORTUNITY, builder.as_markup()
)
if result:
await add_notification(
session, tg_id, "hot_lead_step_2_expired"
)
logger.info(
f"📭 Скидка упущена — отправлено уведомление: {tg_id}"
)
continue
if not has_step_3:
can_send = await check_notification_time(
session,
tg_id=tg_id,
notification_type="hot_lead_step_2",
hours=HOT_LEAD_INTERVAL_HOURS,
)
if not can_send:
continue
keyboard = build_hot_lead_kb(final=True)
result = await send_notification(
bot, tg_id, None, HOT_LEAD_FINAL_MESSAGE, keyboard
)
if result:
await add_notification(session, tg_id, "hot_lead_step_3")
logger.info(f"⚡ Шаг 3 — отправлено финальное уведомление: {tg_id}")
notified += 1
logger.info(f"✅ Уведомления завершены. Отправлено: {notified}")
except Exception as e:
logger.error(f"❌ Ошибка в notify_hot_leads: {e}")
+33 -2
View File
@@ -1,6 +1,6 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from handlers.buttons import MAIN_MENU, RENEW_KEY
from handlers.buttons import DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
@@ -27,3 +27,34 @@ def build_notification_expired_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=MAIN_MENU, callback_data="profile")
return builder.as_markup()
def build_hot_lead_kb(final: bool = False) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=DISCOUNT_TARIFF if not final else MAX_DISCOUNT_TARIFF,
callback_data=(
"hot_lead_discount" if not final else "hot_lead_final_discount"
),
)
]
]
)
def build_tariffs_keyboard(
tariffs: list[dict], prefix: str = "tariff"
) -> InlineKeyboardMarkup:
buttons = [
[
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
callback_data=f"{prefix}|{t['id']}",
)
]
for t in tariffs
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
+80 -18
View File
@@ -2,30 +2,84 @@ import asyncio
import os
import aiofiles
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
from aiogram.exceptions import (
TelegramBadRequest,
TelegramForbiddenError,
TelegramRetryAfter,
)
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from database import create_blocked_user
from logger import logger
async def send_messages_with_limit(bot: Bot, messages: list[dict], messages_per_second: int = 25):
async def send_messages_with_limit(
bot: Bot,
messages: list[dict],
session: AsyncSession = 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")))
try:
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"⚠ Ошибка при отправке сообщений в батче: {e}")
tasks = [
send_notification(
bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")
)
for msg in batch
]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for msg, result in zip(batch, batch_results, strict=False):
tg_id = msg["tg_id"]
if isinstance(result, bool) and result:
results.append(True)
elif isinstance(result, TelegramForbiddenError):
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session, source_file)
results.append(False)
elif (
isinstance(result, TelegramBadRequest)
and "chat not found" in str(result).lower()
):
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
await try_add_blocked_user(tg_id, session, source_file)
results.append(False)
else:
logger.warning(
f"📩 Не удалось отправить уведомление пользователю {tg_id}."
)
await try_add_blocked_user(tg_id, session, source_file)
results.append(False)
await asyncio.sleep(1.0)
return results
async def try_add_blocked_user(
tg_id: int, session: AsyncSession, source_file: str | None
):
"""
Добавляет пользователя в список заблокировавших бота, если требуется.
"""
if source_file == "special_notifications" and session:
try:
await create_blocked_user(session, tg_id)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception as e:
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
def rate_limited_send(func):
async def wrapper(*args, **kwargs):
@@ -38,7 +92,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]
@@ -63,7 +121,9 @@ async def send_notification(
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)
return await _send_photo_notification(
bot, tg_id, photo_path, image_filename, caption, keyboard
)
else:
logger.warning(f"Файл с изображением не найден: {photo_path}")
return await _send_text_notification(bot, tg_id, caption, keyboard)
@@ -83,10 +143,11 @@ async def _send_photo_notification(
async with aiofiles.open(photo_path, "rb") as image_file:
image_data = await image_file.read()
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
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}")
@@ -104,9 +165,10 @@ 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}")
logger.error(
f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}"
)
return False
+109 -97
View File
@@ -1,48 +1,47 @@
import asyncio
from datetime import datetime, timedelta
import asyncpg
import pytz
from aiogram import Bot, Router, types
from aiogram.exceptions import TelegramForbiddenError
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
from config import (
NOTIFY_EXTRA_DAYS,
NOTIFY_INACTIVE,
NOTIFY_INACTIVE_TRAFFIC,
SUPPORT_CHAT_URL,
TRIAL_CONFIG,
)
from database import (
add_notification,
check_notifications_bulk,
create_blocked_user,
mark_trial_extended,
update_key_notified,
)
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 handlers.utils import format_days
from .notify_utils import send_messages_with_limit, send_notification
from .notify_utils import send_messages_with_limit
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
"""
Проверяет пользователей, не активировавших пробный период, и отправляет им напоминания.
Первое уведомление стандартное.
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
"""
async def notify_inactive_trial_users(bot: Bot, session: AsyncSession):
logger.info("Проверка пользователей, не активировавших пробный период...")
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
users = await check_notifications_bulk(session, "inactive_trial", NOTIFY_INACTIVE)
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
messages = []
trial_days = TRIAL_CONFIG["duration_days"]
for user in users:
tg_id = user["tg_id"]
username = user["username"]
@@ -53,8 +52,7 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
text="🚀 Активировать пробный период", callback_data="create_key"
)
)
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -63,120 +61,134 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
trial_extended = user["last_notification_time"] is not None
if trial_extended:
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
total_days = NOTIFY_EXTRA_DAYS + trial_days
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)
await mark_trial_extended(tg_id, session)
else:
message = TRIAL_INACTIVE_FIRST_MSG.format(
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
display_name=display_name,
trial_time_formatted=format_days(trial_days),
)
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}")
await asyncio.sleep(1)
messages.append(
{
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"notification_id": "inactive_trial",
}
)
if messages:
results = await send_messages_with_limit(
bot,
messages,
session=session,
source_file="special_notifications",
messages_per_second=25,
)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
if result:
await add_notification(session, msg["tg_id"], msg["notification_id"])
sent_count += 1
logger.info(f"Отправлено {sent_count} уведомлений неактивным пользователям.")
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
"""
Проверяет трафик пользователей, у которых ещё не отправлялось уведомление о нулевом трафике.
Если трафик 0 ГБ и прошло более 2 часов с момента создания ключа, отправляет уведомление,
но исключает пользователей, у которых подписка недавно продлилась.
"""
async def notify_users_no_traffic(
bot: Bot, session: AsyncSession, current_time: int, keys: list
):
logger.info("Проверка пользователей с нулевым трафиком...")
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
messages = []
for key in keys:
tg_id = key.get("tg_id")
email = key.get("email")
created_at = key.get("created_at")
client_id = key.get("client_id")
expiry_time = key.get("expiry_time")
notified = key.get("notified")
tg_id = key.tg_id
email = key.email
created_at = key.created_at
client_id = key.client_id
expiry_time = key.expiry_time
notified = key.notified
if created_at is None:
logger.warning(f"Для {email} нет значения created_at. Пропускаем.")
if created_at is None or notified:
continue
if notified is True:
created_at_dt = pytz.utc.localize(
datetime.fromtimestamp(created_at / 1000)
).astimezone(moscow_tz)
if current_dt < created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC):
continue
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
created_at_plus_2 = created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
if expiry_time:
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:
expiry_dt = pytz.utc.localize(
datetime.fromtimestamp(expiry_time / 1000)
).astimezone(moscow_tz)
if (current_dt - (expiry_dt - timedelta(days=30))) < timedelta(
hours=NOTIFY_INACTIVE_TRAFFIC
):
continue
if current_dt < created_at_plus_2:
continue
try:
traffic_data = await get_user_traffic(conn, tg_id, email)
traffic_data = await get_user_traffic(session, tg_id, email)
except Exception as e:
logger.error(f"Ошибка получения трафика для {email}: {e}")
continue
if traffic_data.get("status") != "success":
logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
logger.warning(
f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}"
)
continue
total_traffic = sum(
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
value if isinstance(value, int | float) else 0
for value in traffic_data.get("traffic", {}).values()
)
logger.info(f"Ключ для {email}: общий трафик: {total_traffic} ГБ")
try:
await update_key_notified(session, tg_id, client_id)
except Exception as e:
logger.error(f"Ошибка обновления notified для {tg_id} ({client_id}): {e}")
continue
if total_traffic == 0:
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
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"))
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:
result = await send_notification(bot, tg_id, None, message, keyboard)
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
)
if result:
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
else:
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_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}")
if messages:
results = await send_messages_with_limit(
bot,
messages,
session=session,
source_file="special_notifications",
messages_per_second=25,
)
sent_count = sum(result for result in results if result)
logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
+51 -52
View File
@@ -1,10 +1,12 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CRYPTO_BOT_ENABLE,
@@ -15,6 +17,7 @@ from config import (
YOOMONEY_ENABLE,
)
from database import get_last_payments
from database.models import User
from handlers.buttons import (
BALANCE_HISTORY,
COUPON,
@@ -26,78 +29,78 @@ from handlers.buttons import (
YOOKASSA,
YOOMONEY,
)
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
from handlers.payments.cryprobot_pay import process_callback_pay_cryptobot
from handlers.payments.robokassa_pay import process_callback_pay_robokassa
from handlers.payments.stars_pay import process_callback_pay_stars
from handlers.payments.yookassa_pay import process_callback_pay_yookassa
from handlers.payments.yoomoney_pay import process_callback_pay_yoomoney
from handlers.texts import BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
from .utils import edit_or_send_message
router = Router()
@router.callback_query(F.data == "pay")
async def handle_pay(callback_query: CallbackQuery):
async def handle_pay(
callback_query: CallbackQuery, state: FSMContext, session: AsyncSession
):
payment_handlers = []
if YOOKASSA_ENABLE:
payment_handlers.append(process_callback_pay_yookassa)
if YOOMONEY_ENABLE:
payment_handlers.append(process_callback_pay_yoomoney)
if CRYPTO_BOT_ENABLE:
payment_handlers.append(process_callback_pay_cryptobot)
if STARS_ENABLE:
payment_handlers.append(process_callback_pay_stars)
if ROBOKASSA_ENABLE:
payment_handlers.append(process_callback_pay_robokassa)
if len(payment_handlers) == 1:
await callback_query.answer()
return await payment_handlers[0](callback_query, state, session)
builder = InlineKeyboardBuilder()
if YOOKASSA_ENABLE:
builder.row(
InlineKeyboardButton(
text=YOOKASSA,
callback_data="pay_yookassa",
)
)
builder.row(InlineKeyboardButton(text=YOOKASSA, callback_data="pay_yookassa"))
if YOOMONEY_ENABLE:
builder.row(
InlineKeyboardButton(
text=YOOMONEY,
callback_data="pay_yoomoney",
)
)
builder.row(InlineKeyboardButton(text=YOOMONEY, callback_data="pay_yoomoney"))
if CRYPTO_BOT_ENABLE:
builder.row(
InlineKeyboardButton(
text=CRYPTOBOT,
callback_data="pay_cryptobot",
)
)
builder.row(InlineKeyboardButton(text=CRYPTOBOT, callback_data="pay_cryptobot"))
if STARS_ENABLE:
builder.row(
InlineKeyboardButton(
text=STARS,
callback_data="pay_stars",
)
)
builder.row(InlineKeyboardButton(text=STARS, callback_data="pay_stars"))
if ROBOKASSA_ENABLE:
builder.row(
InlineKeyboardButton(
text=ROBOKASSA,
callback_data="pay_robokassa",
)
)
builder.row(InlineKeyboardButton(text=ROBOKASSA, callback_data="pay_robokassa"))
if DONATIONS_ENABLE:
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
builder.row(
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=PAYMENT_METHODS_MSG,
reply_markup=builder.as_markup(),
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
async def balance_handler(callback_query: CallbackQuery, session: AsyncSession):
stmt = select(User.balance).where(User.tg_id == callback_query.from_user.id)
result = await session.execute(stmt)
balance = result.scalar_one_or_none() or 0.0
balance = int(balance)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
builder.row(
InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history")
)
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -119,21 +122,17 @@ async def balance_history_handler(callback_query: CallbackQuery, session: Any):
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)
records = await get_last_payments(session, callback_query.from_user.id)
if records:
history_text = BALANCE_HISTORY_HEADER
history_text = "<b>💳 История операций:</b>\n\n<blockquote>"
for record in records:
amount = record["amount"]
payment_system = record["payment_system"]
status = record["status"]
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
history_text += (
f"<b>Сумма:</b> {amount}\n"
f"<b>Способ оплаты:</b> {payment_system}\n"
f"<b>Статус:</b> {status}\n"
f"<b>Дата:</b> {date}\n\n"
)
history_text += f"Сумма: {amount}\nОплата: {payment_system}\nСтатус: {status}\nДата: {date}\n\n"
history_text += "</blockquote>"
else:
history_text = "❌ У вас пока нет операций с балансом."
+1
View File
@@ -1,6 +1,7 @@
__all__ = ("router",)
from aiogram import Router
from config import (
CRYPTO_BOT_ENABLE,
ROBOKASSA_ENABLE,
+52 -39
View File
@@ -1,38 +1,37 @@
import hashlib
from typing import Any
import asyncpg
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiohttp import web
from robokassa import HashAlgorithm, Robokassa
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
DATABASE_URL,
ROBOKASSA_ENABLE,
ROBOKASSA_LOGIN,
ROBOKASSA_PASSWORD1,
ROBOKASSA_PASSWORD2,
ROBOKASSA_TEST_MODE,
)
from robokassa import HashAlgorithm, Robokassa
from database import (
add_user,
check_user_exists,
add_payment,
add_user,
async_session_maker,
check_user_exists,
get_key_count,
get_temporary_data,
update_balance,
)
from handlers.buttons import BACK, PAY_2
from handlers.payments.utils import send_payment_success_notification
from handlers.texts import PAYMENT_OPTIONS, ENTER_SUM, DEFAULT_PAYMENT_MESSAGE
from handlers.texts import DEFAULT_PAYMENT_MESSAGE, ENTER_SUM, PAYMENT_OPTIONS
from handlers.utils import edit_or_send_message
from logger import logger
from handlers.buttons import BACK, PAY_2
router = Router()
@@ -55,7 +54,9 @@ if ROBOKASSA_ENABLE:
def generate_payment_link(amount, inv_id, description, tg_id):
"""Генерация ссылки на оплату."""
logger.debug(f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}")
logger.debug(
f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}"
)
payment_link = robokassa._payment.link.generate_by_script(
out_sum=amount,
inv_id=inv_id,
@@ -67,7 +68,9 @@ def generate_payment_link(amount, inv_id, description, tg_id):
@router.callback_query(F.data == "pay_robokassa")
async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
async def process_callback_pay_robokassa(
callback_query: types.CallbackQuery, state: FSMContext, session: Any
):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} initiated Robokassa payment.")
@@ -91,12 +94,12 @@ async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, st
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
key_count = await get_key_count(tg_id)
key_count = await get_key_count(session, tg_id)
if key_count == 0:
exists = await check_user_exists(tg_id)
exists = await check_user_exists(session, tg_id)
if not exists:
from_user = callback_query.from_user
await add_user(
@@ -110,20 +113,23 @@ async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, st
)
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
await callback_query.message.delete()
new_message = await callback_query.message.answer(
text="Выберите сумму пополнения:",
reply_markup=builder.as_markup(),
)
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
await state.update_data(
message_id=new_message.message_id, chat_id=new_message.chat.id
)
await state.set_state(ReplenishBalanceState.choosing_amount_robokassa)
logger.info(f"Displayed amount selection for user {tg_id}.")
@router.callback_query(F.data.startswith("robokassa_amount|"))
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
async def process_amount_selection(
callback_query: types.CallbackQuery, state: FSMContext
):
logger.info(f"Получены данные callback_data: {callback_query.data}")
data = callback_query.data.split("|")
@@ -177,8 +183,7 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
async def robokassa_webhook(request):
"""Обработка webhook-уведомлений от Robokassa с учетом shp_id."""
async def robokassa_webhook(request: web.Request):
try:
params = await request.post()
@@ -189,7 +194,9 @@ async def robokassa_webhook(request):
shp_id = params.get("shp_id")
signature_value = params.get("SignatureValue")
logger.info(f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}")
logger.info(
f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}"
)
if not check_payment_signature(params):
logger.error("Неверная подпись или данные запроса.")
@@ -203,10 +210,10 @@ async def robokassa_webhook(request):
logger.info(f"Processing payment for user {tg_id} with amount {amount}.")
await update_balance(int(tg_id), float(amount))
await send_payment_success_notification(tg_id, float(amount))
await add_payment(int(tg_id), float(amount), "robokassa")
async with async_session_maker() as session:
await update_balance(session, int(tg_id), float(amount))
await send_payment_success_notification(tg_id, float(amount), session)
await add_payment(session, int(tg_id), float(amount), "robokassa")
logger.info(f"Payment successful. Balance updated for user {tg_id}.")
@@ -228,7 +235,9 @@ def check_payment_signature(params):
logger.info(f"Signature string before hashing: {signature_string}")
expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
expected_signature = (
hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
)
logger.info(f"Expected signature: {expected_signature}")
logger.info(f"Received signature: {signature_value}")
@@ -237,7 +246,9 @@ def check_payment_signature(params):
@router.callback_query(F.data == "enter_custom_amount_robokassa")
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
async def process_custom_amount_selection(
callback_query: types.CallbackQuery, state: FSMContext
):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} chose to enter a custom amount.")
@@ -251,12 +262,16 @@ async def process_custom_amount_selection(callback_query: types.CallbackQuery, s
force_text=True,
)
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
await state.set_state(
ReplenishBalanceState.waiting_for_payment_confirmation_robokassa
)
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
async def handle_custom_amount_input(
message: types.Message | types.CallbackQuery, state: FSMContext = None, session: Any = None
message: types.Message | types.CallbackQuery,
state: FSMContext = None,
session: AsyncSession = None,
):
if isinstance(message, types.CallbackQuery):
tg_id = message.message.chat.id
@@ -269,9 +284,7 @@ async def handle_custom_amount_input(
inv_id = 0
try:
conn = await asyncpg.connect(DATABASE_URL)
user_data = await get_temporary_data(conn, tg_id)
await conn.close()
user_data = await get_temporary_data(session, tg_id)
if not user_data:
await edit_or_send_message(
@@ -300,13 +313,9 @@ async def handle_custom_amount_input(
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
if state_type == "waiting_for_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
)
message_text = f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
elif state_type == "waiting_for_renewal_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
)
message_text = f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
else:
await edit_or_send_message(
target_message=target_message,
@@ -315,7 +324,11 @@ async def handle_custom_amount_input(
)
return
await edit_or_send_message(target_message=target_message, text=message_text, reply_markup=builder.as_markup())
await edit_or_send_message(
target_message=target_message,
text=message_text,
reply_markup=builder.as_markup(),
)
if isinstance(state, FSMContext):
await state.clear()
+49 -57
View File
@@ -1,19 +1,12 @@
import html
import os
import asyncpg
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
Message,
)
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
DATABASE_URL,
GIFT_BUTTON,
INSTRUCTIONS_BUTTON,
NEWS_MESSAGE,
@@ -32,13 +25,13 @@ from handlers.buttons import (
MY_SUBS,
TRIAL_SUB,
)
from handlers.texts import ADD_SUBSCRIPTION_HINT
from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .texts import profile_message_send
from .utils import edit_or_send_message
router = Router()
@@ -48,6 +41,7 @@ async def process_callback_view_profile(
callback_query_or_message: Message | CallbackQuery,
state: FSMContext,
admin: bool,
session,
):
if isinstance(callback_query_or_message, CallbackQuery):
chat = callback_query_or_message.message.chat
@@ -74,56 +68,54 @@ async def process_callback_view_profile(
image_path = os.path.join("img", "profile.jpg")
logger.info(f"Переход в профиль. Используется изображение: {image_path}")
key_count = await get_key_count(chat_id)
balance = await get_balance(chat_id) or 0
key_count = await get_key_count(session, chat_id)
balance = await get_balance(session, chat_id) or 0
trial_status = await get_trial(session, chat_id)
conn = await asyncpg.connect(DATABASE_URL)
try:
trial_status = await get_trial(chat_id, conn)
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
if key_count == 0:
profile_message += ADD_SUBSCRIPTION_HINT
else:
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
if key_count == 0:
profile_message += (
"\n<blockquote>🔧 <i>Нажмите кнопку ➕ Подписка, чтобы настроить VPN-подключение</i></blockquote>"
)
else:
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
builder = InlineKeyboardBuilder()
if key_count > 0:
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
elif trial_status == 0:
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
else:
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
builder = InlineKeyboardBuilder()
if key_count > 0:
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
elif trial_status == 0:
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
else:
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
row_buttons = []
if REFERRAL_BUTTON:
row_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
if GIFT_BUTTON:
row_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
if row_buttons:
builder.row(*row_buttons)
row_buttons = []
if REFERRAL_BUTTON:
row_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
if GIFT_BUTTON:
row_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
if row_buttons:
builder.row(*row_buttons)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
if admin:
builder.row(
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
if SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
else:
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
await edit_or_send_message(
target_message=target_message,
text=profile_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
force_text=True,
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions")
)
finally:
await conn.close()
if admin:
builder.row(
InlineKeyboardButton(
text="📊 Администратор",
callback_data=AdminPanelCallback(action="admin").pack(),
)
)
if SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
else:
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
await edit_or_send_message(
target_message=target_message,
text=profile_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
force_text=True,
)
+114 -103
View File
@@ -1,11 +1,7 @@
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
@@ -18,24 +14,20 @@ from aiogram.types import (
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
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 config import ADMIN_ID, INLINE_MODE, TOP_REFERRAL_BUTTON, TRIAL_CONFIG, USERNAME_BOT
from database import (
add_referral,
add_user,
check_user_exists,
get_referral_by_referred_id,
get_referral_stats,
)
from database.models import Referral
from handlers.buttons import BACK, INVITE, MAIN_MENU, QR, TOP_FIVE
from handlers.texts import (
INVITE_TEXT_NON_INLINE,
NEW_REFERRAL_NOTIFICATION,
@@ -48,14 +40,14 @@ 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
async def invite_handler(
callback_query_or_message: Message | CallbackQuery, session: AsyncSession
):
if isinstance(callback_query_or_message, CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
target_message = callback_query_or_message.message
@@ -64,7 +56,7 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery):
target_message = callback_query_or_message
referral_link = get_referral_link(chat_id)
referral_stats = await get_referral_stats(chat_id)
referral_stats = await get_referral_stats(session, chat_id)
invite_message = invite_message_send(referral_link, referral_stats)
image_path = os.path.join("img", "pic_invite.jpg")
@@ -91,13 +83,20 @@ async def invite_handler(callback_query_or_message: Message | CallbackQuery):
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
async def inline_referral_handler(inline_query: InlineQuery):
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
trial_time_formatted = format_days(TRIAL_TIME)
referral_link = (
f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
)
trial_days = TRIAL_CONFIG["duration_days"]
trial_time_formatted = format_days(trial_days)
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]
message_text = offer["message"].format(
trial_time=trial_days,
trial_time_formatted=trial_time_formatted
)[:4096]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
@@ -107,7 +106,9 @@ async def inline_referral_handler(inline_query: InlineQuery):
id=str(index),
title=offer["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(),
)
)
@@ -148,114 +149,124 @@ async def show_referral_qr(callback_query: CallbackQuery):
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
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
async def top_referrals_handler(callback_query: CallbackQuery, session: AsyncSession):
user_id = callback_query.from_user.id
result = await session.execute(
select(func.count())
.select_from(Referral)
.where(Referral.referrer_tg_id == user_id)
)
user_referral_count = result.scalar_one() or 0
personal_block = "Твоё место в рейтинге:\n"
if user_referral_count > 0:
subquery = (
select(func.count().label("cnt"))
.select_from(Referral)
.group_by(Referral.referrer_tg_id)
.having(func.count() > user_referral_count)
.subquery()
)
result = await session.execute(select(func.count()).select_from(subquery))
user_position = result.scalar_one() + 1
personal_block += f"{user_position}. {user_id} - {user_referral_count} чел."
else:
personal_block += "Ты еще не приглашал пользователей в проект."
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
"""
result = await session.execute(
select(
Referral.referrer_tg_id,
func.count(Referral.referred_tg_id).label("referral_count"),
)
.group_by(Referral.referrer_tg_id)
.order_by(desc("referral_count"))
.limit(5)
)
top_referrals = result.all()
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"
is_admin = 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)
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"))
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()
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,
)
async def handle_referral_link(referral_code: str, message: Message, state: FSMContext, session: Any):
async def handle_referral_link(
referral_code: str,
message: Message,
state: FSMContext,
session: AsyncSession,
user_data: dict | None = None,
):
try:
referrer_tg_id = int(referral_code)
user_exists_now = await check_user_exists(message.chat.id)
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 == message.chat.id:
await message.answer("❌ Вы не можете быть реферальной ссылкой самого себя.")
if referrer_tg_id == user_id:
await message.answer(
"❌ Вы не можете быть реферальной ссылкой самого себя."
)
return
if user_exists_now:
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
return
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
existing_referral = await get_referral_by_referred_id(session, user_id)
if existing_referral:
await message.answer("❌ Вы уже использовали реферальную ссылку.")
return
await add_referral(message.chat.id, referrer_tg_id, session)
user_exists = await check_user_exists(session, user_id)
if not user_exists:
if isinstance(user, dict):
await add_user(session=session, **user)
else:
await add_user(
session=session,
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),
)
from_user = message.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,
)
await add_referral(session, user_id, referrer_tg_id)
try:
await bot.send_message(
referrer_tg_id,
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id),
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
logger.error(
f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}"
)
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
return
except Exception as e:
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}")
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
return
+160 -114
View File
@@ -1,34 +1,36 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
Message,
)
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import (
CAPTCHA_ENABLE,
CHANNEL_EXISTS,
CHANNEL_ID,
CHANNEL_REQUIRED,
CHANNEL_URL,
DONATIONS_ENABLE,
SHOW_START_MENU_ONCE,
SUPPORT_CHAT_URL,
)
from database import (
add_user,
check_user_exists,
get_trial,
from database import add_user, check_user_exists, get_trial, get_key_count
from database.models import TrackingSource, User
from handlers.buttons import (
ABOUT_VPN,
BACK,
CHANNEL,
MAIN_MENU,
SUB_CHANELL,
SUB_CHANELL_DONE,
SUPPORT,
TRIAL_SUB,
)
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT, TRIAL_SUB
from handlers.captcha import generate_captcha
from handlers.coupons import activate_coupon
from handlers.payments.gift import handle_gift_link
@@ -47,23 +49,28 @@ from .admin.panel.keyboard import AdminPanelCallback
from .refferal import handle_referral_link
from .utils import edit_or_send_message
router = Router()
@router.callback_query(F.data == "start")
async def handle_start_callback_query(
callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool, captcha: bool = False
callback_query: CallbackQuery,
state: FSMContext,
session: Any,
admin: bool,
captcha: bool = False,
):
await start_command(callback_query.message, state, session, admin, captcha)
@router.message(Command("start"))
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True):
async def start_command(
message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True
):
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
if CAPTCHA_ENABLE and captcha:
user_exists = await check_user_exists(message.chat.id)
user_exists = await check_user_exists(session, message.chat.id)
if not user_exists:
captcha_data = await generate_captcha(message, state)
await edit_or_send_message(
@@ -75,50 +82,32 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
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)
@router.callback_query(F.data == "check_subscription")
async def check_subscription_callback(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool):
async def check_subscription_callback(
callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool
):
user_id = callback_query.from_user.id
logger.info(f"[CALLBACK] Получен callback 'check_subscription' от пользователя {user_id}")
logger.info(
f"[CALLBACK] Получен callback 'check_subscription' от пользователя {user_id}"
)
try:
member = await bot.get_chat_member(CHANNEL_ID, user_id)
logger.info(f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}")
logger.info(
f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}"
)
if member.status not in ["member", "administrator", "creator"]:
await callback_query.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
builder.row(InlineKeyboardButton(text=SUB_CHANELL, url=CHANNEL_URL))
builder.row(
InlineKeyboardButton(
text=SUB_CHANELL_DONE, callback_data="check_subscription"
)
)
await callback_query.message.edit_text(
SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
@@ -126,23 +115,50 @@ 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)
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
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)
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
message: Message,
state: FSMContext,
session: Any,
admin: bool,
text_to_process: str = None,
user_data: dict | None = None,
):
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 not text:
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {message.chat.id}")
logger.info(
f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}"
)
await show_start_menu(message, admin, session)
return
@@ -159,7 +175,14 @@ async def process_start_logic(
if "coupons" in part:
logger.info(f"Обнаружена ссылка на купон: {part}")
coupon_code = part.split("coupons")[1].strip("_")
await activate_coupon(message, state, session, coupon_code=coupon_code, admin=admin)
await activate_coupon(
message,
state,
session,
coupon_code,
admin=admin,
user_data=user_data,
)
continue
if "gift" in part:
@@ -167,12 +190,14 @@ async def process_start_logic(
parts = gift_raw.split("_")
if len(parts) < 2:
await message.answer("❌ Неверный формат ссылки на подарок.")
return await process_callback_view_profile(message, state, admin)
return await process_callback_view_profile(message, state, admin, session)
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)
await handle_gift_link(
gift_id, message, state, session, user_data=user_data
)
gift_detected = True
break
@@ -180,92 +205,101 @@ async def process_start_logic(
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)
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_code}")
await handle_utm_link(utm_code, message, state, session)
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(message.chat.id)
user_exists = await check_user_exists(session, user_data["tg_id"])
if not user_exists:
from_user = message.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,
)
await add_user(session=session, **user_data)
trial_status = await get_trial(message.chat.id, session)
trial_status = await get_trial(session, user_data["tg_id"])
key_count = await get_key_count(session, user_data["tg_id"])
if SHOW_START_MENU_ONCE:
if trial_status > 0:
await process_callback_view_profile(message, state, admin)
else:
if key_count > 0:
await process_callback_view_profile(message, state, admin, session)
elif trial_status == 0:
await show_start_menu(message, admin, session)
else:
await process_callback_view_profile(message, state, admin, session)
else:
await show_start_menu(message, admin, session)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при обработке текста {message.text}{e}", exc_info=True)
logger.error(f"Ошибка при обработке текста {text}{e}", exc_info=True)
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
async def handle_utm_link(utm_code, message, state, session):
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", message.chat.id)
if current_code is None:
user_exists = await check_user_exists(message.chat.id)
from_user = message.from_user
if not user_exists:
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,
source_code=utm_code,
)
else:
await session.execute("UPDATE users SET source_code = $1 WHERE tg_id = $2", utm_code, message.chat.id)
logger.info(f"[UTM] Привязана {utm_code} к пользователю {message.chat.id}")
else:
async def handle_utm_link(
utm_code: str,
message: Message,
state: FSMContext,
session: AsyncSession,
user_data: dict,
):
user_id = user_data["tg_id"]
result = await session.execute(
select(TrackingSource).where(TrackingSource.code == utm_code)
)
utm_exists = result.scalar_one_or_none()
if not utm_exists:
await message.answer("❌ UTM ссылка не найдена.")
return
result = await session.execute(select(User).where(User.tg_id == user_id))
user = result.scalar_one_or_none()
if user and user.source_code is None:
user.source_code = utm_code
await session.commit()
logger.info(f"[UTM] Привязана {utm_code} к пользователю {user_id}")
elif not user:
await add_user(session=session, source_code=utm_code, **user_data)
logger.info(
f"[UTM] Зарегистрирован и привязан {utm_code} к пользователю {user_id}"
)
async def show_start_menu(message: Message, admin: bool, session: Any):
"""Функция для отображения стандартного меню через редактирование сообщения.
Если редактирование не удалось, отправляем новое сообщение."""
async def show_start_menu(message: Message, admin: bool, session: AsyncSession):
"""Функция для отображения стандартного меню через редактирование сообщения."""
logger.info(f"Показываю главное меню для пользователя {message.chat.id}")
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)
trial_status = await get_trial(session, message.chat.id)
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
if trial_status == 0:
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
else:
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(
InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key")
)
else:
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
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:
@@ -278,7 +312,10 @@ 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"))
@@ -292,10 +329,18 @@ 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: AsyncSession):
user_id = callback_query.from_user.id
trial = await get_trial(session, user_id)
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"))
builder.row(
InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")
)
support_btn = InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)
if CHANNEL_EXISTS:
channel_btn = InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL)
@@ -303,7 +348,8 @@ 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")
+105 -79
View File
@@ -1,19 +1,23 @@
import json
import os
import re
import secrets
import string
from datetime import datetime
import aiofiles
import aiohttp
import asyncpg
from aiogram.types import (
BufferedInputFile,
InlineKeyboardMarkup,
InputMediaPhoto,
Message,
)
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
from datetime import datetime
from bot import bot
from config import ADMIN_ID, DATABASE_URL
from database import get_all_keys, get_servers
from config import ADMIN_ID
from database import get_servers
from database.models import Key, Server
from logger import logger
@@ -21,14 +25,18 @@ def generate_random_email(length: int = 8) -> str:
"""
Генерирует случайный email с заданной длиной.
"""
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
return (
"".join(
secrets.choice(string.ascii_lowercase + string.digits)
for _ in range(length)
)
if length > 0
else ""
)
async def get_least_loaded_cluster() -> str:
"""
Возвращает кластер с наименьшей загрузкой, где есть хотя бы один сервер с доступным лимитом.
"""
servers = await get_servers()
async def get_least_loaded_cluster(session: AsyncSession) -> str:
servers = await get_servers(session)
server_to_cluster = {}
cluster_loads = {}
@@ -37,36 +45,40 @@ async def get_least_loaded_cluster() -> str:
for server in cluster_servers:
server_to_cluster[server["server_name"]] = cluster_name
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
result = await session.execute(select(Key))
keys = result.scalars().all()
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
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
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, session
):
available_clusters[cluster_name] = cluster_loads[cluster_name]
break
if not available_clusters:
logger.warning("❌ Нет доступных кластеров с лимитом ключей!")
return "cluster1"
least_loaded_cluster = min(available_clusters, key=lambda k: (available_clusters[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:
"""
Универсальная проверка лимита ключей для сервера в режимах кластеров и стран.
"""
async def check_server_key_limit(server_info: dict, session: AsyncSession) -> bool:
from database.models import Key, Notification
server_name = server_info.get("server_name")
cluster_name = server_info.get("cluster_name")
max_keys = server_info.get("max_keys")
@@ -75,19 +87,30 @@ async def check_server_key_limit(server_info: dict, conn) -> bool:
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)
result = await session.execute(
select(func.count()).select_from(Key).where(Key.server_id == identifier)
)
total_keys = result.scalar() or 0
if total_keys >= max_keys:
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {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
result = await session.execute(
select(Notification).where(
Notification.tg_id == 0, Notification.notification_type == notif_key
)
)
already_sent = result.scalar_one_or_none()
if not already_sent:
for admin_id in ADMIN_ID:
try:
@@ -98,26 +121,29 @@ async def check_server_key_limit(server_info: dict, conn) -> bool:
)
except Exception:
pass
await conn.execute(
"INSERT INTO notifications (tg_id, notification_type) VALUES (0, $1) ON CONFLICT DO NOTHING",
notif_key,
)
session.add(Notification(tg_id=0, notification_type=notif_key))
await session.commit()
return True
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
async def handle_error(
tg_id: int, callback_query: object | None = None, message: str = ""
) -> None:
"""
Обрабатывает ошибку, отправляя сообщение пользователю.
"""
try:
if callback_query and hasattr(callback_query, "message"):
try:
await bot.delete_message(chat_id=tg_id, message_id=callback_query.message.message_id)
await bot.delete_message(
chat_id=tg_id, message_id=callback_query.message.message_id
)
except Exception as delete_error:
logger.warning(f"Не удалось удалить сообщение: {delete_error}")
await bot.send_message(tg_id, message)
await bot.send_message(tg_id, message, parse_mode=None)
except Exception as e:
logger.error(f"Ошибка при обработке ошибки: {e}")
@@ -146,12 +172,14 @@ def format_days(days: int) -> str:
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:
@@ -176,7 +204,7 @@ def format_minutes(minutes: int) -> str:
async def edit_or_send_message(
target_message: Message,
text: str,
reply_markup: InlineKeyboardMarkup,
reply_markup: InlineKeyboardMarkup | None = None,
media_path: str = None,
disable_web_page_preview: bool = False,
force_text: bool = False,
@@ -196,7 +224,9 @@ async def edit_or_send_message(
return
except Exception:
await target_message.answer_photo(
photo=BufferedInputFile(image_data, filename=os.path.basename(media_path)),
photo=BufferedInputFile(
image_data, filename=os.path.basename(media_path)
),
caption=text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
@@ -205,7 +235,9 @@ async def edit_or_send_message(
else:
if not force_text and target_message.caption is not None:
try:
await target_message.edit_caption(caption=text, reply_markup=reply_markup)
await target_message.edit_caption(
caption=text, reply_markup=reply_markup
)
return
except Exception as e:
logger.error(f"Ошибка редактирования подписи: {e}")
@@ -236,26 +268,20 @@ def convert_to_bytes(value: float, unit: str) -> int:
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,
async def is_full_remnawave_cluster(cluster_id: str, session: AsyncSession) -> bool:
result = await session.execute(
select(Server.panel_type).where(Server.cluster_name == cluster_id)
)
panel_types = result.scalars().all()
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)
if panel_types:
return all(pt.lower() == "remnawave" for pt in panel_types)
server = await session.fetchrow(
"SELECT panel_type FROM servers WHERE server_name = $1",
cluster_id,
result = await session.execute(
select(Server.panel_type).where(Server.server_name == cluster_id)
)
return server and server["panel_type"].lower() == "remnawave"
panel_type = result.scalar_one_or_none()
return panel_type and panel_type.lower() == "remnawave"
def sanitize_key_name(key_name: str) -> str:
@@ -272,28 +298,28 @@ def sanitize_key_name(key_name: str) -> str:
RUSSIAN_MONTHS = {
'January': 'Января',
'February': 'Февраля',
'March': 'Марта',
'April': 'Апреля',
'May': 'Мая',
'June': 'Июня',
'July': 'Июля',
'August': 'Августа',
'September': 'Сентября',
'October': 'Октября',
'November': 'Ноября',
'December': 'Декабря'
"January": "Января",
"February": "Февраля",
"March": "Марта",
"April": "Апреля",
"May": "Мая",
"June": "Июня",
"July": "Июля",
"August": "Августа",
"September": "Сентября",
"October": "Октября",
"November": "Ноября",
"December": "Декабря",
}
def get_russian_month(date: datetime) -> str:
"""
Преобразует английское название месяца в русское.
Args:
date: Объект datetime, из которого извлекается месяц.
Returns:
Название месяца на русском языке.
"""
+2 -2
View File
@@ -1,11 +1,11 @@
import logging
import os
import sys
from datetime import timedelta
from loguru import logger
from config import LOG_ROTATION_TIME
log_folder = "logs"
@@ -47,7 +47,7 @@ logger.add(
log_file_path,
level="DEBUG",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} | {message}",
rotation=timedelta(minutes=60),
rotation=LOG_ROTATION_TIME,
retention=timedelta(days=3),
)
+1 -1
View File
File diff suppressed because one or more lines are too long
+21 -7
View File
@@ -3,34 +3,48 @@ from collections.abc import Iterable
from aiogram import Dispatcher
from aiogram.dispatcher.middlewares.base import BaseMiddleware
from logger import logger
from middlewares.ban_checker import BanCheckerMiddleware
from middlewares.subscription import SubscriptionMiddleware
from .admin import AdminMiddleware
from .loggings import LoggingMiddleware
from .maintenance import MaintenanceModeMiddleware
from .session import SessionMiddleware
from .throttling import ThrottlingMiddleware
from .user import UserMiddleware
from .maintenance import MaintenanceModeMiddleware
def register_middleware(
dispatcher: Dispatcher,
middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None,
exclude: Iterable[str] | None = None,
pool=None,
sessionmaker=None,
) -> None:
"""Регистрирует middleware в диспетчере.
"""
"""Регистрирует middleware в диспетчере."""
if middlewares is None:
available_middlewares = {
"admin": AdminMiddleware(),
"session": SessionMiddleware(),
"maintenance": MaintenanceModeMiddleware(),
"session": (
SessionMiddleware(sessionmaker) if sessionmaker else SessionMiddleware()
),
"maintenance": MaintenanceModeMiddleware(),
"logging": LoggingMiddleware(),
"throttling": ThrottlingMiddleware(),
"user": UserMiddleware(),
}
if sessionmaker:
dispatcher.update.outer_middleware(SubscriptionMiddleware())
dispatcher.update.outer_middleware(BanCheckerMiddleware(sessionmaker))
exclude_set = set(exclude or [])
middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set]
middlewares = [
middleware
for name, middleware in available_middlewares.items()
if name not in exclude_set
]
handlers = [
dispatcher.message,
+5 -19
View File
@@ -14,7 +14,9 @@ class AdminMiddleware(BaseMiddleware):
является ли пользователь администратором.
"""
_admin_ids: set[int] = set(ADMIN_ID) if isinstance(ADMIN_ID, list | tuple) else {ADMIN_ID}
_admin_ids: set[int] = (
set(ADMIN_ID) if isinstance(ADMIN_ID, list | tuple) else {ADMIN_ID}
)
async def __call__(
self,
@@ -22,28 +24,12 @@ class AdminMiddleware(BaseMiddleware):
event: TelegramObject,
data: dict[str, Any],
) -> Any:
"""Обрабатывает событие и добавляет флаг администратора в data.
Args:
handler: Обработчик события
event: Событие Telegram
data: Словарь с данными события
Returns:
Результат выполнения обработчика
"""
"""Обрабатывает событие и добавляет флаг администратора в data."""
data["admin"] = self._check_admin_access(event)
return await handler(event, data)
def _check_admin_access(self, event: TelegramObject) -> bool:
"""Проверяет, имеет ли пользователь права администратора.
Args:
event: Событие Telegram
Returns:
True, если пользователь администратор, иначе False
"""
"""Проверяет, имеет ли пользователь права администратора."""
try:
if isinstance(event, Message):
return event.from_user and event.from_user.id in self._admin_ids
+82
View File
@@ -0,0 +1,82 @@
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
from pytz import timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import SUPPORT_CHAT_URL
from database.models import ManualBan
from logger import logger
TZ = timezone("Europe/Moscow")
class BanCheckerMiddleware(BaseMiddleware):
def __init__(self, session_factory: Callable[[], AsyncSession]) -> None:
self.session_factory = session_factory
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
tg_id = None
obj = None
if isinstance(event, Update):
if event.message:
tg_id = event.message.from_user.id
obj = event.message
elif event.callback_query:
tg_id = event.callback_query.from_user.id
obj = event.callback_query
elif isinstance(event, Message | CallbackQuery):
tg_id = event.from_user.id
obj = event
if tg_id is None:
return await handler(event, data)
async with self.session_factory() as session:
logger.debug(f"[BanChecker] Проверка блокировки для пользователя {tg_id}")
result = await session.execute(
select(ManualBan).where(
ManualBan.tg_id == tg_id,
(ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()),
)
)
ban = result.scalar_one_or_none()
if ban:
reason = ban.reason or "не указана"
until = ban.until
logger.warning(
f"[BanChecker] Пользователь {tg_id} заблокирован (до: {until}, причина: {reason})"
)
if until:
until_local = until.astimezone(TZ).strftime("%Y-%m-%d %H:%M")
text = (
f"🚫 Вы заблокированы до <b>{until_local}</b> по МСК.\n"
f"📄 Причина: <i>{reason}</i>\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
)
else:
text = (
f"🚫 Вы заблокированы <b>навсегда</b>.\n"
f"📄 Причина: <i>{reason}</i>\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку: {SUPPORT_CHAT_URL}"
)
if isinstance(obj, Message):
await obj.answer(text, parse_mode="HTML")
elif isinstance(obj, CallbackQuery):
await obj.answer(text, show_alert=True)
return
return await handler(event, data)
+5 -12
View File
@@ -26,23 +26,16 @@ class LoggingMiddleware(BaseMiddleware):
if user_info["user_id"]:
logger.info(
f"Активность пользователя - "
f"ID пользователя: {user_info['user_id']}, "
f"Имя пользователя: {user_info['username'] or 'Не указано'}, "
f"Действие: {user_info['action'] or 'Неизвестно'}"
f"Активность пользователя "
f"ID: {str(user_info['user_id']).ljust(10)} "
f"Имя: {user_info['username'] or '':<15} "
f"Действие: {user_info['action'] or ''}"
)
return await handler(event, data)
def _extract_user_info(self, event: TelegramObject) -> UserInfo:
"""Извлекает информацию о пользователе из различных типов событий.
Args:
event: Событие Telegram
Returns:
Словарь с информацией о пользователе
"""
"""Извлекает информацию о пользователе из различных типов событий."""
result: UserInfo = {"user_id": None, "username": None, "action": None}
if hasattr(event, "from_user") and isinstance(event.from_user, User):
+7 -3
View File
@@ -1,9 +1,11 @@
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery
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:
@@ -14,7 +16,9 @@ class MaintenanceModeMiddleware(BaseMiddleware):
user_id = event.from_user.id
if user_id and user_id not in ADMIN_ID:
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
await event.answer(
"⚙️ Бот временно недоступен. Ведутся технические работы."
)
return
return await handler(event, data)
+11 -25
View File
@@ -1,33 +1,19 @@
from collections.abc import Awaitable, Callable
from typing import Any
import asyncpg
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from config import DATABASE_URL
class SessionMiddleware(BaseMiddleware):
pool: asyncpg.Pool | None = None
def __init__(self, sessionmaker=None) -> None:
super().__init__()
if sessionmaker is None:
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=0)
self.sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
else:
self.sessionmaker = sessionmaker
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
if self.pool is None:
self.pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20)
async with self.pool.acquire() as conn:
data["session"] = conn
async def __call__(self, handler, event, data):
async with self.sessionmaker() as session:
data["session"] = session
return await handler(event, data)
@classmethod
async def close(cls) -> None:
"""Закрыть пул соединений при завершении работы приложения."""
if cls.pool is not None:
await cls.pool.close()
cls.pool = None

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