191 Commits

Author SHA1 Message Date
Oleg Kraev a26d334653 Bump bot version to 4.2 2025-05-02 02:39:30 +03:00
Vladless ef1cf1fe26 Merge branch 'dev' 2025-05-01 21:26:14 +03:00
Vladless 1356170fb0 merdge 2025-05-01 21:24:20 +03:00
Vladislav Lisitsyn 998cf9a6e1 Merge pull request #195 from Capybara-z/patch-6
Fix for adding banned users
2025-05-01 21:08:17 +03:00
Capybara-z 85a6be831e Fix for adding banned users
- Вернул логику добавления забанных юзеров на уведолмения связанных  с триалом.
2025-05-01 21:05:02 +03:00
Vladislav Lisitsyn 9df5d8469d Merge pull request #194 from Vladless/dev
v4.2
2025-05-01 20:44:21 +03:00
Vladislav Lisitsyn 3133cc7799 Merge branch 'main' into dev 2025-05-01 20:44:10 +03:00
Vladislav Lisitsyn 7b894300b7 Merge pull request #193 from Capybara-z/patch-5
fix name month
2025-05-01 20:38:06 +03:00
Capybara-z 2621d29ef0 fix name month
- Отображение месяцев на русском.
2025-05-01 19:16:05 +03:00
Capybara-z 76ad4e5821 fix name month
- Отображение месяцев на русском.
2025-05-01 19:09:49 +03:00
Vladless 0056454375 reset traffic 2025-04-30 17:10:52 +03:00
Vladless 3c15589b03 update_cli_launcher for beta/release 2025-04-29 14:02:47 +03:00
Vladless 4849c899c2 fix show start menu once 2025-04-29 14:00:42 +03:00
Vladless 44b9986a45 CLI version 0.1.7 2025-04-27 20:55:19 +03:00
Vladless a9359e9dc0 update version 2025-04-26 23:30:24 +03:00
Vladless 76e730475d get gift button/api/minor fixes 2025-04-26 23:25:48 +03:00
Vladless 18d3b4246a add format day in csv/maintenance mode 2025-04-26 11:14:13 +03:00
Vladless 22121b9171 ruff formatting 2025-04-24 23:20:18 +03:00
Vladless ff498feb7e scalar total_gb 2025-04-24 22:42:49 +03:00
Vladislav Lisitsyn 15fcc0ac70 Update README.md 2025-04-23 22:29:54 +03:00
Vladless b1c73dc1a6 cancel for utm/disable servers for change location/remnawave traffic 2025-04-23 22:27:53 +03:00
Vladless 888812babd fix gift strip in start logic 2025-04-22 19:50:07 +03:00
Vladless 023cd1728d update user middleware 2025-04-22 19:21:15 +03:00
Vladless abbd3474b4 update table for utm 2025-04-22 00:41:34 +03:00
Vladless fa17faa424 update version 2025-04-21 22:04:07 +03:00
Vladless 799c657605 refactoring/utm/fix minor bugs 2025-04-21 21:53:13 +03:00
Vladless fa875fb3bc CLI version 0.1.4 2025-04-21 03:37:27 +03:00
Vladless cc628a08fa CLI version 0.1.4 2025-04-21 03:34:44 +03:00
Vladless fa85fb70f1 hot_leads_sender 2025-04-21 02:18:41 +03:00
Vladless 1d5980d725 rm old logic 2025-04-21 01:46:19 +03:00
Vladless 9474ccfb06 refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args 2025-04-21 00:22:14 +03:00
Vladislav Lisitsyn c6e91c0543 Merge pull request #192 from Capybara-z/patch-4
Fix for extension
2025-04-19 22:18:23 +03:00
Vladislav Lisitsyn 593206f6a0 Merge pull request #191 from Capybara-z/patch-3
v2raytun agent processing
2025-04-19 22:18:09 +03:00
Capybara-z ea6f5297c1 Fix for extension 2025-04-19 21:46:04 +03:00
Capybara-z 471714b80d v2raytun agent processing
- Добавлена обратотка агента v2raytun
2025-04-19 20:13:00 +03:00
Vladislav Lisitsyn a92a19b84d Merge pull request #190 from Capybara-z/patch-2 2025-04-19 17:49:58 +03:00
Capybara-z 289e0ae5b5 fix coupons/formatted day 2025-04-19 17:24:05 +03:00
Vladless d08b410888 update_button on/off 2025-04-18 15:32:07 +03:00
Vladless 455455d696 beta version 2025-04-18 05:01:47 +03:00
Vladless 57ad26711d fix get_keys 2025-04-18 03:51:59 +03:00
Vladless 24428a030e add column in table servers 2025-04-18 01:34:00 +03:00
Vladless c42fee4298 remove table connections/token and cookies 3x-ui/bug fixes 2025-04-18 00:10:51 +03:00
Vladless aba9620c0f limit_key on servers 2025-04-17 16:38:39 +03:00
Vladless dd8c87f887 on/off server in cluster 2025-04-17 15:02:54 +03:00
Vladless eebc2ee1d5 fix get_online in remnawave 2025-04-16 23:20:28 +03:00
Vladless 7bd5ecc55f remnawave in country_mode/save shortid/bug fixes 2025-04-16 22:50:19 +03:00
Vladislav Lisitsyn 5d25e211df Merge pull request #188 from Capybara-z/patch-1
Fix time declination
2025-04-16 20:09:17 +03:00
Capybara-z 0887e28b2b Adding time declination
- Добавил новые склонения
2025-04-15 01:26:17 +03:00
Capybara-z e04be209cb Fix time declination
-Склоение времени.
2025-04-15 01:25:48 +03:00
Capybara-z d19eee8897 Fix time declination
-Склонение времени
2025-04-15 01:24:36 +03:00
Vladless 3efdeed00c add create_key in country_mode/fix traffic in remnawave 2025-04-15 00:12:56 +03:00
Vladless 9e89a2db10 pic.jpg in other menu 2025-04-14 01:08:58 +03:00
Vladislav Lisitsyn 59a23ea9bd Merge pull request #186 from Capybara-z/patch-13
fix trail_time
2025-04-14 01:08:40 +03:00
Capybara-z 216c8f8d81 fix trail_time
- Адаптироавл вывод в инлайн моде, склонение дней.
2025-04-13 22:29:19 +03:00
Vladislav Lisitsyn d0f6511c5c Merge pull request #185 from Capybara-z/patch-12
Fix admin button
2025-04-13 17:51:47 +03:00
Capybara-z e1ecb6a1dc Fix admin button
- Исправлено отображение кнопки админа при активации купона на деньги
2025-04-12 19:48:55 +03:00
Vladless 99c02874ec CTRL-C and install venv in CLI 2025-04-11 22:52:13 +03:00
Vladless 0cd896d35e gb_count/fix coupons 2025-04-11 22:40:38 +03:00
Vladless ab35ce9178 fix renew after sync 2025-04-11 17:34:47 +03:00
Vladless f71e6eb226 add import renew 2025-04-10 15:02:46 +03:00
Vladless cd3b55271a add support for RemnaWave 2025-04-10 14:51:01 +03:00
hteppl e71de5f3cf Fix duplicated button text variables + typo (TARIFFS is unused) 2025-04-10 01:01:13 +03:00
Vladless 0fc2c535de Merge branch 'main' into dev 2025-04-09 23:31:23 +03:00
Vladless aef1eb2861 total_gb fix 2025-04-09 23:13:43 +03:00
Vladislav Lisitsyn b57d79d65c Update version 2025-04-09 20:03:46 +03:00
Vladless d9533e673e refactor: reorganize keys logic into modular files 2025-04-09 17:38:19 +03:00
Vladless 3e452c4c1b exclude img 2025-04-09 16:08:59 +03:00
Vladless 31582080c6 bash -c in cli-interface 2025-04-08 14:43:46 +03:00
Vladless d85dad2a9e version in cli-interface 2025-04-08 14:34:16 +03:00
Vladislav Lisitsyn 7f15e208e1 Merge pull request #184 from Capybara-z/patch-11
Fix for coupon activation
2025-04-07 02:17:43 +03:00
Capybara-z ae291e2ae7 Fix for coupon activation
- Корректная обработка активации купонов.
2025-04-07 02:04:01 +03:00
Capybara-z 7fd15da4a6 Fix for coupon activation
- Корректная передача состояний и текста в купоны.
2025-04-07 02:03:11 +03:00
Vladless 4fdbaca7e8 yoomoney webapp 2025-04-07 00:22:15 +03:00
Vladless 54180c6b28 change callback in add_key 2025-04-06 21:44:09 +03:00
Vladless b7db9be638 cli-interface 2025-04-06 21:19:33 +03:00
Vladless 76d6476ea7 cli-interface 2025-04-06 21:15:59 +03:00
Vladislav Lisitsyn 70867dccec Merge pull request #183 from Capybara-z/patch-10
Add server availability check and cluster-based country selection
2025-04-06 01:59:25 +03:00
Capybara-z dd7144cd5b Add server availability check and cluster-based country selection
- Добавил проверку доступности серверов перед отображением списка стран для выбора.
- Сделал пропуск удаления ключа при недоступности сервера, чтобы смена страны не останавливалась.
- Обеспечил вывод только рабочих серверов при создании ключа.
- Улучшил выбор стран, сделав его корректным согласно кластерам пользователя.
- Исправил пересоздание клиента, обновляя только server_id при смене страны.
2025-04-06 00:30:20 +03:00
Vladless 29f54a408d add data_state 2025-04-05 20:48:13 +03:00
Vladless bff7d3ede0 on/off manual_instructions/fix send_photo 2025-04-05 02:16:24 +03:00
Vladislav Lisitsyn 2a5a254cae Merge pull request #180 from Capybara-z/patch-9
Фикс ошибок и создание ключей
2025-04-05 02:03:28 +03:00
Vladislav Lisitsyn f7db7f445b Merge pull request #179 from Capybara-z/patch-8
Исправление.
2025-04-05 02:02:43 +03:00
Capybara-z ec9016f958 Перенос обратобтки состояний после создания ключа
- Перенос оплат и списания триала после создания ключа
2025-04-05 00:46:24 +03:00
Capybara-z d73a765866 Фикс ошибок сервера
- Исправлен сбор ошибок сервера
2025-04-05 00:44:45 +03:00
Capybara-z eb70e38a02 Исправление.
- Убрал лишнию проверку.
2025-04-04 23:38:14 +03:00
Vladislav Lisitsyn b587cf527e Merge pull request #177 from Capybara-z/patch-6
Добавление активности юзера, мелкие правки
2025-04-03 22:31:54 +03:00
Capybara-z a8ce3f65ee Добавление формулы склонения.
- Добалвение формулы склонения
- Адаптация клонение дней к новой формуле.
2025-04-03 22:17:19 +03:00
Capybara-z 5200e25e88 Корректировка текста
- Скорректировал текст, при смене названия синхронизация не нужна.
2025-04-03 11:32:05 +03:00
Capybara-z e676213102 Функция склонения дней.
- Добавлена функция склонения дней.
2025-04-03 10:26:47 +03:00
Capybara-z 1f56820ce5 Склонение дней
- Склонение дней
2025-04-03 10:25:58 +03:00
Capybara-z 58937a405d Склонение дней
- Склонение дней
2025-04-03 10:25:39 +03:00
Capybara-z b7d13576b8 Добавлено подверждение удаления
- Добавил подверждение удаления
- Поправил удаление купонов.
2025-04-03 09:58:23 +03:00
Capybara-z 0828bca2ed Правка под дни 2025-04-03 09:57:39 +03:00
Capybara-z c1dc10e5ed Купоны по дням
- Купоны теперь можно выдать днями
- Добавил поделиться днями через инлайн мод.
2025-04-03 02:31:54 +03:00
Capybara-z 50eba2d093 Перенесана логика купонов
- Логика купонов перенесена в свое место.
2025-04-03 02:06:28 +03:00
Capybara-z ea6d9d300f Корректировка работы с купонами.
- Скорректировал работу с купонами так как ранее работа была напрямую из start
- Скорретировал работу с купонам под новый столбец days
2025-04-03 02:05:36 +03:00
Capybara-z a06ee6f395 Добавлено создание столбца
- Добавил создание столбца в купонах days
2025-04-03 02:04:07 +03:00
Capybara-z 86e678de08 Добавлена возможность делиться купоном
- Добалена возможность делиться купоном при инлайн режиме.
2025-04-02 23:07:05 +03:00
Capybara-z 772a677424 Отображение пробной подписки
- Добавлено отображение пробной подписки если доступна в профиле.
2025-04-02 21:24:49 +03:00
Capybara-z cccb963da2 Дата регистрации/активности
- Добавлена стата даты регистрации/активности
2025-04-02 21:12:23 +03:00
Vladislav Lisitsyn 2b3ce6ce55 Merge pull request #176 from Capybara-z/patch-5
Смена версии
2025-04-02 03:01:10 +03:00
Capybara-z 1c119b1d7a Смена версии
- Смена бета версии
2025-04-02 02:50:41 +03:00
Vladislav Lisitsyn 4043380411 Merge pull request #175 from Capybara-z/patch-4
Фикс активных юзеров и улучшения управления кластерами/серверами
2025-04-02 02:29:48 +03:00
Capybara-z c3d71bdc28 Доработка сообщений
- Добавлены напоминание синхронизации.
2025-04-02 02:08:09 +03:00
Capybara-z dc6f73c539 Логика переноса ключей
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:43:04 +03:00
Capybara-z 476a8cb842 Добавление логики переноса
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:42:12 +03:00
Capybara-z bc81c64cbe Корректировка сообщений
- Удалил ненужное
2025-04-02 01:41:11 +03:00
Capybara-z 812264722a Добавление QR-кода
- Добавлен QR-код для реф ссылки.
2025-04-02 00:13:13 +03:00
Capybara-z 0d06f61d40 Доп стата
- Стата за прошлый месяц
2025-04-01 23:55:27 +03:00
Capybara-z 2634facd51 Добавление логики смены названий
- Добавлена логика смены названия кластера/сервера
- Сделана проверка на наличие ключей с названием кластера/сервера и смены на новое название
2025-04-01 23:40:35 +03:00
Capybara-z a92b6d1e86 Добавление кнопки
- Добавлена кнопка смены названия кластера
2025-04-01 23:35:19 +03:00
Capybara-z 4cfbcd8590 добавление кнопки
- добавление кнопки смены названия сервера
2025-04-01 23:34:12 +03:00
Capybara-z 73ba081779 фикс активации купона
- Корректировка состояния активации купона
2025-04-01 22:10:40 +03:00
Vladislav Lisitsyn 3aa6c26940 Merge pull request #174 from Capybara-z/patch-3
Фикс имени подписки и экранирование в подписках
2025-04-01 15:46:35 +03:00
Capybara-z dc8ff4a007 Фикс html разметки
- Добавил экранирование
2025-04-01 15:45:37 +03:00
Capybara-z 6390a18327 Фикс имени подписки
- Поставлены ограничения на символа ввода
- Поравлен текст.
2025-04-01 15:25:07 +03:00
Vladless a5d9dad826 fixing bugs 2025-04-01 02:37:52 +03:00
Vladislav Lisitsyn 06e6b30d31 Merge pull request #173 from Capybara-z/patch-2
Добавление отдельного меню Windows/macOS и функции личного рейтинга.
2025-04-01 00:11:35 +03:00
Capybara-z f9e404a7c2 правка текста
-правка текста
2025-04-01 00:01:44 +03:00
Capybara-z 3920992cee Выбор win/macOS
- Сделан выбор win/macOS
- Сделаны текста для них отдельные.
2025-03-31 22:08:27 +03:00
Capybara-z fcb6dd9773 Часть текста перенесана
- Часть текста перенесена CHOOSE_DEVICE_TEXT
2025-03-31 22:04:33 +03:00
Capybara-z 6d42647bf6 Добавление новых кнопок
-  Добавлены кнопки скачать win/macos
- Добавлены кнопки выбора устройств win/macos
2025-03-31 22:01:59 +03:00
Capybara-z 6e3b33e335 Добавление личного рейтинга
-Добавление личного рейтинга вне зависимости от топа.
2025-03-31 00:57:13 +03:00
Vladless 063ade1a2a add user in profile 2025-03-30 00:29:44 +03:00
Vladless 56ffbd654e add new menu in country mode 2025-03-29 22:27:26 +03:00
Vladless 47777c7ee8 fix QRCode, False DISCOUNTS and name profile 2025-03-29 21:57:36 +03:00
Vladless 27d021171e add back button 2025-03-29 12:03:56 +03:00
Vladless d4b00fb494 format text 2025-03-29 02:15:52 +03:00
Vladless 7816fa535c add pillow in requirements 2025-03-29 01:18:59 +03:00
Vladless 0208c48212 add qrcode in requirements 2025-03-28 22:19:25 +03:00
Vladless 4295e4b5ec ruff, button file, castomize ping and backup 2025-03-28 20:39:06 +03:00
Vladless 4fb13a089a reorganizing the menu, reissuing the key, improving statistics, and more 2025-03-28 17:44:14 +03:00
Vladless f1841caea7 rebase api file 2025-03-27 12:52:23 +03:00
Vladless 4a67f9233d customizing the menu, resetting traffic, and creating a key from the admin panel 2025-03-27 04:39:05 +03:00
Vladless e55a89af8e fix admin and add time in cluster 2025-03-27 03:04:21 +03:00
Vladless 1f172f1a21 rename sub and reorganization menu 2025-03-27 02:01:39 +03:00
Vladless 3f7bcb090a notify keyboards in folder 2025-03-26 23:54:36 +03:00
Vladless a3b049dc1f fix after merge 2025-03-26 16:41:43 +03:00
hteppl bf2d225c55 Fix keyboards for users_handler and make data optional for AdminClusterCallback 2025-03-26 02:49:04 +03:00
Vladislav Lisitsyn 33b15b8486 Merge pull request #172 from Vladless/feature/admin-optimize
Admin optimize
2025-03-26 02:37:00 +03:00
Oleg Kraev 19a273baf4 Merge pull request #171 from Vladless/upstream-170
Upstream
2025-03-26 02:06:19 +03:00
hteppl 1c205f4a24 Upstream 2025-03-26 02:05:24 +03:00
hteppl f2a47de5da Fix packages imports 2025-03-26 02:00:28 +03:00
hteppl 372933e230 Implement untrial type for sender 2025-03-26 01:59:52 +03:00
hteppl 11236ac64e Implement separate sync logic 2025-03-26 01:58:59 +03:00
hteppl 46f71a7a6e Update init routing for handlers 2025-03-26 01:11:54 +03:00
hteppl 70293fe387 Fix back button in sender + add broadcast start text 2025-03-26 00:19:24 +03:00
hteppl c7915a45b8 Fix some back buttons 2025-03-26 00:16:14 +03:00
Vladislav Lisitsyn 8b016c7010 Merge pull request #169 from izzzzzi/main
Исправление сравнения tg_id в обработчике подписок.
2025-03-25 20:21:01 +03:00
izzzzzi 17032811a4 Исправление сравнения tg_id в обработчике подписок. Приведение типов к int для корректной проверки идентификаторов. 2025-03-25 22:00:54 +05:00
hteppl ff591fc387 Optimize and fix imports 2025-03-25 17:42:53 +03:00
Oleg Kraev ece35bb90a Merge pull request #168 from Vladless/upstream-168
Changes from upstream
2025-03-25 17:34:54 +03:00
Oleg Kraev dfec3c5748 Merge branch 'feature/admin-optimize' into upstream-168 2025-03-25 17:34:46 +03:00
hteppl 29ecb61ad2 Make clusters 2 in a row 2025-03-25 17:27:25 +03:00
hteppl 79fd3cde5d Do not use clusters_handler for handling management actions 2025-03-25 17:21:05 +03:00
hteppl 5f4a23e808 Trying to split cluster and server manage logic 2025-03-25 17:12:47 +03:00
hteppl 3d10e2c1fe Code optimize by ruff 2025-03-25 16:54:02 +03:00
hteppl 6b8507253e Improve clusters and servers manage texts + fix input len checks 2025-03-25 16:52:38 +03:00
Vladislav Lisitsyn 90d9c0bc54 Merge pull request #167 from Capybara-z/patch-1
Standardization of texts in a separate file.
2025-03-25 16:39:07 +03:00
hteppl 71301f90ae Update AdminPanelCallback __init__ as in super().__init__ 2025-03-25 16:32:31 +03:00
hteppl 472a3ad69d Use ruff formatting 2025-03-25 16:30:44 +03:00
hteppl 5cd63bc89d Move admin keyboards to their own packages 2025-03-25 16:28:08 +03:00
hteppl 77bd87a2b5 Rename classes + implement own packages for all handlers 2025-03-25 16:22:52 +03:00
Capybara-z 53483ce6aa Вынос текста
Вынесенно:
- NO_SUBSCRIPTIONS_MSG
- FROZEN_SUBSCRIPTION_MSG
- UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
- SUBSCRIPTION_UNFROZEN_MSG
- FREEZE_SUBSCRIPTION_CONFIRM_MSG
- SUBSCRIPTION_FROZEN_MSG
- DELETE_KEY_CONFIRM_MSG
- KEY_DELETED_MSG_SIMPLE
- INSUFFICIENT_FUNDS_RENEWAL_MSG
2025-03-25 15:24:39 +03:00
Capybara-z a9e546091a Вынос текста
Вынесенно: 
- CREATING_CONNECTION_MSG
- SELECT_TARIFF_PLAN_MSG
- INSUFFICIENT_FUNDS_MSG
- SELECT_COUNTRY_MSG
2025-03-25 15:24:07 +03:00
Capybara-z 924b5e15c5 Вынос текста
Вынесенно: 
- CREATING_CONNECTION_MSG
- SELECT_TARIFF_PLAN_MSG
- INSUFFICIENT_FUNDS_MSG
- SELECT_COUNTRY_MSG
2025-03-25 15:16:33 +03:00
Capybara-z ae9b5b1bf7 Вынос текста
Вынесенно:
- TRIAL_INACTIVE_FIRST_MSG
- TRIAL_INACTIVE_BONUS_MSG
- ZERO_TRAFFIC_MSG
2025-03-25 15:15:25 +03:00
Capybara-z adb669fa9e Вынос текста
Вынесенно:
- KEY_DELETED_MSG
- KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG
- KEY_EXPIRED_DELAY_HOURS_MSG
- KEY_EXPIRED_DELAY_MINUTES_MSG
- KEY_EXPIRED_NO_DELAY_MSG
- KEY_RENEWED_TEMP_MSG
2025-03-25 15:11:42 +03:00
Capybara-z 2daf2fadc8 Вынос текста
Вынесенно:
- CAPTCHA_PROMPT_MSG
2025-03-25 15:11:10 +03:00
Capybara-z c9b0cd2db1 Вынос текст
Вынесенно:
- COUPON_INPUT_PROMPT
- COUPON_NOT_FOUND_MSG
- COUPON_ALREADY_USED_MSG
- COUPON_ACTIVATED_SUCCESS_MSG
2025-03-25 15:10:46 +03:00
Capybara-z 098459f3ce Вынос текста
Вынесенно:
- PAYMENT_METHODS_MSG
2025-03-25 15:10:23 +03:00
Capybara-z 64d25967c9 Вынос текста
Вынесенно:
- BALANCE_MANAGEMENT_TEXT
- BALANCE_HISTORY_HEADER
- INVITE_TEXT_NON_INLINE
2025-03-25 15:10:04 +03:00
Capybara-z 5804a8e87a Вынос текста
Вынесенно: 
- COUPON_SUCCESS_MSG
- SUBSCRIPTION_REQUIRED_MSG
- NOT_SUBSCRIBED_YET_MSG
- SUBSCRIPTION_CONFIRMED_MSG 
- SUBSCRIPTION_CHECK_ERROR_MSG
- GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG
2025-03-25 15:08:56 +03:00
Vladless 388fbb5afc add_refferal_stats 2025-03-23 19:44:11 +03:00
Vladless d3e502ce43 minor bug fixes and improvements 2025-03-21 09:26:30 +03:00
Vladless af497f644a frozen_subs/restore_all_trial and more 2025-03-12 18:12:29 +03:00
Vladislav Lisitsyn e57680f3a7 Merge pull request #166 from Vladless/revert-165-main 2025-03-12 10:24:09 +03:00
Vladislav Lisitsyn c7eff5cc3e Revert "Добавление нового middleware и рефакторинг" 2025-03-12 10:23:51 +03:00
Vladislav Lisitsyn 3ccbf00259 Merge pull request #165 from izzzzzi/main
Добавление нового middleware и рефакторинг
2025-03-11 23:44:51 +03:00
izzzzzi 6066775cf4 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-03-08 04:59:32 +05:00
izzzzzi 92ec071e6b Добавление нового middleware и рефакторинг обработчиков с поддержкой target_message 2025-03-08 04:59:23 +05:00
Oleg Kraev 8e070349ba Merge pull request #163 from izzzzzi/main
Рефакторинг и оптимизация кода
2025-03-07 16:13:02 +03:00
Oleg Kraev 80360691f2 Merge pull request #164 from Vladless/fix/readme
Move preview gifs into .github folder
2025-03-07 15:46:08 +03:00
hteppl 2f0c22abe8 Move preview gifs into .github folder 2025-03-07 15:44:57 +03:00
izzzzzi 9c7cbe403b Рефакторинг и оптимизация кода: улучшение импортов, форматирования и обработки уведомлений 2025-03-07 06:16:24 +05:00
Vladless fcdfd3a484 fix refferal system 2025-03-07 00:51:02 +03:00
Vladislav Lisitsyn 8f1ceb4e66 Merge pull request #162 from izzzzzi/main
Добавлена обработка исключений в асинхронных задачах
2025-03-04 16:09:25 +03:00
izzzzzi 2db29fba9d Добавлена обработка таймаутов с использованием httpx в клиентских операциях 2025-03-04 01:23:54 +05:00
izzzzzi d0125c1f33 Добавлена обработка исключений в асинхронных задачах с помощью return_exceptions=True 2025-03-04 00:57:41 +05:00
Vladislav Lisitsyn 0dba6a6e42 Merge pull request #161 from izzzzzi/main
Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
2025-03-03 19:27:02 +03:00
izzzzzi 1d6ae5fa07 Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
- Значительное упрощение middleware для ограничения частоты запросов
- Унифицирован механизм троттлинга с единым кэшем
- Добавлена поддержка уведомлений о превышении лимита запросов для CallbackQuery
- Удалены избыточные проверки и сложная логика
- Сокращено количество параметров инициализации
2025-02-26 02:33:26 +05:00
126 changed files with 7802 additions and 115659 deletions
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Сохраняем текущие изменения
echo "Сохранение текущих изменений..."
git stash -q --keep-index
# Запускаем форматирование кода
echo "Запуск Ruff format..."
ruff format . --config pyproject.toml --exclude main.py,handlers/payments
# Запускаем проверку и исправление кода
echo "Запуск Ruff check с автоисправлением..."
ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
# Добавляем изменения, внесенные форматированием
git add -u
# Восстанавливаем сохраненные изменения
echo "Восстановление сохраненных изменений..."
git stash pop -q
# Выход с кодом 0 (успешно)
exit 0

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 867 KiB

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

+3 -3
View File
@@ -1,8 +1,8 @@
# 🚀 SoloBot
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
**SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
<img src="./img/preview.gif" alt="SoloBot" width="1600">
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
@@ -60,7 +60,7 @@
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
<img src="./img/preview_2.gif" alt="SoloBot" width="1600">
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
+118 -19
View File
@@ -1,3 +1,4 @@
CREATE TABLE IF NOT EXISTS users
(
tg_id BIGINT PRIMARY KEY NOT NULL,
@@ -7,15 +8,28 @@ CREATE TABLE IF NOT EXISTS users
language_code TEXT,
is_bot BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS connections
(
tg_id BIGINT PRIMARY KEY NOT NULL,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'balance'
) THEN
ALTER TABLE users ADD COLUMN balance REAL NOT NULL DEFAULT 0.0;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'trial'
) THEN
ALTER TABLE users ADD COLUMN trial INTEGER NOT NULL DEFAULT 0;
END IF;
END$$;
CREATE TABLE IF NOT EXISTS payments
(
@@ -42,6 +56,38 @@ CREATE TABLE IF NOT EXISTS keys
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,
@@ -54,11 +100,22 @@ 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,
@@ -81,34 +138,76 @@ CREATE TABLE IF NOT EXISTS servers
cluster_name TEXT NOT NULL,
server_name TEXT NOT NULL,
api_url TEXT NOT NULL,
subscription_url TEXT NOT NULL,
subscription_url TEXT,
inbound_id TEXT NOT NULL,
panel_type TEXT NOT NULL DEFAULT '3x-ui',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
max_keys INTEGER,
UNIQUE (cluster_name, server_name)
);
ALTER TABLE servers ADD COLUMN IF NOT EXISTS panel_type TEXT NOT NULL DEFAULT '3x-ui';
ALTER TABLE servers
ALTER COLUMN subscription_url DROP NOT NULL;
ALTER TABLE servers
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE servers ADD COLUMN IF NOT EXISTS max_keys INTEGER;
CREATE TABLE IF NOT EXISTS gifts
(
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
selected_months INTEGER NOT NULL,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
recipient_tg_id BIGINT,
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS temporary_data (
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS blocked_users (
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tracking_sources (
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT,
is_active BOOLEAN DEFAULT TRUE
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS source_code TEXT REFERENCES tracking_sources (code);
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'connections'
) THEN
EXECUTE $upd$
UPDATE users
SET balance = c.balance,
trial = c.trial
FROM connections c
WHERE users.tg_id = c.tg_id;
$upd$;
EXECUTE 'DROP TABLE connections';
END IF;
END$$;
+2 -8
View File
@@ -1,10 +1,11 @@
import os
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Tuple, Union
import aiofiles
from aiogram.types import BufferedInputFile
from bot import bot
@@ -48,17 +49,14 @@ def _create_database_backup() -> tuple[str | None, Exception | None]:
"""
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
# Создаем директорию для бэкапов, если она не существует
backup_dir = Path(BACK_DIR)
backup_dir.mkdir(parents=True, exist_ok=True)
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
try:
# Устанавливаем пароль PostgreSQL через переменную окружения
os.environ["PGPASSWORD"] = DB_PASSWORD
# Запускаем pg_dump для создания бэкапа
subprocess.run(
[
"pg_dump",
@@ -87,7 +85,6 @@ def _create_database_backup() -> tuple[str | None, Exception | None]:
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
return None, e
finally:
# Удаляем переменную окружения с паролем
if "PGPASSWORD" in os.environ:
del os.environ["PGPASSWORD"]
@@ -104,10 +101,8 @@ def _cleanup_old_backups() -> Exception | None:
if not backup_dir.exists():
return None
# Вычисляем дату, старше которой нужно удалить файлы
cutoff_date = datetime.now() - timedelta(days=3)
# Находим и удаляем старые файлы бэкапов
for backup_file in backup_dir.glob("*.sql"):
if backup_file.is_file():
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
@@ -152,7 +147,6 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
# Отправляем файл каждому администратору
for admin_id in ADMIN_ID:
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
+2 -1
View File
@@ -14,11 +14,12 @@ 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.0"
version = "4.2"
register_middleware(dp)
+402
View File
@@ -0,0 +1,402 @@
import os
import re
import subprocess
import sys
import requests
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
if not os.environ.get("LC_ALL", "").endswith("UTF-8"):
os.environ["LC_ALL"] = "en_US.UTF-8"
os.environ["LANG"] = "en_US.UTF-8"
console = Console()
BACK_DIR = os.path.expanduser("~/.solobot_backup")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == '/root'
if IS_ROOT_DIR:
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
console.print("[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]")
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
def is_service_exists(service_name):
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
return service_name in result.stdout
def print_logo():
logo = Text(
"""
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
""",
style="bold cyan",
)
console.print(logo)
def backup_project():
console.print("[yellow]📦 Создаётся резервная копия проекта...[/yellow]")
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
subprocess.run(["rm", "-rf", BACK_DIR])
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
def fix_permissions():
"""Устанавливает корректные права на файлы проекта"""
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
try:
user = os.getenv('SUDO_USER') or os.getenv('USER')
if user:
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
console.print("[green]✅ Права успешно установлены[/green]")
except subprocess.CalledProcessError as e:
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
def install_rsync_if_needed():
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
console.print("[blue]📦 Установка rsync...[/blue]")
os.system("sudo apt update && sudo apt install -y rsync")
def clean_project_dir_safe(update_buttons=False):
console.print("[yellow]🧹 Очистка проекта перед обновлением...[/yellow]")
preserved_paths = {
os.path.join(PROJECT_DIR, "config.py"),
os.path.join(PROJECT_DIR, "handlers", "buttons.py"),
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
os.path.join(PROJECT_DIR, "img"),
}
if not update_buttons:
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
for file in files:
path = os.path.join(root, file)
if path in preserved_paths:
continue
try:
os.remove(path)
except PermissionError:
subprocess.run(["sudo", "rm", "-f", path])
except Exception as e:
console.print(f"[red]Не удалось удалить файл: {path}: {e}[/red]")
for dir in dirs:
dir_path = os.path.join(root, dir)
if os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "handlers"):
continue
try:
os.rmdir(dir_path)
except Exception:
subprocess.run(["sudo", "rm", "-rf", dir_path])
def install_git_if_needed():
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
console.print("[blue]Установка Git...[/blue]")
os.system("sudo apt update && sudo apt install -y git")
def install_dependencies():
console.print("[blue]🔧 Установка зависимостей...[/blue]")
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
try:
if not os.path.exists("venv"):
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
subprocess.run("python3 -m venv venv", shell=True, check=True)
subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True
)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
def restart_service():
if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
def get_local_version():
path = os.path.join(PROJECT_DIR, "bot.py")
if not os.path.isfile(path):
return None
with open(path, encoding="utf-8") as f:
for line in f:
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
return match.group(1)
return None
def get_remote_version(branch="main"):
try:
url = f"https://raw.githubusercontent.com/Vladless/Solo_bot/{branch}/bot.py"
response = requests.get(url, timeout=10)
if response.status_code == 200:
for line in response.text.splitlines():
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
return match.group(1)
except Exception:
return None
return None
def update_from_beta():
update_cli_launcher()
local_version = get_local_version()
remote_version = get_remote_version(branch="dev")
if local_version and remote_version:
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
if local_version == remote_version:
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
return
else:
console.print("[red]⚠️ Не удалось определить версии.[/red]")
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
return
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
os.chdir(PROJECT_DIR)
console.print("[cyan]📅 Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons)
exclude_options = "--exclude=img"
if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py"
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
fix_permissions()
install_dependencies()
restart_service()
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
def update_from_release():
update_cli_launcher()
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
return
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
try:
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
releases = response.json()[:3]
tag_choices = [r["tag_name"] for r in releases]
if not tag_choices:
raise ValueError("Не удалось получить список релизов")
console.print("\n[bold green]Доступные релизы:[/bold green]")
for idx, tag in enumerate(tag_choices, 1):
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)]
)
tag_name = tag_choices[int(selected) - 1]
if not Confirm.ask(f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"):
return
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", shell=True, check=True)
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons)
exclude_options = "--exclude=img"
if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py"
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
fix_permissions()
install_dependencies()
restart_service()
console.print(f"[green]✅ Обновление до релиза {tag_name} завершено.[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
return
table = Table(title="Выберите способ обновления", title_style="bold green")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Источник", style="white")
table.add_row("1", "Обновить до BETA")
table.add_row("2", "Обновить до последнего релиза")
table.add_row("3", "Назад в меню")
console.print(table)
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
if choice == "1":
update_from_beta()
elif choice == "2":
update_from_release()
def show_menu():
table = Table(title="Solobot CLI v0.1.7", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
table.add_row("3", "Перезапустить бота (systemd)")
table.add_row("4", "Остановить бота (systemd)")
table.add_row("5", "Показать логи (80 строк)")
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Обновить CLI лаунчер")
table.add_row("9", "Выход")
console.print(table)
def update_cli_launcher():
"""Обновляет CLI лаунчер с dev ветки"""
console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10)
if response.status_code == 200:
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), 'w', encoding='utf-8') as f:
f.write(response.text)
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
else:
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
def main():
os.chdir(PROJECT_DIR)
print_logo()
try:
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 10)])
if choice == "1":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
subprocess.run(["venv/bin/python", "main.py"])
elif choice == "3":
if is_service_exists(SERVICE_NAME):
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "4":
if is_service_exists(SERVICE_NAME):
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "5":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "journalctl", "-u", SERVICE_NAME, "-n", "80", "--no-pager"])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "6":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "status", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "7":
show_update_menu()
elif choice == "8":
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
update_cli_launcher()
elif choice == "9":
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
update_cli_launcher()
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
if __name__ == "__main__":
main()
+288 -568
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,8 +12,10 @@ from .notifications import router as notifications_router
from .pay import router as pay_router
from .payments import router as payments_router
from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
router = Router(name="handlers_main_router")
router.include_routers(
@@ -28,4 +30,5 @@ router.include_routers(
keys_router,
instructions_router,
admin_router,
refferal_router,
)
+16 -9
View File
@@ -2,21 +2,27 @@ __all__ = ("router",)
from aiogram import Router
from .admin_backups import router as backups_router
from .admin_bans import router as bans_router
from .admin_coupons import router as coupons_router
from .admin_panel import router as panel_router
from .admin_restart import router as restart_router
from .admin_sender import router as sender_router
from .admin_servers import router as servers_router
from .admin_stats import router as stats_router
from .admin_users import router as users_router
from .ads import router as ads_router
from .backups import router as backups_router
from .bans import router as bans_router
from .clusters import router as clusters_router
from .coupons import router as coupons_router
from .management import router as management_router
from .panel import router as panel_router
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 .users import router as users_router
router = Router(name="admins_main_router")
router.include_routers(
panel_router,
management_router,
servers_router,
clusters_router,
users_router,
stats_router,
backups_router,
@@ -24,4 +30,5 @@ router.include_routers(
coupons_router,
restart_router,
bans_router,
ads_router,
)
-172
View File
@@ -1,172 +0,0 @@
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, Message
from config import USERNAME_BOT
from database import create_coupon, delete_coupon, get_all_coupons
from filters.admin import IsAdminFilter
from keyboards.admin.coupons_kb import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from logger import logger
router = Router()
class AdminCouponsState(StatesGroup):
waiting_for_coupon_data = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons"),
IsAdminFilter(),
)
async def handle_coupons(
callback_query: CallbackQuery,
):
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_create"),
IsAdminFilter(),
)
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("coupons"),
)
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
async def handle_coupon_data_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = build_admin_back_kb("coupons")
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈"
)
await message.answer(
text=text,
reply_markup=kb,
)
return
try:
coupon_code = parts[0]
coupon_amount = float(parts[1])
usage_limit = int(parts[2])
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
await message.answer(
text=text,
reply_markup=kb,
)
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"💰 Сумма: <b>{coupon_amount} рублей</b> \n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}</code>\n"
)
await message.answer(text=text, reply_markup=kb)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_list"),
IsAdminFilter(),
)
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
try:
data = AdminPanelCallback.unpack(callback_query.data)
page = data.page if data.page is not None else 1
per_page = 10
result = await get_all_coupons(session, page, per_page)
coupons = result["coupons"]
if not coupons:
await callback_query.message.edit_text(
text="❌ На данный момент нет доступных купонов!",
reply_markup=build_admin_back_kb("coupons"),
)
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\n"
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{USERNAME_BOT}?start=coupons_{coupon['code']}</code>\n"
)
await callback_query.message.edit_text(text=coupon_list, reply_markup=kb)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
await callback_query.message.answer("Произошла ошибка при получении списка купонов.")
@router.callback_query(AdminCouponDeleteCallback.filter(), IsAdminFilter())
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
coupon_code = callback_data.coupon_code
try:
result = await delete_coupon(coupon_code, session)
if result:
await callback_query.message.edit_text(f"Купон {coupon_code} удалён!")
else:
await callback_query.message.edit_text(f"❌ Купон с кодом {coupon_code} не найден.", show_alert=True)
await update_coupons_list(callback_query.message, session)
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
await callback_query.message.edit_text("Произошла ошибка при удалении купона.", show_alert=True)
async def update_coupons_list(message, session: Any, page: int = 1):
per_page = 10
result = await get_all_coupons(session, page, per_page)
coupons = result["coupons"]
if not coupons:
await message.edit_text(
text="❌ На данный момент нет доступных купонов!",
reply_markup=build_admin_back_kb("coupons"),
)
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\n"
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{USERNAME_BOT}?start=coupons_{coupon['code']}</code>\n"
)
await message.edit_text(text=coupon_list, reply_markup=kb)
-491
View File
@@ -1,491 +0,0 @@
import asyncio
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 CallbackQuery, Message
from py3xui import AsyncApi
from backup import create_backup_and_send_to_admins
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL
from database import check_unique_server_name, delete_server, get_servers
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import create_key_on_cluster
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.servers_kb import (
AdminServerEditorCallback,
build_clusters_editor_kb,
build_delete_server_kb,
build_manage_cluster_kb,
build_manage_server_kb,
)
from logger import logger
router = Router()
class AdminServersEditor(StatesGroup):
waiting_for_cluster_name = State()
waiting_for_api_url = State()
waiting_for_inbound_id = State()
waiting_for_server_name = State()
waiting_for_subscription_url = State()
waiting_for_new_domain = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "servers"),
IsAdminFilter(),
)
async def handle_servers(callback_query: CallbackQuery):
servers = await get_servers()
text = (
"<b>🔧 Управление кластерами</b>\n\n"
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_clusters_editor_kb(servers),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "clusters_add"),
IsAdminFilter(),
)
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
text = (
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя кластера должно быть уникальным!</b>\n"
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("servers"))
await state.set_state(AdminServersEditor.waiting_for_cluster_name)
@router.message(AdminServersEditor.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: Message, state: FSMContext):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("servers")
)
return
cluster_name = message.text.strip()
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_server_name)
@router.message(AdminServersEditor.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("servers")
)
return
server_name = message.text.strip()
if len(server_name) > 14:
await message.answer(
text="❌ Имя сервера не должно превышать 10 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("servers"),
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
if not await check_unique_server_name(server_name, session, cluster_name):
await message.answer(
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
reply_markup=build_admin_back_kb("servers"),
)
return
await state.update_data(server_name=server_name)
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"API URL должен быть в следующем формате:\n\n"
"<code>https://your_domain:port/panel_path</code>\n\n"
"URL должен быть без слэша на конце!\n"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_api_url)
@router.message(AdminServersEditor.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext, session: Any):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("servers"),
)
return
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Subscription URL должен быть в следующем формате:\n\n"
"<code>https://your_domain:port_sub/sub_path</code>\n\n"
"URL должен быть без слэша и имени клиента на конце!\n"
"Его можно увидеть в панели 3x-ui в информации о клиенте."
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_subscription_url)
@router.message(AdminServersEditor.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("servers"),
)
return
subscription_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
text = (
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_inbound_id)
@router.message(AdminServersEditor.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: Message, state: FSMContext):
inbound_id = message.text.strip()
if not inbound_id.isdigit():
await message.answer(
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
reply_markup=build_admin_back_kb("servers"),
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
VALUES ($1, $2, $3, $4, $5)
""",
cluster_name,
server_name,
api_url,
subscription_url,
inbound_id,
)
await conn.close()
await message.answer(
text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
reply_markup=build_admin_back_kb("servers"),
)
await state.clear()
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_manage"), IsAdminFilter())
async def handle_clusters_manage(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
await callback_query.message.edit_text(
text=f"🔧 Управление серверами для кластера {cluster_name}",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_availability"), IsAdminFilter())
async def handle_servers_availability(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
text = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
await callback_query.message.edit_text(text=text)
result_text = f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
total_online_users = 0
for server in cluster_servers:
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
try:
await xui.login()
online_users = len(await xui.client.online())
total_online_users += online_users
result_text += f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
except Exception as e:
result_text += f"{server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
result_text += f"\n👥 Общее количество активных пользователей в кластере: {total_online_users}."
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("servers"))
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_manage"), IsAdminFilter())
async def handle_servers_manage(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback):
server_name = callback_data.data
servers = await get_servers()
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
)
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {api_url}\n"
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
f"<b>🔑 Inbound ID:</b> {inbound_id}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete"), IsAdminFilter())
async def handle_servers_delete(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback):
server_name = callback_data.data
await callback_query.message.edit_text(
text=f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
reply_markup=build_delete_server_kb(server_name),
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"), IsAdminFilter())
async def handle_servers_delete_confirm(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
):
server_name = callback_data.data
await delete_server(server_name, session)
await callback_query.message.edit_text(
text=f"🗑️ Сервер {server_name} успешно удален.", reply_markup=build_admin_back_kb("servers")
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_add"), IsAdminFilter())
async def handle_servers_add(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, state: FSMContext
):
cluster_name = callback_data.data
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_server_name)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_backup"), IsAdminFilter())
async def handle_clusters_backup(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
for server in cluster_servers:
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
await create_backup_and_send_to_admins(xui)
text = (
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_sync"), IsAdminFilter())
async def handle_clusters_sync(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
):
cluster_name = callback_data.data
try:
query_keys = """
SELECT tg_id, client_id, email, expiry_time
FROM keys
WHERE server_id = $1
"""
keys_to_sync = await session.fetch(query_keys, cluster_name)
if not keys_to_sync:
await callback_query.message.answer(
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
reply_markup=build_admin_back_kb("servers"),
)
return
for key in keys_to_sync:
try:
await create_key_on_cluster(
cluster_name,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}")
await callback_query.message.answer(
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
reply_markup=build_admin_back_kb("servers"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
await callback_query.message.answer(
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("servers")
)
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
"""Запрашивает у администратора новый домен."""
await state.set_state(AdminServersEditor.waiting_for_new_domain)
await callback_query.message.edit_text(
text="🌐 Введите новый домен (без https://):\nПример: pocomachodomen.ru",
)
@router.message(AdminServersEditor.waiting_for_new_domain)
async def process_new_domain(message: Message, state: FSMContext, session: asyncpg.Connection):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
reply_markup=build_admin_back_kb("admin"),
)
return
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)
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
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}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
await state.clear()
-120
View File
@@ -1,120 +0,0 @@
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.stats_kb import build_stats_kb
from logger import logger
from utils.csv_export import export_payments_csv, export_users_csv
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats"),
IsAdminFilter(),
)
async def handle_stats(callback_query: CallbackQuery, session: Any):
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_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
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
moscow_tz = pytz.timezone("Europe/Moscow")
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
stats_message = (
f"📊 <b>Подробная статистика проекта:</b>\n\n"
f"👥 Пользователи:\n"
f" 📅 За день: <b>{registrations_today}</b>\n"
f" 📆 За неделю: <b>{registrations_week}</b>\n"
f" 📆 За месяц: <b>{registrations_month}</b>\n"
f" 🌐 За все время: <b>{total_users}</b>\n\n"
f"🌟 Активные пользователи:\n"
f" 🌟 Активных сегодня: <b>{users_updated_today}</b>\n\n"
f"👥 Рефералы:\n"
f" 🤝 Всего привлечено: <b>{total_referrals}</b>\n\n"
f"🔑 Ключи:\n"
f" 🌈 Всего сгенерировано: <b>{total_keys}</b>\n"
f" ✅ Действующих: <b>{active_keys}</b>\n"
f" ❌ Просроченных: <b>{expired_keys}</b>\n\n"
f"💰 Финансовая статистика:\n"
f" 📅 За день: <b>{total_payments_today} ₽</b>\n"
f" 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
f" 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
f" 🏦 За все время: <b>{total_payments_all_time} ₽</b>\n\n"
f" ⏳ Последнее обновление: {update_time}"
)
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): # skip when Telegram message is not modified
logger.error(f"Error in user_stats_menu: {e}")
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
IsAdminFilter(),
)
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_users_csv(session)
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)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
IsAdminFilter(),
)
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_payments_csv(session)
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)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .ads_handler import router
+155
View File
@@ -0,0 +1,155 @@
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from config import USERNAME_BOT
from database import (
create_tracking_source,
get_all_tracking_sources,
get_tracking_source_stats,
)
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminAdsCallback,
build_ads_delete_confirm_kb,
build_ads_kb,
build_ads_list_kb,
build_ads_stats_kb,
build_cancel_input_kb,
)
router = Router()
class AdminAdsState(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_code = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
async def handle_ads_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminAdsState.waiting_for_new_name)
await callback_query.message.edit_text(
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
)
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
async def handle_ads_name_input(message: Message, state: FSMContext):
name = message.text.strip()
await state.update_data(name=name)
await state.set_state(AdminAdsState.waiting_for_new_code)
await message.answer(
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.", reply_markup=build_cancel_input_kb()
)
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
async def handle_ads_code_input(message: Message, state: FSMContext, session):
code = message.text.strip()
data = await state.get_data()
name = data["name"]
code_with_prefix = f"utm_{code}"
try:
await create_tracking_source(
name=name, code=code_with_prefix, type_="utm", created_by=message.from_user.id, session=session
)
stats = await get_tracking_source_stats(code_with_prefix, session)
if not stats:
await message.answer("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await message.answer(
text=msg,
reply_markup=build_ads_stats_kb(code_with_prefix),
)
except Exception as e:
logger.error(f"Ошибка при создании ссылки: {e}")
await message.answer("❌ Произошла ошибка при создании ссылки.")
finally:
await state.clear()
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
async def handle_ads_list(callback_query: CallbackQuery, session):
try:
ads = await get_all_tracking_sources(session)
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
await callback_query.message.edit_text(
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Ошибка при получении списка UTM: {e}")
await callback_query.message.edit_text("Произошла ошибка при получении списка.")
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
async def handle_ads_view(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
code = callback_data.code
try:
stats = await get_tracking_source_stats(code, session)
if not stats:
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await callback_query.message.edit_text(
text=msg, reply_markup=build_ads_stats_kb(code), parse_mode=ParseMode.HTML
)
except Exception as e:
logger.error(f"Ошибка при просмотре статистики: {e}")
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
code = callback_data.code
await callback_query.message.edit_text(
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
reply_markup=build_ads_delete_confirm_kb(code),
)
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
async def handle_ads_delete(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
code = callback_data.code
try:
await session.execute("UPDATE users SET source_code = NULL WHERE source_code = $1", code)
await session.execute("DELETE FROM tracking_sources WHERE code = $1", code)
await callback_query.message.edit_text(f"🗑️ Ссылка <code>{code}</code> удалена.", reply_markup=build_ads_kb())
except Exception as e:
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
def format_ads_stats(stats: dict, username_bot: str) -> str:
return (
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
f"📌 <b>Название:</b> {stats['name']}\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
f"💡 <b>Активность:</b>\n"
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n"
f"\n💰 <b>Финансовая информация:</b>\n"
f"└ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n\n"
f"<i>Просмотр статистики и управление рекламными ссылками</i>."
)
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
+77
View File
@@ -0,0 +1,77 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import build_admin_back_btn
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
action: str
code: str | None = None
def build_ads_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=" Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for ad in ads:
builder.button(
text=f"📎 {ad['name']}",
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
)
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(build_admin_back_btn("ads"))
return builder.as_markup()
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🗑️ Удалить", callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack())
builder.row(build_admin_back_btn("ads"))
return builder.as_markup()
def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="✅ Да, удалить",
callback_data=AdminAdsCallback(
action="delete",
code=code,
).pack(),
)
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="view", code=code).pack())
builder.adjust(1)
return builder.as_markup()
def build_cancel_input_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="cancel_input", code="none").pack())
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .backups_handler import router
@@ -3,7 +3,9 @@ from aiogram.types import CallbackQuery
from backup import backup_database
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .bans_handler import router
@@ -5,8 +5,10 @@ from aiogram.types import BufferedInputFile, CallbackQuery
from database import delete_user_data
from filters.admin import IsAdminFilter
from keyboards.admin.bans_kb import build_bans_kb
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_bans_kb
router = Router()
@@ -1,7 +1,7 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_bans_kb() -> InlineKeyboardMarkup:
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .clusters_handler import router
+857
View File
@@ -0,0 +1,857 @@
import asyncio
import time
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 CallbackQuery, Message
from py3xui import AsyncApi
from backup import create_backup_and_send_to_admins
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
DATABASE_URL,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
TOTAL_GB,
USE_COUNTRY_SELECTION,
)
from database import check_unique_server_name, get_servers, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
renew_key_in_cluster,
)
from logger import logger
from panels.remnawave import RemnawaveAPI
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import (
AdminClusterCallback,
AdminServerCallback,
build_cluster_management_kb,
build_clusters_editor_kb,
build_manage_cluster_kb,
build_panel_type_kb,
build_sync_cluster_kb,
)
router = Router()
class AdminClusterStates(StatesGroup):
waiting_for_cluster_name = State()
waiting_for_api_url = State()
waiting_for_inbound_id = State()
waiting_for_server_name = State()
waiting_for_subscription_url = State()
waiting_for_days_input = State()
waiting_for_new_cluster_name = State()
waiting_for_new_server_name = State()
waiting_for_server_transfer = State()
waiting_for_cluster_transfer = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "clusters"),
IsAdminFilter(),
)
async def handle_servers(callback_query: CallbackQuery):
servers = await get_servers()
text = (
"<b>🔧 Управление кластерами</b>\n\n"
"<blockquote>"
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
"</blockquote>\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_clusters_editor_kb(servers),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
text = (
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя должно быть уникальным!</b>\n"
"<b>Имя не должно превышать 12 символов!</b>\n\n"
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: Message, state: FSMContext):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
)
return
if len(message.text) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
cluster_name = message.text.strip()
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
)
return
server_name = message.text.strip()
if len(server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
if not await check_unique_server_name(server_name, session, cluster_name):
await message.answer(
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(server_name=server_name)
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
"️ <b>Формат для 3X-UI:</b>\n"
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
"️ <b>Формат для Remnawave:</b>\n"
"<code>https://your-domain.com/api</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_api_url)
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext):
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Если вы используете Remnawave — введите <code>0</code>\n\n"
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
)
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
raw = message.text.strip()
subscription_url = None if raw == "0" else raw.rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
await message.answer(
text=f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
f"Для Remnawave это UUID Инбаунда, для 3x-ui — просто ID (например, <code>1</code>).",
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: Message, state: FSMContext):
inbound_id = message.text.strip()
await state.update_data(inbound_id=inbound_id)
await message.answer(
text=(
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
"Поддержка режима выбора стран — <b>ограничена</b>."
),
reply_markup=build_panel_type_kb(),
)
@router.callback_query(AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])), IsAdminFilter())
async def handle_panel_type_selection(
callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext
):
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
inbound_id = user_data.get("inbound_id")
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id, panel_type)
VALUES ($1, $2, $3, $4, $5, $6)
""",
cluster_name,
server_name,
api_url,
subscription_url,
inbound_id,
panel_type,
)
await conn.close()
await callback_query.message.edit_text(
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
reply_markup=build_admin_back_kb("clusters"),
)
await state.clear()
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_clusters_manage(
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
):
cluster_name = callback_data.data
await callback_query.message.edit_text(
text=f"<b>🔧 Управление кластером {cluster_name}</b>",
reply_markup=build_cluster_management_kb(cluster_name),
)
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
async def handle_cluster_servers(callback: CallbackQuery):
cluster_name = callback.data.split("|", 1)[1]
servers = await get_servers()
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
text=f"<b>📡 Серверы в кластере {cluster_name}</b>",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
async def handle_cluster_availability(
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
await callback_query.message.edit_text(
text=(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
)
total_online_users = 0
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
for server in cluster_servers:
server_name = server["server_name"]
panel_type = server.get("panel_type", "3x-ui").lower()
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
try:
if panel_type == "3x-ui":
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
await xui.login()
inbound_id = int(server["inbound_id"])
online_clients = await xui.client.online()
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
elif panel_type == "remnawave":
remna = RemnawaveAPI(server["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise Exception("Не удалось авторизоваться")
server_inbound_id = server.get("inbound_id")
if not server_inbound_id:
raise Exception("Не указан inbound_id сервера")
all_nodes = await remna.get_all_nodes()
if not all_nodes:
raise Exception("Не удалось получить список нод")
matching_node = None
for node in all_nodes:
excluded_inbounds = node.get("excludedInbounds", [])
if server_inbound_id not in excluded_inbounds:
matching_node = node
break
if not matching_node:
raise Exception("Нода, обслуживающая этот inbound_id, не найдена")
online_remna_users = matching_node.get("usersOnline", 0)
total_online_users += online_remna_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
except Exception as e:
error_text = str(e) or "Сервер недоступен"
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
async def handle_clusters_backup(
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
for server in cluster_servers:
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await create_backup_and_send_to_admins(xui)
text = (
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
async def handle_sync(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>",
reply_markup=build_sync_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
async def handle_sync_server(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
server_name = callback_data.data
try:
query_keys = """
SELECT s.*, k.tg_id, k.client_id, k.email, k.expiry_time
FROM servers s
JOIN keys k ON s.cluster_name = k.server_id
WHERE s.server_name = $1;
"""
keys_to_sync = await session.fetch(query_keys, server_name)
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
text = f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
await callback_query.message.edit_text(
text=text,
)
semaphore = asyncio.Semaphore(2)
for key in keys_to_sync:
try:
await create_client_on_server(
{
"api_url": key["api_url"],
"inbound_id": key["inbound_id"],
"server_name": key["server_name"],
},
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
semaphore,
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters")
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
cluster_name = callback_data.data
try:
query_keys = """
SELECT tg_id, client_id, email, expiry_time, remnawave_link
FROM keys
WHERE server_id = $1
"""
keys_to_sync = await session.fetch(query_keys, cluster_name)
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
)
for key in keys_to_sync:
try:
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"])
await session.execute(
"DELETE FROM keys WHERE tg_id = $1 AND client_id = $2", key["tg_id"], key["client_id"]
)
await create_key_on_cluster(
cluster_name,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
session=session,
remnawave_link=key.get("remnawave_link"),
)
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
cluster_name = callback_data.data
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
cluster_name = callback_data.data
await state.set_state(AdminClusterStates.waiting_for_days_input)
await state.update_data(cluster_name=cluster_name)
await callback_query.message.edit_text(
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
reply_markup=build_admin_back_kb("clusters"),
)
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
async def handle_days_input(message: Message, state: FSMContext, session: Any):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
now = int(time.time() * 1000)
add_ms = days * 86400 * 1000
total_gb = int((days / 30) * TOTAL_GB * 1024**3)
keys = await session.fetch(
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
cluster_name,
)
if not keys:
await message.answer("❌ Нет подписок в этом кластере.")
await state.clear()
return
for key in keys:
new_expiry = (key["expiry_time"] or now) + add_ms
await renew_key_in_cluster(
cluster_name,
email=key["email"],
client_id=key["client_id"],
new_expiry_time=new_expiry,
total_gb=total_gb,
)
await update_key_expiry(key["client_id"], new_expiry, session)
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
)
except ValueError:
await message.answer("❌ Введите корректное число дней.")
return
except Exception as e:
logger.error(f"Ошибка при добавлении дней: {e}")
await message.answer("❌ Произошла ошибка при продлении времени.")
finally:
await state.clear()
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_cluster(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
cluster_name = callback_data.data
await state.update_data(old_cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_cluster</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_cluster_name = message.text.strip()
if len(new_cluster_name) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_cluster_name = user_data.get("old_cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
existing_cluster = await conn.fetchval(
"SELECT cluster_name FROM servers WHERE cluster_name = $1 LIMIT 1", new_cluster_name
)
if existing_cluster:
await message.answer(
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_cluster_name)
async with conn.transaction():
await conn.execute(
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2", new_cluster_name, old_cluster_name
)
if keys_count > 0:
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
)
await message.answer(
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
old_server_name = callback_data.data
servers = await get_servers()
cluster_name = None
for c_name, server_list in servers.items():
for server in server_list:
if server["server_name"] == old_server_name:
cluster_name = c_name
break
if cluster_name:
break
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным в пределах кластера.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_server</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
async def handle_new_server_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_server_name = message.text.strip()
if len(new_server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_server_name = user_data.get("old_server_name")
cluster_name = user_data.get("cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
existing_server = await conn.fetchval(
"SELECT server_name FROM servers WHERE cluster_name = $1 AND server_name = $2 LIMIT 1",
cluster_name,
new_server_name,
)
if existing_server:
await message.answer(
text=f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. Введите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_server_name)
async with conn.transaction():
await conn.execute(
"UPDATE servers SET server_name = $1 WHERE cluster_name = $2 AND server_name = $3",
new_server_name,
cluster_name,
old_server_name,
)
if keys_count > 0:
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name
)
final_text = f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!"
await message.answer(
text=final_text,
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
@router.callback_query(F.data.startswith("transfer_to_server|"))
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext):
data = callback_query.data.split("|")
new_server_name = data[1]
old_server_name = data[2]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
async with conn.transaction():
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name)
await conn.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
)
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
await callback_query.message.edit_text(
text=final_text,
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
@router.callback_query(F.data.startswith("transfer_to_cluster|"))
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext):
data = callback_query.data.split("|")
new_cluster_name = data[1]
old_cluster_name = data[2]
old_server_name = data[3]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
async with conn.transaction():
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_server_name)
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
)
await conn.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
)
await callback_query.message.edit_text(
text=f"✅ Ключи успешно перенесены в кластер '{new_cluster_name}', сервер '{old_server_name}' и кластер '{old_cluster_name}' удалены!\n\n⚠️ Не забудьте сделать \"Синхронизацию\".",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
+132
View File
@@ -0,0 +1,132 @@
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
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
action: str
data: str | None = None
def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
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(),
)
for name in cluster_names[i : i + 2]
])
builder.row(
InlineKeyboardButton(text=" Добавить кластер", callback_data=AdminClusterCallback(action="add").pack())
)
builder.row(build_admin_back_btn())
return builder.as_markup()
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(),
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить сервер",
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
)
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📡 Серверы",
callback_data=f"cluster_servers|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность",
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔄 Синхронизация",
callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап",
callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Добавить время",
callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✏️ Сменить название",
callback_data=AdminClusterCallback(action="rename", data=cluster_name).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:
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(),
)
)
builder.row(
InlineKeyboardButton(
text="📍 Синхронизировать кластер",
callback_data=AdminClusterCallback(action="sync-cluster", data=cluster_name).pack(),
)
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_panel_type_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
builder.button(text="🌀 Remnawave", callback_data=AdminClusterCallback(action="panel_remnawave").pack())
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .coupons_handler import router
+317
View File
@@ -0,0 +1,317 @@
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
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import (
CallbackQuery,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
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
router = Router()
class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons"),
IsAdminFilter(),
)
async def handle_coupons(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_create"),
IsAdminFilter(),
)
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
text = "🎫 <b>Выберите тип купона:</b>"
kb = InlineKeyboardBuilder()
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
kb.button(text="⏳ Время", callback_data="coupon_type_days")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
@router.callback_query(F.data == "coupon_type_balance")
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_balance_data)
@router.callback_query(F.data == "coupon_type_days")
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_days_data)
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
coupon_amount = int(parts[1])
usage_limit = int(parts[2])
if coupon_amount <= 0:
raise ValueError("Сумма должна быть больше 0")
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
kb = InlineKeyboardBuilder()
if INLINE_MODE:
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
async def handle_days_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
"Пример: <b>'DAYS10 10 50'</b> 👈"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
days = int(parts[1])
usage_limit = int(parts[2])
if days <= 0:
raise ValueError("Количество дней должно быть больше 0")
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(coupon_code, 0, usage_limit, session, days=days)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"⏳ <b>{format_days(days)}</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
kb = InlineKeyboardBuilder()
if INLINE_MODE:
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_list"),
IsAdminFilter(),
)
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
try:
data = AdminPanelCallback.unpack(callback_query.data)
page = data.page if data.page is not None else 1
await update_coupons_list(callback_query.message, session, page)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
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):
coupon_code = callback_data.coupon_code
kb = InlineKeyboardBuilder()
kb.button(
text="✅ Да, удалить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
)
kb.button(
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()
)
@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)
if not result:
await callback_query.message.edit_text(
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")
)
return
await update_coupons_list(callback_query.message, session)
async def update_coupons_list(message, session: Any, page: int = 1):
per_page = 10
result = await get_all_coupons(session, page, per_page)
coupons = result["coupons"]
if not coupons:
await message.edit_text(
text="❌ На данный момент нет доступных купонов!",
reply_markup=build_admin_back_kb("coupons"),
)
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
text = format_coupons_list(coupons, USERNAME_BOT)
await message.edit_text(text=text, reply_markup=kb)
@router.inline_query(F.query.startswith("coupon_"))
async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
if not INLINE_MODE:
return
coupon_code = inline_query.query.split("coupon_")[1]
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
if not coupon:
await inline_query.answer(
results=[],
switch_pm_text="Купон не найден",
switch_pm_parameter="coupons",
cache_time=1,
)
return
title = f"Купон {coupon['code']}"
description = (
f"Получи {coupon['amount']} рублей!"
if coupon["amount"] > 0
else f"Продли подписку на {format_days(coupon['days'])}!"
)
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n"
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
f"👇 Нажми, чтобы активировать!"
)
builder = InlineKeyboardBuilder()
builder.button(text="Активировать купон", url=coupon_link)
result = InlineQueryResultArticle(
id=coupon_code,
title=title,
description=description,
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
@@ -1,12 +1,18 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
from handlers.buttons import BACK
from handlers.utils import format_days
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
coupon_code: str
confirm: bool | None = None
def build_coupons_kb() -> InlineKeyboardMarkup:
@@ -31,7 +37,7 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
text=BACK,
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
)
)
@@ -48,3 +54,21 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
builder.row(build_admin_back_btn("coupons"))
builder.adjust(2)
return builder.as_markup()
def format_coupons_list(coupons: list, username_bot: str) -> str:
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
value_text = (
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
if coupon["amount"] > 0
else f"⏳ <b>{format_days(coupon['days'])}</b>"
)
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"{value_text}\n"
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
)
return coupon_list
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .management_handler import router
+21
View File
@@ -0,0 +1,21 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from middlewares import maintenance
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.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
@@ -0,0 +1,87 @@
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 filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from .keyboard import AdminPanelCallback, build_management_kb
from middlewares import maintenance
router = Router()
class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
async def handle_management(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(),
)
@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)
await callback_query.message.edit_text(
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
)
@router.message(AdminManagementStates.waiting_for_new_domain)
async def process_new_domain(message: Message, state: FSMContext, session: Connection):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
reply_markup=build_admin_back_kb("admin"),
)
return
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)
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
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}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
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())
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .panel_handler import router
@@ -1,16 +1,20 @@
from typing import Any
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK, MAIN_MENU
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
action: str
page: int
def __init__(self, *args, **kwargs):
if "page" not in kwargs or kwargs["page"] is None:
kwargs["page"] = 1
super().__init__(*args, **kwargs)
def __init__(self, /, **data: Any) -> None:
if "page" not in data or data["page"] is None:
data["page"] = 1
super().__init__(**data)
def build_panel_kb() -> InlineKeyboardMarkup:
@@ -18,27 +22,19 @@ def build_panel_kb() -> InlineKeyboardMarkup:
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="servers").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.button(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack())
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()),
InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
)
builder.button(text="Личный кабинет", callback_data="profile")
builder.adjust(1, 1, 2, 1, 2, 1)
return builder.as_markup()
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.row(build_admin_back_btn())
builder.adjust(1)
builder.row(
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
)
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1, 1, 2, 2, 2, 1)
return builder.as_markup()
@@ -51,7 +47,7 @@ def build_restart_kb() -> InlineKeyboardMarkup:
def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
return build_admin_singleton_kb("🔙 Назад", action)
return build_admin_singleton_kb(BACK, action)
def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
@@ -61,7 +57,7 @@ def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
return build_admin_btn("🔙 Назад", action)
return build_admin_btn(BACK, action)
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
@@ -1,11 +1,15 @@
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot import version
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_management_kb, build_panel_kb
from logger import logger
from .keyboard import AdminPanelCallback, build_panel_kb
router = Router()
@@ -17,12 +21,18 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
await state.clear()
if callback_query.message.text:
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
try:
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
else:
raise
else:
try:
await callback_query.message.delete()
except Exception as e:
print(f"Ошибка при удалении сообщения: {e}")
logger.error(f"Ошибка при удалении сообщения: {e}")
await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
@@ -38,11 +48,3 @@ async def handle_admin_message(message: Message, state: FSMContext):
await state.clear()
await message.answer(text=text, reply_markup=build_panel_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
async def handle_management(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(),
)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .restart_handler import router
@@ -4,7 +4,9 @@ from aiogram import F, Router
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb, build_restart_kb
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
router = Router()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .sender_handler import router
+45
View File
@@ -0,0 +1,45 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import build_admin_back_btn
class AdminSenderCallback(CallbackData, prefix="admin_sender"):
type: str
data: str | None = None
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()),
)
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())
)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for cluster in clusters:
name = cluster["cluster_name"]
builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
builder.adjust(2)
builder.row(build_admin_back_btn())
return builder.as_markup()
@@ -7,10 +7,12 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.sender_kb import AdminSenderCallback, build_clusters_kb, build_sender_kb
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()
@@ -75,45 +77,64 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
state_data = await state.get_data()
send_to = state_data.get("type", "all")
now = int(datetime.utcnow().timestamp() * 1000)
if send_to == "subscribed":
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
SELECT DISTINCT u.tg_id
FROM users u
JOIN keys k ON u.tg_id = k.tg_id
WHERE k.expiry_time > $1
""",
int(datetime.utcnow().timestamp() * 1000),
now,
)
elif send_to == "unsubscribed":
tg_ids = await session.fetch(
"""
SELECT c.tg_id
FROM connections c
LEFT JOIN keys k ON c.tg_id = k.tg_id
GROUP BY c.tg_id
SELECT u.tg_id
FROM users u
LEFT JOIN keys k ON u.tg_id = k.tg_id
GROUP BY u.tg_id
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
""",
int(datetime.utcnow().timestamp() * 1000),
now,
)
elif send_to == "untrial":
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users WHERE tg_id NOT IN (SELECT tg_id FROM keys)")
elif send_to == "cluster":
cluster_name = state_data.get("cluster_name")
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
SELECT DISTINCT u.tg_id
FROM users u
JOIN keys k ON u.tg_id = k.tg_id
JOIN servers s ON k.server_id = s.cluster_name
WHERE s.cluster_name = $1
""",
cluster_name,
)
elif send_to == "hotleads":
tg_ids = await session.fetch(
"""
SELECT DISTINCT u.tg_id
FROM users u
JOIN payments p ON u.tg_id = p.tg_id
LEFT JOIN keys k ON u.tg_id = k.tg_id
WHERE p.status = 'success'
AND k.tg_id IS NULL
"""
)
else:
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users")
total_users = len(tg_ids)
success_count = 0
text = f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
await message.answer(text=text)
for record in tg_ids:
tg_id = record["tg_id"]
try:
@@ -130,11 +151,10 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
text = (
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Всего пользователей:</b> {total_users}\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f"✅ <b>Доставлено:</b> {success_count}\n"
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
)
await message.answer(text=text, reply_markup=build_admin_back_kb("stats"), parse_mode="HTML")
await message.answer(text=text, reply_markup=build_admin_back_kb("sender"))
await state.clear()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .servers_handler import router
+36
View File
@@ -0,0 +1,36 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
class AdminServerCallback(CallbackData, prefix="admin_server"):
action: str
data: str
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
from ..clusters.keyboard import AdminClusterCallback
builder = InlineKeyboardBuilder()
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
toggle_action = "disable" if enabled else "enable"
builder.button(text=toggle_text, callback_data=AdminServerCallback(action=toggle_action, data=server_name).pack())
builder.button(
text="📈 Задать лимит", callback_data=AdminServerCallback(action="set_limit", data=server_name).pack()
)
builder.button(text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack())
builder.button(
text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack()
)
builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
builder.adjust(1)
return builder.as_markup()
+269
View File
@@ -0,0 +1,269 @@
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 database import get_servers
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminServerCallback,
build_manage_server_kb,
)
router = Router()
class ServerLimitState(StatesGroup):
waiting_for_limit = State()
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_server_manage(callback_query: CallbackQuery, callback_data: AdminServerCallback):
server_name = callback_data.data
servers = await get_servers(include_enabled=True)
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
)
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {api_url}\n"
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
f"<b>🔑 Inbound ID:</b> {inbound_id}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@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
):
from ..clusters.clusters_handler import AdminClusterStates
server_name = callback_data.data
servers = 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:
break
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Не удалось найти кластер для сервера '{server_name}'.",
reply_markup=build_admin_back_kb("clusters"),
)
return
keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", server_name)
if keys_count > 0:
await state.update_data(server_name=server_name, cluster_name=cluster_name)
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,
)
if all_servers:
builder = InlineKeyboardBuilder()
for server 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}",
)
)
builder.row(
InlineKeyboardButton(
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
)
await callback_query.message.edit_text(
text=f"⚠️ На сервере '{server_name}' есть {keys_count} ключей. Выберите сервер для переноса ключей:",
reply_markup=builder.as_markup(),
)
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
)
if remaining_servers == 0:
other_clusters = await session.fetch(
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1", cluster_name
)
if other_clusters:
cluster_keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", cluster_name)
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,
)
builder = InlineKeyboardBuilder()
for cluster 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}",
)
)
builder.row(
InlineKeyboardButton(
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
)
await callback_query.message.edit_text(
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
reply_markup=builder.as_markup(),
)
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
)
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
)
await callback_query.message.edit_text(
text=f"✅ Сервер '{server_name}' удален.",
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter())
async def toggle_server_enabled(callback_query: CallbackQuery, callback_data: AdminServerCallback, session: Any):
server_name = callback_data.data
action = callback_data.action
new_status = action == "enable"
await session.execute("UPDATE servers SET enabled = $1 WHERE server_name = $2", new_status, server_name)
servers = await get_servers(include_enabled=True)
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
)
if not server:
await callback_query.message.edit_text("❌ Сервер не найден.")
return
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {server['api_url']}\n"
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=new_status),
)
@router.callback_query(AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter())
async def ask_server_limit(callback: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
server_name = callback_data.data
await state.set_state(ServerLimitState.waiting_for_limit)
await state.update_data(server_name=server_name)
await callback.message.edit_text(
f"Введите лимит ключей для сервера <b>{server_name}</b> (целое число, 0 — без лимита):",
)
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
async def save_server_limit(message: types.Message, state: FSMContext, session: Any):
try:
limit = int(message.text.strip())
if limit < 0:
raise ValueError
data = await state.get_data()
server_name = data["server_name"]
new_value = limit if limit > 0 else None
await session.execute("UPDATE servers SET max_keys = $1 WHERE server_name = $2", new_value, server_name)
servers = await get_servers(include_enabled=True)
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
)
if not server:
await message.answer("❌ Сервер не найден.")
await state.clear()
return
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys is not None else "не задан"
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {server['api_url']}\n"
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
await message.answer(
text, reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True))
)
await state.clear()
except ValueError:
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .stats_handler import router
@@ -1,7 +1,7 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_stats_kb() -> InlineKeyboardMarkup:
@@ -14,6 +14,13 @@ def build_stats_kb() -> InlineKeyboardMarkup:
builder.button(
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()
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
+201
View File
@@ -0,0 +1,201 @@
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
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 ..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):
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
moscow_tz = pytz.timezone("Europe/Moscow")
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>{registrations_today}</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>{users_updated_today}</b>\n\n"
"🤝 <b>Реферальная система:</b>\n"
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
"🔐 <b>Подписки:</b>\n"
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>{total_payments_today} ₽</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"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
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)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
IsAdminFilter(),
)
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_users_csv(session)
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)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
IsAdminFilter(),
)
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_payments_csv(session)
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)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"),
IsAdminFilter(),
)
async def handle_export_hot_leads_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_hot_leads_csv(session)
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)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"),
IsAdminFilter(),
)
async def handle_export_keys_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_keys_csv(session)
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)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .users_handler import router
@@ -4,8 +4,11 @@ from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RENEWAL_PRICES
from keyboards.admin.panel_kb import build_admin_back_btn
from config import RENEWAL_PRICES, TOTAL_GB
from database import get_clusters
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_btn
class AdminUserEditorCallback(CallbackData, prefix="admin_users"):
@@ -27,6 +30,11 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> 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(),
)
for record in key_records:
email = record["email"]
expiry = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
@@ -43,6 +51,10 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
text="💸 Изменить баланс",
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(),
)
builder.button(
text="♻️ Восстановить триал",
callback_data=AdminUserEditorCallback(action="users_trial_restore", tg_id=tg_id).pack(),
@@ -59,7 +71,7 @@ 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="🔙 Назад", # todo: fix magic text was set
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
)
return builder.as_markup()
@@ -94,7 +106,7 @@ def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔙 Назад", # todo: fix magic text was set
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
)
return builder.as_markup()
@@ -123,7 +135,7 @@ def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
)
builder.button(
text="🔙 Назад", # todo: fix magic text was set
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)
@@ -144,7 +156,7 @@ def build_user_delete_kb(tg_id: int):
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔙 Назад", 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()
@@ -170,6 +182,13 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
text="📊 Трафик",
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(),
)
builder.row(build_editor_back_btn(key_details["tg_id"], True))
builder.adjust(1)
return builder.as_markup()
@@ -189,7 +208,7 @@ def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
return build_editor_singleton_kb("🔙 Назад", tg_id, edit)
return build_editor_singleton_kb(BACK, tg_id, edit)
def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
@@ -199,10 +218,22 @@ def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> Inli
def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton:
return build_editor_btn("🔙 Назад", tg_id, edit)
return build_editor_btn(BACK, tg_id, edit)
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()
)
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=BACK, callback_data=f"edit_user_key|{tg_id}|{email}")
builder.adjust(1)
return builder.as_markup()
@@ -1,44 +1,61 @@
import asyncio
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
import pytz
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import TOTAL_GB
from config import RENEWAL_PRICES, TOTAL_GB, USE_COUNTRY_SELECTION
from database import (
delete_key,
delete_user_data,
get_balance,
get_client_id_by_email,
get_key_details,
get_servers,
set_user_balance,
update_balance,
update_key_expiry,
update_trial,
)
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import delete_key_from_cluster, get_user_traffic, renew_key_in_cluster, update_subscription
from handlers.utils import sanitize_key_name
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.users_kb import (
from handlers.keys.key_utils import (
create_key_on_cluster,
delete_key_from_cluster,
get_user_traffic,
renew_key_in_cluster,
reset_traffic_in_cluster,
update_subscription,
)
from handlers.utils import generate_random_email, sanitize_key_name
from logger import logger
from utils.csv_export import export_referrals_csv
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb
from .keyboard import (
AdminUserEditorCallback,
AdminUserKeyEditorCallback,
build_cluster_selection_kb,
build_editor_kb,
build_key_delete_kb,
build_key_edit_kb,
build_user_delete_kb,
build_user_edit_kb,
build_user_key_kb,
build_users_balance_change_kb,
build_users_balance_kb,
build_users_key_expiry_kb,
build_users_key_show_kb,
)
from logger import logger
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
@@ -46,13 +63,14 @@ router = Router()
class UserEditorState(StatesGroup):
# search
waiting_for_user_data = State()
waiting_for_key_name = State()
# updating data
waiting_for_balance = State()
waiting_for_expiry_time = State()
waiting_for_message_text = State()
selecting_cluster = State()
selecting_duration = State()
selecting_country = State()
@router.callback_query(
@@ -81,41 +99,6 @@ async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.edit_text(text="🔑 Введите имя ключа для поиска:", reply_markup=build_admin_back_kb())
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
if message.forward_from:
tg_id = message.forward_from.id
await process_user_search(message, state, session, tg_id)
return
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
if message.text.isdigit():
tg_id = int(message.text)
else:
# Удаление '@' символа в начале сообщения
username = message.text.strip().lstrip("@")
# Удаление начала ссылки на профиль
username = username.replace("https://t.me/", "")
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
if not user:
await message.answer(
text="🚫 Пользователь с указанным Username не найден!",
reply_markup=kb,
)
return
tg_id = user["tg_id"]
await process_user_search(message, state, session, tg_id)
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
async def handle_key_name_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
@@ -134,6 +117,39 @@ async def handle_key_name_input(message: Message, state: FSMContext, session: An
await process_user_search(message, state, session, key_details["tg_id"])
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
if message.forward_from:
tg_id = message.forward_from.id
await process_user_search(message, state, session, tg_id)
return
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
if message.text.isdigit():
tg_id = int(message.text)
else:
username = message.text.strip().lstrip("@")
username = username.replace("https://t.me/", "")
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
if not user:
await message.answer(
text="🚫 Пользователь с указанным Username не найден!",
reply_markup=kb,
)
return
tg_id = user["tg_id"]
await process_user_search(message, state, session, tg_id)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_send_message"),
IsAdminFilter(),
@@ -228,8 +244,15 @@ async def handle_balance_add(
tg_id = callback_data.tg_id
amount = callback_data.data
if amount:
await update_balance(tg_id, int(amount), session, is_admin=True)
if amount is not None:
amount = int(amount)
if amount >= 0:
await update_balance(tg_id, amount, session, is_admin=True)
else:
current_balance = await get_balance(tg_id)
new_balance = max(0, current_balance + amount)
await set_user_balance(tg_id, new_balance, session)
await handle_balance_change(callback_query, callback_data, session)
return
@@ -286,8 +309,11 @@ async def handle_balance_input(message: Message, state: FSMContext, session: Any
text = f"✅ К балансу пользователя добавлено <b>{amount}Р</b>"
await update_balance(tg_id, amount, session)
elif op_type == "take":
text = f"✅ Из баланса пользователя было вычтено <b>{amount}Р</b>"
await update_balance(tg_id, -amount, session)
current_balance = await get_balance(tg_id)
new_balance = max(0, current_balance - amount)
deducted = current_balance if amount > current_balance else amount
text = f"✅ Из баланса пользователя было вычтено <b>{deducted}Р</b>"
await set_user_balance(tg_id, new_balance, session)
else:
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
await set_user_balance(tg_id, amount, session)
@@ -312,9 +338,11 @@ async def handle_key_edit(
)
return
key_value = key_details.get("key") or key_details.get("remnawave_link") or ""
text = (
f"<b>🔑 Информация о ключе</b>"
f"\n\n<code>{key_details['key']}</code>"
f"\n\n<code>{key_value}</code>"
f"\n\n⏰ Дата истечения: <b>{key_details['expiry_date']} (UTC)</b>"
f"\n🌐 Кластер: <b>{key_details['cluster_name']}</b>"
f"\n🆔 ID клиента: <b>{key_details['tg_id']}</b>"
@@ -468,16 +496,25 @@ async def handle_update_key(callback_query: CallbackQuery, callback_data: AdminU
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_text(
text=f"📡 Выберите кластер, на котором пересоздать ключ <b>{email}</b>:",
reply_markup=await build_cluster_selection_kb(session, tg_id, email, action="confirm_admin_key_reissue"),
)
@router.callback_query(F.data.startswith("confirm_admin_key_reissue|"), IsAdminFilter())
async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Any):
_, tg_id, email, cluster_id = callback_query.data.split("|")
tg_id = int(tg_id)
try:
await update_subscription(tg_id, email, session)
await handle_key_edit(callback_query, callback_data, session, True)
except TelegramBadRequest:
pass
except Exception as e:
logger.error(f"Ошибка при обновлении ключа {email} администратором: {e}")
await callback_query.message.answer(
text=f"❗ Произошла ошибка при обновлении ключа: {e}", reply_markup=build_user_key_kb(tg_id, email)
await update_subscription(tg_id, email, session, cluster_override=cluster_id)
await handle_key_edit(
callback_query, AdminUserEditorCallback(tg_id=tg_id, data=email, action="view_key"), session, True
)
except Exception as e:
logger.error(f"Ошибка при перевыпуске ключа {email}: {e}")
await callback_query.message.answer(f"❗ Ошибка: {e}")
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key"), IsAdminFilter())
@@ -514,7 +551,7 @@ async def handle_delete_key_confirm(
for cluster_name, cluster_servers in clusters.items():
for _ in cluster_servers:
tasks.append(delete_key_from_cluster(cluster_name, email, client_id))
await asyncio.gather(*tasks)
await asyncio.gather(*tasks, return_exceptions=True)
await delete_key_from_servers()
await delete_key(client_id, session)
@@ -546,7 +583,7 @@ async def handle_delete_user_confirm(
servers = await get_servers()
for cluster_id, _cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
await asyncio.gather(*tasks)
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
@@ -576,25 +613,30 @@ async def process_user_search(
) -> None:
await state.clear()
balance = await session.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
if balance is None:
user_data = await session.fetchrow(
"SELECT username, balance, created_at, updated_at FROM users WHERE tg_id = $1", tg_id
)
if not user_data:
await message.answer(
text="🚫 Пользователь с указанным ID не найден!",
reply_markup=build_admin_back_kb(),
)
return
balance = int(balance)
balance = int(user_data["balance"] or 0)
username = user_data["username"]
created_at = user_data["created_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
updated_at = user_data["updated_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
username = await session.fetchval("SELECT username FROM users WHERE tg_id = $1", tg_id)
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
text = (
f"<b>📊 Информация о пользователе</b>"
f"\n\n🆔 ID: <b>{tg_id}</b>"
f"\n📄 Логин: <b>@{username}</b>"
f"\n📅 Дата регистрации: <b>{created_at}</b>"
f"\n🏃 Дата активности: <b>{updated_at}</b>"
f"\n💰 Баланс: <b>{balance}</b>"
f"\n👥 Количество рефералов: <b>{referral_count}</b>"
)
@@ -610,34 +652,6 @@ async def process_user_search(
await message.answer(text=text, reply_markup=kb)
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT k.client_id, k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
FROM keys k
JOIN connections c ON k.tg_id = c.tg_id
WHERE k.email = $1
""",
email,
)
if not record:
return None
cluster_name = record["server_id"]
expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
return {
"client_id": record["client_id"],
"balance": record["balance"],
"tg_id": record["tg_id"],
"key": record["key"],
"cluster_name": cluster_name,
"expiry_time": record["expiry_time"],
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
}
async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exception | None:
client_id = await get_client_id_by_email(email)
@@ -650,6 +664,8 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
return ValueError(f"User with client_id {server_id} was not found")
clusters = await get_servers()
added_days = max((expiry_time - int(time.time() * 1000)) / (1000 * 86400), 1)
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
async def update_key_on_all_servers():
tasks = [
@@ -659,29 +675,18 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce
email,
client_id,
expiry_time,
total_gb=TOTAL_GB,
total_gb=total_gb,
)
)
for cluster_name in clusters
]
await asyncio.gather(*tasks)
await asyncio.gather(*tasks, return_exceptions=True)
await update_key_on_all_servers()
await update_key_expiry(client_id, expiry_time, session)
async def set_user_balance(tg_id: int, balance: int, session: Any) -> None:
try:
await session.execute(
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
balance,
tg_id,
)
except Exception as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_traffic"), IsAdminFilter())
async def handle_user_traffic(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
@@ -715,3 +720,228 @@ async def handle_user_traffic(
result_text += f"\n🔢 <b>Общий трафик:</b> {total_traffic:.2f} ГБ"
await callback_query.message.edit_text(result_text, reply_markup=build_editor_kb(tg_id, True))
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_trials"), IsAdminFilter())
async def confirm_restore_trials(callback_query: types.CallbackQuery):
"""
Меню подтверждения перед восстановлением пробников.
"""
builder = InlineKeyboardBuilder()
builder.button(text="✅ Подтвердить", callback_data=AdminPanelCallback(action="confirm_restore_trials").pack())
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text="⚠ Вы уверены, что хотите восстановить пробники для пользователей? \n\n"
"Только для тех, у кого нет активной подписки!",
reply_markup=builder.as_markup(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "confirm_restore_trials"), IsAdminFilter())
async def restore_trials(callback_query: types.CallbackQuery, session: Any):
"""
Восстанавливает пробники для пользователей, у которых нет активной подписки.
"""
query = """
UPDATE users
SET trial = 0
WHERE tg_id IN (
SELECT u.tg_id
FROM users u
LEFT JOIN (
SELECT tg_id
FROM keys
WHERE expiry_time > EXTRACT(EPOCH FROM NOW()) * 1000
) k ON u.tg_id = k.tg_id
WHERE k.tg_id IS NULL AND u.trial != 0
)
"""
await session.execute(query)
builder = InlineKeyboardBuilder()
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text="✅ Пробники успешно восстановлены для пользователей без активных подписок.",
reply_markup=builder.as_markup(),
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_export_referrals"), IsAdminFilter())
async def handle_users_export_referrals(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
"""
Обработчик: получает tg_id реферера из callback_data,
вызывает export_referrals_csv и отправляет файл или отвечает,
что рефералов нет.
"""
referrer_tg_id = callback_data.tg_id
csv_file = await export_referrals_csv(referrer_tg_id, session)
if csv_file is None:
await callback_query.message.answer("У пользователя нет рефералов.")
return
await callback_query.message.answer_document(
document=csv_file, caption=f"Список рефералов для пользователя {referrer_tg_id}."
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
async def handle_create_key_start(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id)
if USE_COUNTRY_SELECTION:
await state.set_state(UserEditorState.selecting_country)
rows = await session.fetch("SELECT DISTINCT server_name FROM servers ORDER BY server_name")
countries = [row["server_name"] for row in rows]
if not countries:
await callback_query.message.edit_text(
"❌ Нет доступных стран для создания ключа.", reply_markup=build_editor_kb(tg_id)
)
return
builder = InlineKeyboardBuilder()
for country in countries:
builder.button(text=country, callback_data=country)
builder.adjust(1)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
"🌍 <b>Выберите страну для создания ключа:</b>", reply_markup=builder.as_markup()
)
return
await state.set_state(UserEditorState.selecting_cluster)
servers = await get_servers(session)
cluster_names = list(servers.keys())
if not cluster_names:
await callback_query.message.edit_text(
"❌ Нет доступных кластеров для создания ключа.", reply_markup=build_editor_kb(tg_id)
)
return
builder = InlineKeyboardBuilder()
for cluster in cluster_names:
builder.button(text=f"🌐 {cluster}", callback_data=cluster)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
"🌐 <b>Выберите кластер для создания ключа:</b>", reply_markup=builder.as_markup()
)
@router.callback_query(UserEditorState.selecting_country, IsAdminFilter())
async def handle_create_key_country(callback_query: CallbackQuery, state: FSMContext):
country = callback_query.data
await state.update_data(country=country)
await state.set_state(UserEditorState.selecting_duration)
builder = InlineKeyboardBuilder()
for months, _ in RENEWAL_PRICES.items():
builder.button(text=f"{months} мес.", callback_data=str(months))
builder.adjust(1)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text=f"🕒 <b>Выберите срок действия ключа для страны {country}:</b>", reply_markup=builder.as_markup()
)
@router.callback_query(UserEditorState.selecting_cluster, IsAdminFilter())
async def handle_create_key_cluster(callback_query: CallbackQuery, state: FSMContext):
cluster_name = callback_query.data
await state.update_data(cluster_name=cluster_name)
await state.set_state(UserEditorState.selecting_duration)
builder = InlineKeyboardBuilder()
for months, _ in RENEWAL_PRICES.items():
builder.button(text=f"{months} мес.", callback_data=str(months))
builder.adjust(1)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text=f"🕒 <b>Выберите срок действия ключа для кластера {cluster_name}:</b>", reply_markup=builder.as_markup()
)
@router.callback_query(UserEditorState.selecting_duration, IsAdminFilter())
async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMContext, session: Any):
try:
months = int(callback_query.data)
data = await state.get_data()
tg_id = data["tg_id"]
client_id = str(uuid.uuid4())
email = generate_random_email()
expiry = datetime.now(tz=timezone.utc) + timedelta(days=30 * months)
expiry_ms = int(expiry.timestamp() * 1000)
if USE_COUNTRY_SELECTION and "country" in data:
country = data["country"]
await create_key_on_cluster(country, tg_id, client_id, email, expiry_ms, plan=months, session=session)
await state.clear()
await callback_query.message.edit_text(
f"✅ Ключ успешно создан для страны <b>{country}</b> на {months} мес.",
reply_markup=build_editor_kb(tg_id),
)
elif "cluster_name" in data:
cluster_name = data["cluster_name"]
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms, plan=months, session=session)
await state.clear()
await callback_query.message.edit_text(
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.",
reply_markup=build_editor_kb(tg_id),
)
else:
await callback_query.message.edit_text("❌ Не удалось определить источник — страна или кластер.")
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
await callback_query.message.edit_text(
"❌ Не удалось создать ключ. Попробуйте позже.", reply_markup=build_editor_kb(data.get("tg_id", 0))
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_reset_traffic"), IsAdminFilter())
async def handle_reset_traffic(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
tg_id = callback_data.tg_id
email = callback_data.data
record = await session.fetchrow(
"SELECT server_id, client_id FROM keys WHERE tg_id = $1 AND email = $2",
tg_id,
email,
)
if not record:
await callback_query.message.edit_text("❌ Ключ не найден в базе данных.", reply_markup=build_editor_kb(tg_id))
return
cluster_id = record["server_id"]
try:
await reset_traffic_in_cluster(cluster_id, email)
await callback_query.message.edit_text(
f"✅ Трафик для ключа <b>{email}</b> успешно сброшен.", reply_markup=build_editor_kb(tg_id)
)
except Exception as e:
logger.error(f"Ошибка при сбросе трафика: {e}")
await callback_query.message.edit_text(
"❌ Произошла ошибка при сбросе трафика. Попробуйте позже.", reply_markup=build_editor_kb(tg_id)
)
+83
View File
@@ -0,0 +1,83 @@
# Основные кнопки меню:
MAIN_MENU = "👤 Личный кабинет"
BACK = "⬅️ Назад"
SUPPORT = "💬 Поддержка"
CHANNEL = "📢 Канал"
ABOUT_VPN = "💬 О сервисе"
APPLY = "✅ Подтвердить"
CANCEL = "❌ Отмена"
# Профиль
ADD_SUB = " Подписка"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
INSTRUCTIONS = "📘 Инструкции"
TOP_FIVE = "🏆 Топ-5"
TRIAL_SUB = "🎁 Пробная подписка"
# Меню Оплат и баланса
BALANCE_HISTORY = "📊 История пополнения"
PAYMENT = "💳 Пополнить баланс"
COUPON = "🎟️ Активировать купон"
COUPON_RESTART = "🎟️ Попробовать другой купон"
YOOKASSA = "💳 ЮКасса: быстрая оплата"
YOOMONEY = "💳 ЮМани: перевод по карте"
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
STARS = "⭐ Оплата Звездами"
ROBOKASSA = "⭐ RoboKassa"
# Подарки
GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
GIFTS_MENU = "В меню подарков"
SHARE_GIFT = "🎁 Поделиться подарком"
GET_GIFT = "🎁 Получить подарок"
# Создание и подключение ключей
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
DOWNLOAD_MACOS_BUTTON = "🍏 Скачать macOS"
DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
IMPORT_IOS = "🍏 Подключить"
IMPORT_ANDROID = "🤖 Подключить"
PC_BUTTON = "💻 Компьютеры"
TV_BUTTON = "📺 Андроид TV"
CONNECT_PHONE = "📱 Подключить телефон"
CONNECT_DEVICE = "📲 Подключить устройство"
CONNECT_WINDOWS_BUTTON = "💻 Подключить"
CONNECT_MACOS_BUTTON = "🍏 Подключить"
ALIAS = "✏️"
UNFREEZE = "🟢 Разморозить подписку"
FREEZE = "🛑 Заморозить подписку"
RENEW = "⏳ Продлить"
RENEW_FULL = "⏳ Продлить подписку"
DELETE = "❌ Удалить"
CHANGE_LOCATION = "🌍 Сменить локацию"
QR = "📷 Показать QR-код"
IPHONE = "🍏 Айфон"
ANDROID = "🤖 Андроид"
PC = "💻 Компьютер"
PC_PC = "💻 Windows"
PC_MACOS = "🍏 macOS"
TV = "📺 Телевизор"
ROUTER = "📶 Роутер"
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
RENEW_KEY = "🔄 Продлить подписку"
TV_CONTINUE = "▶ Продолжить"
TV_INSTRUCTIONS = "📖 Полная инструкция"
# Кнопки касс
PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
STARS_BOT = "🤖 Бот для покупки звезд"
-6
View File
@@ -1,6 +0,0 @@
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
IMPORT_IOS = "🍏 Подключить"
IMPORT_ANDROID = "🤖 Подключить"
PC_BUTTON = "💻 Компьютеры"
TV_BUTTON = "📺 Андроид TV"
-9
View File
@@ -1,9 +0,0 @@
GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
PROFILE = "👤 Личный кабинет"
BACK = "В меню подарков"
GIFTS_ABOUT = "<b>Дарите подарки и следите, чтобы они дошли до адресата! 🎄</b>"
SHARE_GIFT = "🎁 Поделиться подарком"
APPLY = "✅ Подтвердить"
CANCEL = "❌ Отмена"
NO_GIFTS = "У вас нет подарков."
-3
View File
@@ -1,3 +0,0 @@
RENEW_KEY = "🔄 Продлить подписку"
ADD_KEY = " Добавить подписку"
PROFILE = "👤 Личный кабинет"
-10
View File
@@ -1,10 +0,0 @@
ADD_SUB = " Подписка"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
BALANCE_HISTORY = "📊 История пополнения"
PAYMENT = "💳 Пополнить баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
MAIN_MENU = "⬅️ Главное меню"
-12
View File
@@ -1,12 +0,0 @@
PAY = "Пополнить"
PAY_2 = "Оплатить"
BACK = "⬅️ Назад"
CUSTOM_SUM = "💰 Ввести свою сумму"
PROFILE = "👤 Личный кабинет"
KEY_CREATION_PAYMENT_MESSAGE = (
"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
)
KEY_RENEWAL_PAYMENT_MESSAGE = (
"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
)
DEFAULT_PAYMENT_MESSAGE = "Вы выбрали пополнение на {amount} рублей. Перейдите по ссылке для оплаты:"
+4 -2
View File
@@ -1,5 +1,6 @@
import random
import secrets
from typing import Any
from aiogram import F, Router
@@ -7,11 +8,12 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CAPTCHA_EMOJIS
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
from logger import logger
from .utils import edit_or_send_message
router = Router()
@@ -35,7 +37,7 @@ async def generate_captcha(message: Message, state: FSMContext):
builder.adjust(2, 2)
return {
"text": f"🔒 Для подтверждения, что вы не робот, выберите кнопку с {correct_text}",
"text": CAPTCHA_PROMPT_MSG.format(correct_text=correct_text),
"markup": builder.as_markup(),
}
+173 -22
View File
@@ -1,24 +1,43 @@
import html
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import ADMIN_ID
from database import (
add_user,
check_coupon_usage,
check_user_exists,
create_coupon_usage,
get_coupon_by_code,
get_keys,
update_balance,
update_coupon_usage_count,
update_key_expiry,
)
from .utils import edit_or_send_message
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPON_ALREADY_USED_MSG,
COUPON_INPUT_PROMPT,
COUPON_NOT_FOUND_MSG,
)
from handlers.utils import edit_or_send_message, format_days
from logger import logger
class CouponActivationState(StatesGroup):
waiting_for_coupon_code = State()
waiting_for_key_selection = State()
router = Router()
@@ -28,7 +47,7 @@ router = Router()
@router.message(F.text == "/activate_coupon")
async def handle_activate_coupon(callback_query_or_message: Message | CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
@@ -37,8 +56,7 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
await edit_or_send_message(
target_message=target_message,
text="<b>🎫 Введите код купона:</b>\n\n"
"📝 Пожалуйста, введите действующий код купона, который вы хотите активировать. 🔑",
text=COUPON_INPUT_PROMPT,
reply_markup=builder.as_markup(),
media_path=None,
)
@@ -48,30 +66,163 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
@router.message(CouponActivationState.waiting_for_coupon_code)
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
coupon_code = message.text.strip()
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await message.answer(activation_result, reply_markup=builder.as_markup())
await state.clear()
await activate_coupon(message, state, session, coupon_code=coupon_code)
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
async def activate_coupon(message: Message, state: FSMContext, session: Any, coupon_code: str, admin: bool = False):
logger.info(f"Активация купона: {coupon_code}")
coupon_record = await get_coupon_by_code(coupon_code, session)
if not coupon_record:
return "<b>❌ Купон не найден</b> 🚫 или его использование ограничено. 🔒 Пожалуйста, проверьте код и попробуйте снова. 🔍"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
return
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
if coupon_record["usage_count"] >= coupon_record["usage_limit"] or coupon_record["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
await state.clear()
return
if usage_exists:
return "<b>❌ Вы уже активировали этот купон.</b> 🚫 Купоны могут быть активированы только один раз. 🔒"
user_id = message.chat.id
coupon_amount = coupon_record["amount"]
usage = await check_coupon_usage(coupon_record["id"], user_id, session)
if usage:
await message.answer(COUPON_ALREADY_USED_MSG)
await state.clear()
return
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
user_exists = await check_user_exists(user_id)
if not user_exists:
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 update_balance(user_id, coupon_amount, session)
return f"<b>✅ Купон успешно активирован! 🎉</b>\n\nНа ваш баланс добавлено <b>{coupon_amount} рублей</b> 💰."
if coupon_record["amount"] > 0:
try:
await update_balance(user_id, coupon_record["amount"], session, skip_referral=True)
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon_record['amount']} рублей.")
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
if coupon_record["days"] is not None and coupon_record["days"] > 0:
try:
keys = await get_keys(user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
await state.clear()
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon_record['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(CouponActivationState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon_record["id"], user_id=user_id)
except Exception as e:
logger.error(f"Ошибка при обработке купона на дни: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
await state.clear()
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
try:
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await check_coupon_usage(coupon_id, callback_query.from_user.id, session)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2", callback_query.from_user.id, client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
await renew_key_in_cluster(
cluster_id=key["server_id"], email=key["email"], client_id=client_id, new_expiry_time=new_expiry, total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await update_coupon_usage_count(coupon["id"], session)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime(
"%d.%m.%y, %H:%M"
)
await callback_query.message.answer(
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Ошибка при активации купона.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
@router.callback_query(F.data == "exit_coupon_input")
async def handle_exit_coupon_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
is_admin = callback_query.from_user.id in ADMIN_ID
await process_callback_view_profile(callback_query.message, state, admin=is_admin)
+5 -4
View File
@@ -5,6 +5,7 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Mes
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RUB_TO_XTR
from handlers.buttons import BACK, MAIN_MENU
from logger import logger
from .utils import edit_or_send_message
@@ -31,7 +32,7 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
callback_data="enter_custom_donate_amount",
)
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = (
"🌟 Поддержите наш проект! 💪\n\n"
@@ -49,7 +50,7 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == "enter_custom_donate_amount")
async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
text = "💸 Введите сумму доната в рублях:"
await edit_or_send_message(
@@ -75,7 +76,7 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
try:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
await message.answer_invoice(
title=f"Донат проекту {amount} рублей",
@@ -103,7 +104,7 @@ async def on_successful_donate(message: Message, state: FSMContext):
try:
amount = float(message.successful_payment.invoice_payload.split("_")[0])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await message.answer(
text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
reply_markup=builder.as_markup(),
+1
View File
@@ -4,6 +4,7 @@ from aiogram import Router
from .instructions import router as instructions_router
router = Router(name="instructions_main_router")
router.include_routers(
+81 -19
View File
@@ -1,7 +1,8 @@
import os
from typing import Any
from aiogram import F, Router, types
from aiogram import F, Router
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
@@ -9,17 +10,39 @@ from aiogram.types import (
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CONNECT_MACOS, CONNECT_WINDOWS, SUPPORT_CHAT_URL
from config import (
CONNECT_MACOS,
CONNECT_WINDOWS,
DOWNLOAD_MACOS,
DOWNLOAD_PC,
SUPPORT_CHAT_URL,
)
from database import get_key_details
from handlers.buttons import (
BACK,
CONNECT_MACOS_BUTTON,
CONNECT_WINDOWS_BUTTON,
DOWNLOAD_MACOS_BUTTON,
DOWNLOAD_PC_BUTTON,
MAIN_MENU,
PC_MACOS,
PC_PC,
SUPPORT,
TV_CONTINUE,
TV_INSTRUCTIONS,
)
from handlers.texts import (
CHOOSE_DEVICE_TEXT,
CONNECT_TV_TEXT,
INSTRUCTION_PC,
INSTRUCTIONS,
INSTRUCTION_MACOS,
INSTRUCTION_PC,
KEY_MESSAGE,
SUBSCRIPTION_DETAILS_TEXT,
)
from handlers.utils import edit_or_send_message
router = Router()
@@ -30,8 +53,8 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
image_path = os.path.join("img", "instructions.jpg")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
@@ -52,7 +75,7 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
record = await get_key_details(key_name, session)
if not record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍",
@@ -61,16 +84,32 @@ 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=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
@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)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}"))
builder.row(InlineKeyboardButton(text="💻 Подключить MacOS", url=f"{CONNECT_MACOS}{key}"))
builder.row(InlineKeyboardButton(text="🆘 Поддержка", url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
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=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
@@ -80,15 +119,36 @@ async def process_connect_pc(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)
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=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=instruction_message,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_tv|"))
async def process_connect_tv(callback_query: CallbackQuery):
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="▶ Продолжить", callback_data=f"continue_tv|{key_name}"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
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"))
await edit_or_send_message(
target_message=callback_query.message,
@@ -102,17 +162,19 @@ 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]
tg_id = callback_query.from_user.id
record = await get_key_details(key_name, session)
subscription_link = record["key"]
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="📖 Полная инструкция", url="https://vpn4tv.com/quick-guide.html"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"connect_tv|{key_name}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
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"))
await edit_or_send_message(
target_message=callback_query.message, text=message_text, reply_markup=builder.as_markup(), media_path=None
target_message=callback_query.message,
text=message_text,
reply_markup=builder.as_markup(),
media_path=None,
)
+7 -5
View File
@@ -2,12 +2,14 @@ __all__ = ("router",)
from aiogram import Router
from .key_management import router as management_router
from .key_connect import router as connect_router
from .key_freeze import router as freeze_router
from .key_mode import router as key_mode_router
from .key_renew import router as renew_router
from .key_view import router as view_router
from .keys import router as keys_router
router = Router(name="keys_main_router")
router.include_routers(
keys_router,
management_router,
)
router.include_routers(keys_router, view_router, renew_router, freeze_router, connect_router, key_mode_router)
+229
View File
@@ -0,0 +1,229 @@
import os
from io import BytesIO
from typing import Any
import asyncpg
import qrcode
from aiogram import F, Router, types
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, INSTRUCTIONS_BUTTON
from handlers.buttons import (
ANDROID,
BACK,
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
IPHONE,
MAIN_MENU,
MANUAL_INSTRUCTIONS,
PC,
TV,
)
from handlers.texts import (
ANDROID_DESCRIPTION_TEMPLATE,
CHOOSE_DEVICE_TEXT,
IOS_DESCRIPTION_TEMPLATE,
SUBSCRIPTION_DESCRIPTION,
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
try:
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
logger.error(f"Ошибка в handle_connect_device: {e}")
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow(
"""
SELECT key FROM keys WHERE email = $1
""",
email,
)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
)
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
try:
key_name = callback_query.data.split("|")[1]
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(record["key"])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record['email']}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
+220
View File
@@ -0,0 +1,220 @@
import time
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
TOTAL_GB,
)
from database import (
get_key_details,
)
from handlers.buttons import (
APPLY,
BACK,
CANCEL,
)
from handlers.keys.key_utils import (
renew_key_in_cluster,
toggle_client_on_cluster,
)
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
SUBSCRIPTION_UNFROZEN_MSG,
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
)
from handlers.utils import edit_or_send_message, handle_error
router = Router()
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"unfreeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Размораживает (включает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = FALSE
WHERE tg_id = $2
AND client_id = $3
""",
new_expiry_time,
record["tg_id"],
client_id,
)
added_days = max(leftover / (1000 * 86400), 0.01)
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
await renew_key_in_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
key_name = callback_query.data.split("|")[1]
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"freeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Замораживает (отключает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
time_left = record["expiry_time"] - now_ms
if time_left < 0:
time_left = 0
await session.execute(
"""
UPDATE keys
SET expiry_time = $1,
is_frozen = TRUE
WHERE tg_id = $2
AND client_id = $3
""",
time_left,
record["tg_id"],
client_id,
)
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
-538
View File
@@ -1,538 +0,0 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from bot import bot
from client import delete_client
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_ANDROID,
CONNECT_IOS,
CONNECT_PHONE_BUTTON,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
NOTIFY_EXTRA_DAYS,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_connection,
check_connection_exists,
create_temporary_data,
get_balance,
get_key_details,
get_trial,
store_key,
update_balance,
update_trial,
)
from handlers.buttons.add_subscribe import (
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
PC_BUTTON,
TV_BUTTON,
)
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import DISCOUNTS, key_message_success
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
waiting_for_key_name = "waiting_for_key_name"
viewing_profile = "viewing_profile"
waiting_for_message = "waiting_for_message"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
updated = await update_trial(tg_id, 1, session)
if updated:
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text="⏳ Пожалуйста, подождите, создаем вам подключение...",
reply_markup=None,
)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
else:
logger.error(f"Не удалось обновить статус триального периода для пользователя {tg_id}.")
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text="💳 Выберите тарифный план для создания нового ключа:",
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):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=f"💳 Недостаточно средств. Для продолжения необходимо пополнить баланс на {required_amount}₽.",
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text="⏳ Подождите, создаем вам подключение...",
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await create_key(tg_id, expiry_time, None, session, callback_query, None, plan_id)
await update_balance(tg_id, -plan_price, session)
async def create_key(
tg_id: int,
expiry_time: datetime,
state: FSMContext | None,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
"""Создаёт ключ с заданным сроком действия."""
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
if USE_COUNTRY_SELECTION:
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
least_loaded_cluster = await get_least_loaded_cluster()
logger.info(
f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов"
)
servers = await session.fetch(
"SELECT server_name FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
countries = [server["server_name"] for server in servers]
logger.info(f"[Country Selection] Список серверов: {countries}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in countries:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="profile"))
if target_message:
await edit_or_send_message(
target_message=target_message,
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await bot.send_message(
chat_id=tg_id,
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
reply_markup=builder.as_markup(),
)
return
while True:
key_name = generate_random_email()
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
least_loaded_cluster = await get_least_loaded_cluster()
tasks = [
asyncio.create_task(
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
)
]
await asyncio.gather(*tasks)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
least_loaded_cluster,
session,
)
await update_trial(tg_id, 1, session)
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
else:
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
expiry_time_local = expiry_time.replace(tzinfo=None).astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
if target_message:
await edit_or_send_message(
target_message=target_message, text=key_message_text, reply_markup=builder.as_markup(), media_path=None
)
else:
await bot.send_message(
chat_id=tg_id,
text=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
servers = await session.fetch("SELECT server_name FROM servers")
countries = [row["server_name"] for row in servers]
logger.info(f"Доступные страны для смены локации: {countries}")
builder = InlineKeyboardBuilder()
for country in countries:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name – значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, None, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
"""Финализирует создание ключа с выбором стран."""
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
key_name = old_key_name
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
old_client_id = old_key_details.get("client_id") if old_key_details else None
old_email = old_key_details.get("email") if old_key_details else None
old_server_id = old_key_details.get("server_id") if old_key_details else None
if old_client_id and old_email and old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
xui = AsyncApi(
old_server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
deletion_success = await delete_client(
xui,
old_server_info["inbound_id"],
old_email,
old_client_id,
)
if not deletion_success:
raise ValueError(f"Не удалось удалить клиента с сервера {old_server_id}.")
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
if old_key_name:
await session.execute(
"""
UPDATE keys
SET client_id = $1, email = $2, expiry_time = $3, key = $4, server_id = $5
WHERE tg_id = $6 AND email = $7
""",
client_id,
email,
expiry_timestamp,
public_link,
selected_country,
tg_id,
old_key_name,
)
else:
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
selected_country,
)
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
else:
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
await edit_or_send_message(
target_message=callback_query.message, text=key_message_text, reply_markup=builder.as_markup(), media_path=None
)
if state:
await state.clear()
+16
View File
@@ -0,0 +1,16 @@
__all__ = ("router",)
from aiogram import Router
from .key_cluster_mode import router as cluster_router
from .key_country_mode import router as country_router
from .key_create import router as create_router
router = Router(name="key_mode_router")
router.include_routers(
create_router,
cluster_router,
country_router,
)
+149
View File
@@ -0,0 +1,149 @@
import uuid
from datetime import datetime
import pytz
from aiogram import Router
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
CONNECT_PHONE_BUTTON,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
)
from database import (
get_key_details,
get_trial,
update_balance,
update_trial,
)
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import (
key_message_success,
)
from handlers.utils import (
edit_or_send_message,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_cluster_mode(
tg_id: int,
expiry_time: datetime,
state,
session,
message_or_query: Message | CallbackQuery | None = None,
plan: int = None,
):
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
try:
least_loaded_cluster = await get_least_loaded_cluster()
await create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan, session)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
key_record = await get_key_details(email, session)
if not key_record:
raise ValueError(f"Ключ не найден после создания: {email}")
public_link = key_record.get("key")
remnawave_link = key_record.get("remnawave_link")
final_link = public_link or remnawave_link or ""
data = await state.get_data() if state else {}
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if await is_full_remnawave_cluster(least_loaded_cluster, session):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=final_link),
)
)
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(final_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
if target_message:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
+511
View File
@@ -0,0 +1,511 @@
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.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from config import (
DATABASE_URL,
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
)
from database import (
add_user,
check_server_name_by_cluster,
check_user_exists,
get_key_details,
get_trial,
update_balance,
update_trial,
)
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_client_on_server
from handlers.texts import (
SELECT_COUNTRY_MSG,
key_message_success,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_country_mode(
tg_id: int,
expiry_time: datetime,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
):
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
least_loaded_cluster = await get_least_loaded_cluster()
servers = await session.fetch(
"SELECT server_name, api_url, panel_type FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
if not servers:
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
return
available_servers = []
tasks = [asyncio.create_task(check_server_availability(server)) for server in servers]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
return
logger.info(f"[Country Selection] Доступные серверы для выбора: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = await session.fetch(
"SELECT server_name, api_url, panel_type, enabled, max_keys FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
current_server,
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
"panel_type": server["panel_type"],
"enabled": server.get("enabled", True),
"max_keys": server.get("max_keys"),
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name – значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
if not await check_user_exists(tg_id):
if isinstance(callback_query, CallbackQuery):
from_user = callback_query.from_user
else:
from_user = callback_query.from_user
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
remna = None
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
panel_type = server_info["panel_type"].lower()
public_link = None
remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
if old_key_name:
old_server_id = old_key_details.get("server_id")
if old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
old_panel_type = old_server_info["panel_type"].lower()
try:
if old_panel_type == "3x-ui":
xui = await get_xui_instance(old_server_info["api_url"])
await delete_client(
xui,
old_server_info["inbound_id"],
email,
client_id,
)
await session.execute(
"UPDATE keys SET key = NULL WHERE tg_id = $1 AND email = $2",
tg_id,
email,
)
logger.info(f"[Delete] Удалён клиент {email} с 3x-ui сервера {old_server_id}")
elif old_panel_type == "remnawave":
remna = RemnawaveAPI(old_server_info["api_url"])
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
await session.execute(
"UPDATE keys SET remnawave_link = NULL WHERE tg_id = $1 AND email = $2",
tg_id,
email,
)
logger.info(f"[Delete] Удалён клиент {client_id} с Remnawave сервера {old_server_id}")
else:
logger.warning(f"[Delete] Не удалось авторизоваться в Remnawave ({old_server_id})")
except Exception as e:
logger.warning(f"[Delete] Ошибка при удалении клиента с сервера {old_server_id}: {e}")
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": [server_info["inbound_id"]],
}
result = await remna.create_user(user_data)
if not result:
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
client_id = result.get("uuid")
remnawave_link = result.get("subscriptionUrl")
logger.info(f"[Key Creation] Remnawave пользователь создан: {result}")
if old_key_name:
await session.execute(
"UPDATE keys SET client_id = $1 WHERE tg_id = $2 AND email = $3",
client_id,
tg_id,
email,
)
if panel_type == "3x-ui":
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
if old_key_name:
await session.execute(
"UPDATE keys SET server_id = $1 WHERE tg_id = $2 AND email = $3",
selected_country,
tg_id,
old_key_name,
)
if panel_type == "3x-ui":
await session.execute(
"UPDATE keys SET key = $1 WHERE tg_id = $2 AND email = $3",
public_link,
tg_id,
email,
)
elif panel_type == "remnawave":
await session.execute(
"UPDATE keys SET remnawave_link = $1 WHERE tg_id = $2 AND email = $3",
remnawave_link,
tg_id,
email,
)
else:
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, remnawave_link, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
remnawave_link,
selected_country,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
except Exception as e:
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
key_message_text = key_message_success(link_to_show, f"⏳ Осталось дней: {days} 📅")
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path="img/pic.jpg",
)
if state:
await state.clear()
async def check_server_availability(server_info: dict, session: Any = None) -> bool:
server_name = server_info.get("server_name", "unknown")
panel_type = server_info.get("panel_type", "3x-ui").lower()
enabled = server_info.get("enabled", True)
max_keys = server_info.get("max_keys")
if not enabled:
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
return False
connection = None
external_session = session is not None
try:
if not external_session:
connection = await asyncpg.connect(DATABASE_URL)
session = connection
if max_keys is not None:
count_query = "SELECT COUNT(*) FROM keys WHERE server_id = $1"
key_count = await session.fetchval(count_query, server_name)
if key_count >= max_keys:
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}.")
return False
except Exception as e:
logger.warning(f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}")
return False
finally:
if connection:
await connection.close()
try:
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True
else:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
return True
except TimeoutError:
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
return False
+213
View File
@@ -0,0 +1,213 @@
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
NOTIFY_EXTRA_DAYS,
RENEWAL_PRICES,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_user,
check_user_exists,
create_temporary_data,
get_balance,
get_trial,
)
from handlers.buttons import (
MAIN_MENU,
PAYMENT,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message
from logger import logger
from .key_cluster_mode import key_cluster_mode
from .key_country_mode import key_country_mode
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if DISCOUNTS and plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
async def create_key(
tg_id: int,
expiry_time,
state,
session,
message_or_query=None,
old_key_name: str = None,
plan: int = None,
):
"""
Универсальная точка входа для создания ключа.
Делегирует выполнение в зависимости от выбранного режима (страна или кластер).
Также отвечает за первичное подключение пользователя.
"""
if not await check_user_exists(tg_id):
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
if from_user:
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
if USE_COUNTRY_SELECTION:
await key_country_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
old_key_name=old_key_name,
)
else:
await key_cluster_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
plan=plan,
)
+226
View File
@@ -0,0 +1,226 @@
from datetime import datetime, timedelta
from typing import Any
import asyncpg
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
DATABASE_URL,
RENEWAL_PRICES,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
check_server_name_by_cluster,
create_temporary_data,
get_balance,
get_key_by_server,
get_key_details,
update_balance,
update_key_expiry,
)
from handlers.buttons import (
BACK,
MAIN_MENU,
PAYMENT,
)
from handlers.keys.key_utils import (
renew_key_in_cluster,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
DISCOUNTS,
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
SUCCESS_RENEWAL_MSG,
)
from handlers.utils import edit_or_send_message, format_months
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
for plan_id, price in RENEWAL_PRICES.items():
months = int(plan_id)
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
button_text = f"📅 {format_months(months)} ({price} руб.)"
if discount > 0:
button_text += f" {discount}% скидка"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{months}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await callback_query.message.answer("<b>Ключ не найден.</b>")
except Exception as e:
logger.error(f"Ошибка в process_callback_renew_key: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
days_to_extend = 30 * int(plan)
total_gb = int((int(plan) or 1) * TOTAL_GB * 1024**3)
try:
record = await get_key_by_server(tg_id, client_id, session)
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
cost = RENEWAL_PRICES.get(plan)
if cost is None:
await callback_query.message.answer("❌ Неверный тарифный план.")
return
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
)
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"plan": plan,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
await handle_custom_amount_input(callback_query, session)
else:
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
else:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
logger.info(f"[Info] Продление ключа {client_id} на {plan} мес. (Start)")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
response_message = SUCCESS_RENEWAL_MSG.format(months_formatted=format_months(int(plan)))
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
conn = await asyncpg.connect(DATABASE_URL)
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
await conn.close()
return
server_id = key_info["server_id"]
if USE_COUNTRY_SELECTION:
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
await conn.close()
return
cluster_id = cluster_info["cluster_name"]
else:
cluster_id = server_id
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
await update_key_expiry(client_id, new_expiry_time, conn)
await update_balance(tg_id, -cost, conn)
await conn.close()
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
+486 -153
View File
@@ -1,24 +1,49 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
from py3xui import AsyncApi
import asyncpg
from client import ClientConfig, add_client, delete_client, extend_client_key, get_client_traffic, toggle_client
from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB, USE_COUNTRY_SELECTION
from database import get_servers, store_key
from handlers.utils import get_least_loaded_cluster
from bot import bot
from config import (
DATABASE_URL,
LIMIT_IP,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
SUPERNODE,
TOTAL_GB,
)
from database import delete_notification, get_servers, store_key
from handlers.utils import check_server_key_limit, get_least_loaded_cluster
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import (
ClientConfig,
add_client,
delete_client,
extend_client_key,
get_client_traffic,
get_xui_instance,
toggle_client,
)
async def create_key_on_cluster(
cluster_id: str, tg_id: int, client_id: str, email: str, expiry_timestamp: int, plan: int = None
cluster_id: str,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
plan: int = None,
session=None,
remnawave_link: str = None,
):
"""
Создает ключ на всех серверах указанного кластера (или на конкретном сервере, если cluster_id — это имя сервера).
"""
try:
servers = await get_servers()
servers = await get_servers(include_enabled=True)
cluster = servers.get(cluster_id)
server_id_to_store = cluster_id
if not cluster:
found_servers = []
@@ -28,22 +53,118 @@ async def create_key_on_cluster(
found_servers.append(server_info)
if found_servers:
cluster = found_servers
server_id_to_store = found_servers[0].get("server_name")
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
enabled_servers = [s for s in cluster if s.get("enabled", True)]
if not enabled_servers:
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
return
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
remnawave_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, conn)
]
xui_servers = [
s
for s in enabled_servers
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, conn)
]
if not remnawave_servers and not xui_servers:
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
return
semaphore = asyncio.Semaphore(2)
if SUPERNODE:
for server_info in cluster:
await create_client_on_server(
server_info, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan
)
else:
await asyncio.gather(
*(
create_client_on_server(server, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan)
for server in cluster
remnawave_created = False
remnawave_key = None
remnawave_client_id = None
if remnawave_servers:
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
else:
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
inbound_ids = [s.get("inbound_id") for s in remnawave_servers if s.get("inbound_id")]
if not inbound_ids:
logger.warning("Нет inbound_id у серверов Remnawave")
else:
traffic_limit_bytes = int((plan or 1) * TOTAL_GB * 1024**3)
short_uuid = None
if remnawave_link and "/" in remnawave_link:
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"trafficLimitBytes": traffic_limit_bytes,
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
if short_uuid:
user_data["shortUuid"] = short_uuid
result = await remna.create_user(user_data)
if not result:
logger.error("Ошибка при создании пользователя в Remnawave")
else:
remnawave_created = True
remnawave_key = result.get("subscriptionUrl")
remnawave_client_id = result.get("uuid")
logger.info(f"[Key Creation] Пользователь создан в Remnawave: {result}")
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" if xui_servers else None
final_client_id = remnawave_client_id or client_id
if xui_servers:
if SUPERNODE:
for server_info in xui_servers:
await create_client_on_server(
server_info,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
)
else:
await asyncio.gather(
*(
create_client_on_server(
server,
tg_id,
final_client_id,
email,
expiry_timestamp,
semaphore,
plan=plan,
)
for server in xui_servers
),
return_exceptions=True,
)
if (remnawave_created and remnawave_client_id) or xui_servers:
await store_key(
tg_id,
final_client_id,
email,
expiry_timestamp,
key=public_link,
server_id=server_id_to_store,
session=session,
remnawave_link=remnawave_key,
)
except Exception as e:
@@ -64,11 +185,7 @@ async def create_client_on_server(
Создает клиента на указанном сервере.
"""
async with semaphore:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
@@ -84,7 +201,7 @@ async def create_client_on_server(
unique_email = email
sub_id = unique_email
total_gb_value = int(TOTAL_GB) if plan is None else int(plan) * int(TOTAL_GB)
total_gb_value = int((plan or 1) * TOTAL_GB * 1024**3)
await add_client(
xui,
@@ -122,33 +239,98 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
tg_id_query = "SELECT tg_id FROM keys WHERE client_id = $1 LIMIT 1"
tg_id_record = await conn.fetchrow(tg_id_query, client_id)
if not tg_id_record:
logger.error(f"Не найден пользователь с client_id={client_id} в таблице keys.")
return False
tg_id = tg_id_record["tg_id"]
notification_prefixes = ["key_24h", "key_10h", "key_expired", "renew"]
for notif in notification_prefixes:
notification_id = f"{email}_{notif}"
await delete_notification(tg_id, notification_id, session=conn)
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
remnawave_inbound_ids = []
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
inbound_id = server_info.get("inbound_id")
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if panel_type == "remnawave":
inbound_uuid = server_info.get("inbound_id")
if inbound_uuid:
remnawave_inbound_ids.append(inbound_uuid)
else:
logger.warning(f"Не указан inbound_id для продления Remnawave на сервере {server_name}")
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
tasks.append(
extend_client_key(xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id)
if remnawave_inbound_ids:
remnawave_server = next(
(
srv
for srv in cluster
if srv.get("panel_type", "").lower() == "remnawave"
and srv.get("inbound_id") in remnawave_inbound_ids
),
None,
)
await asyncio.gather(*tasks)
if not remnawave_server:
logger.error("❌ Не найден Remnawave сервер для продления")
else:
remna = RemnawaveAPI(remnawave_server["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if logged_in:
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
updated = await remna.update_user(
uuid=client_id,
expire_at=expire_iso,
active_user_inbounds=remnawave_inbound_ids,
traffic_limit_bytes=total_gb,
)
if updated:
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
await remna.reset_user_traffic(client_id)
else:
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}")
else:
logger.error("Не удалось войти в Remnawave API")
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
if panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
tasks.append(
extend_client_key(
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
)
)
elif panel_type != "remnawave":
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
@@ -173,31 +355,42 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
panel_type = server_info.get("panel_type", "3x-ui").lower()
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.error("Не удалось войти в Remnawave API")
continue
success = await remna.delete_user(client_id)
if success:
logger.info(f"Клиент Remnawave {client_id} успешно удалён")
else:
logger.warning(f"Не удалось удалить клиента Remnawave {client_id}")
continue
tasks.append(
delete_client(
elif panel_type == "3x-ui":
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
continue
await delete_client(
xui,
int(inbound_id),
email,
client_id,
)
)
await asyncio.gather(*tasks)
else:
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_info.get('server_name')}")
except Exception as e:
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
@@ -206,7 +399,10 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id):
"""
Обновляет ключ на всех серверах указанного кластера (или сервера, если передано имя).
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
Работает с панелями 3x-ui и Remnawave. Использует новый client_id от Remnawave для всех серверов (если пересоздан).
Если SUPERNODE активен — email делается уникальным на каждый сервер.
Возвращает кортеж: (новый client_id, remnawave ссылка или None).
"""
try:
servers = await get_servers()
@@ -223,49 +419,88 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
remnawave_client_id = None
remnawave_key = None
if remnawave_servers:
inbound_ids = [s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")]
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if logged_in:
await remna.delete_user(client_id)
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_iso,
"telegramId": tg_id,
"activeUserInbounds": inbound_ids,
}
result = await remna.create_user(user_data)
if result:
remnawave_client_id = result.get("uuid")
remnawave_key = result.get("subscriptionUrl")
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
else:
logger.error("[Update] Ошибка создания Remnawave клиента")
else:
logger.error("[Update] Не удалось авторизоваться в Remnawave")
if not remnawave_client_id:
logger.warning(f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}")
remnawave_client_id = client_id
for server_info in xui_servers:
server_name = server_info.get("server_name", "unknown")
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
tasks.append(
add_client(
xui,
ClientConfig(
client_id=client_id,
email=email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=email,
),
)
xui = await get_xui_instance(server_info["api_url"])
if SUPERNODE:
sub_id = email
unique_email = f"{email}_{server_name.lower()}"
else:
sub_id = email
unique_email = email
config = ClientConfig(
client_id=remnawave_client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
expiry_time=expiry_time,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
)
await asyncio.gather(*tasks)
tasks.append(add_client(xui, config))
logger.info(f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}")
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}")
return remnawave_client_id, remnawave_key
except Exception as e:
logger.error(f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}")
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
raise e
async def update_subscription(tg_id: int, email: str, session: Any) -> None:
async def update_subscription(tg_id: int, email: str, session: Any, cluster_override: str = None) -> None:
record = await session.fetchrow(
"""
SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id
@@ -281,107 +516,128 @@ async def update_subscription(tg_id: int, email: str, session: Any) -> None:
expiry_time = record["expiry_time"]
client_id = record["client_id"]
old_cluster_id = record["server_id"]
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
await delete_key_from_cluster(old_cluster_id, email, client_id)
await session.execute(
"""
DELETE FROM keys
WHERE tg_id = $1 AND email = $2
""",
"DELETE FROM keys WHERE tg_id = $1 AND email = $2",
tg_id,
email,
)
new_cluster_id = cluster_override or await get_least_loaded_cluster()
least_loaded_cluster_id = await get_least_loaded_cluster()
new_client_id, remnawave_key = await update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id)
await asyncio.gather(
update_key_on_cluster(
tg_id,
client_id,
email,
expiry_time,
least_loaded_cluster_id,
)
)
servers = await get_servers()
cluster_servers = servers.get(new_cluster_id, [])
has_xui = any(s.get("panel_type", "").lower() == "3x-ui" for s in cluster_servers)
final_key_link = public_link if has_xui else None
await store_key(
tg_id,
client_id,
new_client_id,
email,
expiry_time,
public_link,
server_id=least_loaded_cluster_id,
key=final_key_link,
remnawave_link=remnawave_key,
server_id=new_cluster_id,
session=session,
)
async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, Any]:
"""
Получает трафик пользователя на всех серверах, где у него есть ключ.
Args:
session (Any): Сессия базы данных.
tg_id (int): ID пользователя Telegram.
email (str): Email пользователя.
Returns:
dict[str, Any]: Структура с данными о трафике.
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
"""
query = "SELECT client_id, server_id FROM keys WHERE tg_id = $1 AND email = $2"
rows = await session.fetch(query, tg_id, email)
if not rows:
return {"status": "error", "message": "У пользователя нет активных ключей."}
return {"status": "error", "message": "У пользователя нет активных ключей."}
server_ids = {row["server_id"] for row in rows}
query_servers = """
SELECT server_name, api_url FROM servers
SELECT server_name, cluster_name, api_url, panel_type
FROM servers
WHERE server_name = ANY($1) OR cluster_name = ANY($1)
"""
server_rows = await session.fetch(query_servers, list(server_ids))
if not server_rows:
logger.error(f"Не найдено серверов для: {server_ids}")
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
logger.error(f"Не найдено серверов для: {server_ids}")
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
servers_map = {row["server_name"]: row["api_url"] for row in server_rows}
servers_map = {row["server_name"]: row for row in server_rows}
user_traffic_data = {}
async def fetch_traffic(api_url: str, client_id: str, server: str) -> tuple[str, Any]:
"""
Получает трафик с сервера для заданного client_id.
Возвращает кортеж: (server, used_gb) или (server, ошибка).
"""
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
try:
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server, round(used_gb, 2)
else:
return server, "Ошибка получения трафика"
except Exception as e:
return server, f"Ошибка: {e}"
tasks = []
remnawave_client_id = None
remnawave_checked = False
async def fetch_traffic(server_info: dict, client_id: str) -> tuple[str, Any]:
server_name = server_info["server_name"]
api_url = server_info["api_url"]
panel_type = server_info.get("panel_type", "3x-ui").lower()
try:
if panel_type == "3x-ui":
xui = await get_xui_instance(api_url)
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server_name, round(used_gb, 2)
else:
return server_name, "Ошибка получения трафика"
else:
return server_name, f"Неизвестная панель: {panel_type}"
except Exception as e:
return server_name, f"Ошибка: {e}"
for row in rows:
client_id = row["client_id"]
server_id = row["server_id"]
if server_id in servers_map:
api_url = servers_map[server_id]
tasks.append(fetch_traffic(api_url, client_id, server_id))
else:
for server, api_url in servers_map.items():
tasks.append(fetch_traffic(api_url, client_id, server))
results = await asyncio.gather(*tasks)
matched_servers = [
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
]
for server_info in matched_servers:
panel_type = server_info.get("panel_type", "3x-ui").lower()
if panel_type == "remnawave" and not remnawave_checked:
remnawave_client_id = client_id
remnawave_api_url = server_info["api_url"]
remnawave_checked = True
elif panel_type == "3x-ui":
tasks.append(fetch_traffic(server_info, client_id))
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in results:
user_traffic_data[server] = result
if remnawave_client_id:
try:
remna = RemnawaveAPI(remnawave_api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
user_traffic_data["Remnawave (общий)"] = "Не удалось авторизоваться"
else:
user_data = await remna.get_user_by_uuid(remnawave_client_id)
if not user_data:
user_traffic_data["Remnawave (общий)"] = "Клиент не найден"
else:
used_bytes = user_data.get("usedTrafficBytes", 0)
used_gb = round(used_bytes / 1073741824, 2)
user_traffic_data["Remnawave (общий)"] = used_gb
except Exception as e:
user_traffic_data["Remnawave (общий)"] = f"Ошибка: {e}"
return {"status": "success", "traffic": user_traffic_data}
@@ -418,11 +674,7 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
xui = await get_xui_instance(server_info["api_url"])
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
@@ -457,3 +709,84 @@ async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str,
except Exception as e:
logger.error(f"Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
return {"status": "error", "error": str(e)}
async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
"""
Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
Работает с 3x-ui и Remnawave.
Args:
cluster_id (str): ID кластера или имя сервера
email (str): Email клиента (будет преобразован в уникальный для SUPERNODE)
"""
try:
servers = await get_servers()
cluster = servers.get(cluster_id)
if not cluster:
found_servers = []
for _, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
remnawave_done = False
for server_info in cluster:
panel_type = server_info.get("panel_type", "3x-ui").lower()
server_name = server_info.get("server_name", "unknown")
api_url = server_info.get("api_url")
inbound_id = server_info.get("inbound_id")
if panel_type == "remnawave" and not remnawave_done:
conn = await asyncpg.connect(DATABASE_URL)
try:
row = await conn.fetchrow(
"SELECT client_id FROM keys WHERE email = $1 AND server_id = $2 LIMIT 1",
email,
cluster_id,
)
finally:
await conn.close()
if not row:
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
continue
client_id = row["client_id"]
remna = RemnawaveAPI(api_url)
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
if not logged_in:
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
continue
tasks.append(remna.reset_user_traffic(client_id))
remnawave_done = True
continue
if panel_type == "3x-ui":
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = await get_xui_instance(api_url)
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
else:
logger.warning(f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}")
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
except Exception as e:
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+320
View File
@@ -0,0 +1,320 @@
import html
import os
import re
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
QRCODE,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
)
from database import (
get_key_details,
get_keys,
)
from handlers.buttons import (
ADD_SUB,
ALIAS,
BACK,
CHANGE_LOCATION,
CONNECT_DEVICE,
CONNECT_PHONE,
DELETE,
FREEZE,
MAIN_MENU,
PC_BUTTON,
QR,
RENEW,
RENEW_FULL,
TV_BUTTON,
UNFREEZE,
)
from handlers.texts import (
FROZEN_SUBSCRIPTION_MSG,
NO_SUBSCRIPTIONS_MSG,
key_message,
)
from handlers.utils import (
edit_or_send_message,
format_days,
format_hours,
format_minutes,
get_russian_month,
handle_error,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
class RenameKeyState(StatesGroup):
waiting_for_new_alias = State()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
try:
records = await get_keys(target_message.chat.id, session)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
for record in records:
alias = record.get("alias")
email = record["email"]
client_id = record["client_id"]
expiry_time = record.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
else:
response_message = NO_SUBSCRIPTIONS_MSG
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("rename_key|"))
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
client_id = callback.data.split("|")[1]
await state.set_state(RenameKeyState.waiting_for_new_alias)
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
await edit_or_send_message(
target_message=callback.message,
text="✏️ Введите новое имя подписки (до 10 символов):",
reply_markup=builder.as_markup(),
)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
alias = message.text.strip()
if len(alias) > 10:
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
await message.answer(
"❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод."
)
return
data = await state.get_data()
client_id = data.get("client_id")
try:
await session.execute(
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
alias,
message.chat.id,
client_id,
)
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
finally:
await state.clear()
await process_callback_or_message_view_keys(message, session)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
return
is_frozen = record["is_frozen"]
builder = InlineKeyboardBuilder()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
if is_frozen:
builder.row(
InlineKeyboardButton(
text=UNFREEZE,
callback_data=f"unfreeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=FROZEN_SUBSCRIPTION_MSG,
reply_markup=builder.as_markup(),
media_path=image_path,
)
return
key = record.get("key")
remnawave_link = record.get("remnawave_link")
final_link = key or remnawave_link
expiry_time = record["expiry_time"]
server_name = record["server_id"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
time_left = expiry_date - datetime.utcnow()
if time_left.total_seconds() <= 0:
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла"
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = (
f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
)
formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
response_message = key_message(
final_link,
formatted_expiry_date,
days_left_message,
server_name,
server_name if USE_COUNTRY_SELECTION else None,
)
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
if is_full_remnawave and final_link:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=final_link),
)
)
else:
if CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(
text=CONNECT_PHONE,
callback_data=f"connect_phone|{key_name}",
)
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
if QRCODE:
builder.row(
InlineKeyboardButton(
text=QR,
callback_data=f"show_qr|{key_name}",
)
)
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
)
else:
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
if USE_COUNTRY_SELECTION:
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
if TOGGLE_CLIENT:
builder.row(
InlineKeyboardButton(
text=FREEZE,
callback_data=f"freeze_subscription|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
)
+24 -458
View File
@@ -1,284 +1,49 @@
import asyncio
import locale
import os
from datetime import datetime, timedelta
from typing import Any
import asyncpg
import pytz
from aiogram import F, Router, types
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from bot import bot
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
CONNECT_PHONE_BUTTON,
DATABASE_URL,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
PUBLIC_LINK,
RENEWAL_PLANS,
TOTAL_GB,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
check_server_name_by_cluster,
create_temporary_data,
delete_key,
get_balance,
get_key_by_server,
get_key_details,
get_keys,
get_servers,
update_balance,
update_key_expiry,
)
from handlers.buttons.add_subscribe import (
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
PC_BUTTON,
TV_BUTTON,
from handlers.buttons import (
APPLY,
BACK,
CANCEL,
)
from handlers.keys.key_utils import (
delete_key_from_cluster,
renew_key_in_cluster,
update_subscription,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.keys.key_view import process_callback_view_key
from handlers.texts import (
DISCOUNTS,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
SUBSCRIPTION_DESCRIPTION,
SUCCESS_RENEWAL_MSG,
key_message,
DELETE_KEY_CONFIRM_MSG,
KEY_DELETED_MSG_SIMPLE,
)
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
router = Router()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
try:
records = await get_keys(target_message.chat.id, session)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
for record in records:
key_name = record["email"]
expiry_time = record.get("expiry_time")
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
formatted_date_short = expiry_date_full.strftime("до %d.%m.%y")
else:
formatted_date_full = "без срока действия"
formatted_date_short = "без срока действия"
button_text = f"🔑{key_name} ({formatted_date_short})"
builder.row(InlineKeyboardButton(text=button_text, callback_data=f"view_key|{key_name}"))
response_message += f"• <b>{key_name}</b> ({formatted_date_full})\n"
response_message += "</blockquote>\n"
else:
response_message = (
"<b>🔑 У вас пока нет подписок.</b>\n\nВы можете создать новую подписку для подключения устройств."
)
builder.row(InlineKeyboardButton(text=" Добавить подписку", callback_data="create_key"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
key = record["key"]
expiry_time = record["expiry_time"]
server_name = record["server_id"]
country = server_name
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0\nОсталось минут: 0"
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = f"Осталось: <b>{days}</b> дней, <b>{hours}</b> часов, <b>{minutes}</b> минут"
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
response_message = key_message(
key, formatted_expiry_date, days_left_message, server_name, country if USE_COUNTRY_SELECTION else None
)
builder = InlineKeyboardBuilder()
if not key.startswith(PUBLIC_LINK) or ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
if CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}")
)
else:
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key}"),
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text="⏳ Продлить", callback_data=f"renew_key|{key_name}"),
InlineKeyboardButton(text="❌ Удалить", callback_data=f"delete_key|{key_name}"),
)
else:
builder.row(InlineKeyboardButton(text="⏳ Продлить подписку", callback_data=f"renew_key|{key_name}"))
if USE_COUNTRY_SELECTION:
builder.row(
InlineKeyboardButton(text="🌍 Сменить локацию", callback_data=f"change_location|{key_name}")
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
keyboard = builder.as_markup()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=keyboard,
media_path=image_path,
)
else:
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
)
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery):
email = callback_query.data.split("|")[1]
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
key_data = await conn.fetchrow(
"""
SELECT key FROM keys WHERE email = $1
""",
email,
)
if not key_data:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = key_data["key"]
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
finally:
if conn:
await conn.close()
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
builder.row(InlineKeyboardButton(text="📖 Ручная установка", callback_data="instructions"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
)
@router.callback_query(F.data.startswith("update_subscription|"))
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
email = callback_query.data.split("|")[1]
try:
try:
await callback_query.message.delete()
except TelegramBadRequest as e:
if "message can't be deleted" not in str(e):
raise
await update_subscription(tg_id, email, session)
await process_callback_view_key(callback_query, session)
except Exception as e:
@@ -294,74 +59,25 @@ async def process_callback_delete_key(callback_query: CallbackQuery):
inline_keyboard=[
[
types.InlineKeyboardButton(
text="✅ Да, удалить",
text=APPLY,
callback_data=f"confirm_delete|{client_id}",
)
],
[types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="view_keys")],
[types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
]
)
if callback_query.message.caption:
await callback_query.message.edit_caption(
caption="<b>Вы уверены, что хотите удалить ключ?</b>", reply_markup=confirmation_keyboard
caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
)
else:
await callback_query.message.edit_text(
text="<b>Вы уверены, что хотите удалить ключ?</b>", 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}")
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(key_name, session)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
for plan_id, plan_details in RENEWAL_PLANS.items():
months = plan_details["months"]
price = plan_details["price"]
discount = DISCOUNTS.get(plan_id, 0)
button_text = f"📅 {months} месяц{'а' if months > 1 else ''} ({price} руб.)" + (
f" {discount}% скидка" if discount > 0 else ""
)
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{months}|{client_id}",
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
balance = await get_balance(tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await callback_query.message.answer("<b>Ключ не найден.</b>")
except Exception as e:
logger.error(e)
@router.callback_query(F.data.startswith("confirm_delete|"))
async def process_callback_confirm_delete(callback_query: CallbackQuery, session: Any):
email = callback_query.data.split("|")[1]
@@ -369,8 +85,8 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
record = await get_key_details(email, session)
if record:
client_id = record["client_id"]
response_message = "Ключ успешно удален."
back_button = types.InlineKeyboardButton(text="Назад", callback_data="view_keys")
response_message = KEY_DELETED_MSG_SIMPLE
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await delete_key(client_id, session)
@@ -386,7 +102,7 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
tasks = []
for cluster_id, _cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
await asyncio.gather(*tasks)
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
@@ -395,160 +111,10 @@ async def process_callback_confirm_delete(callback_query: CallbackQuery, session
await delete_key(client_id, session)
else:
response_message = "Ключ не найден или уже удален."
back_button = types.InlineKeyboardButton(text="Назад", 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
)
except Exception as e:
logger.error(e)
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
days_to_extend = 30 * int(plan)
gb_multiplier = {"1": 1, "3": 3, "6": 6, "12": 12}
total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0
try:
record = await get_key_by_server(tg_id, client_id, session)
if record:
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
cost = RENEWAL_PLANS[plan]["price"]
balance = await get_balance(tg_id)
if balance < cost:
required_amount = cost - balance
logger.info(
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
)
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"plan": plan,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
await handle_custom_amount_input(callback_query, session)
else:
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=f"💳 Недостаточно средств. Пополните баланс на {required_amount}₽.",
reply_markup=builder.as_markup(),
media_path=None,
)
return
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
else:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
logger.info(
f"[RENEW] Начинаю процесс продления ключа с параметрами: "
f"tg_id={tg_id}, client_id={client_id}, email={email}, "
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}, cost={cost}, "
f"callback_query={'есть' if callback_query else 'отсутствует'}, plan={plan}"
)
response_message = SUCCESS_RENEWAL_MSG.format(months=plan)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка редактирования сообщения в complete_key_renewal: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
conn = await asyncpg.connect(DATABASE_URL)
logger.info(f"[RENEW] Получение данных о ключе для email: {email}")
key_info = await get_key_details(email, conn)
if not key_info:
logger.error(f"[RENEW] Ключ с client_id {client_id} для пользователя {tg_id} не найден.")
await conn.close()
return
server_id = key_info["server_id"]
if USE_COUNTRY_SELECTION:
logger.info(f"[RENEW] USE_COUNTRY_SELECTION включён. Проверяю информацию о сервере {server_id}")
cluster_info = await check_server_name_by_cluster(server_id, conn)
if not cluster_info:
logger.error(f"[RENEW] Сервер {server_id} не найден в таблице servers.")
await conn.close()
return
cluster_id = cluster_info["cluster_name"]
logger.info(f"[RENEW] Информация о сервере получена: {cluster_info}. Использую cluster_id: {cluster_id}")
else:
cluster_id = server_id
logger.info(f"[RENEW] USE_COUNTRY_SELECTION выключен. Использую server_id в качестве cluster_id: {cluster_id}")
logger.info(f"[RENEW] Запуск продления ключа для пользователя {tg_id} на {plan} мес. в кластере {cluster_id}.")
async def renew_key_on_cluster():
logger.info(
f"[RENEW] Запуск renew_key_on_cluster с параметрами: "
f"cluster_id={cluster_id}, email={email}, client_id={client_id}, "
f"new_expiry_time={new_expiry_time}, total_gb={total_gb}"
)
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
logger.info("[RENEW] Продление ключа на сервере завершено. Обновляю срок действия в базе данных.")
await update_key_expiry(client_id, new_expiry_time, conn)
logger.info("[RENEW] Срок действия ключа обновлён. Обновляю баланс пользователя.")
await update_balance(tg_id, -cost, conn)
logger.info(f"[RENEW] Ключ {client_id} успешно продлён на {plan} мес. для пользователя {tg_id}.")
logger.info("[RENEW] Инициализация процесса продления ключа в кластере.")
await renew_key_on_cluster()
logger.info("[RENEW] Процесс продления ключа завершён. Закрываю соединение с базой данных.")
await conn.close()
+39 -189
View File
@@ -4,23 +4,20 @@ import random
import re
import time
import urllib.parse
from datetime import datetime
import aiohttp
import asyncpg
import pytz
from aiohttp import web
from config import (
DATABASE_URL,
PROJECT_NAME,
SUB_MESSAGE,
SUPERNODE,
SUPPORT_CHAT_URL,
TOTAL_GB,
TRANSITION_DATE_STR,
USE_COUNTRY_SELECTION,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
from database import get_key_details, get_servers
from handlers.utils import convert_to_bytes
@@ -28,148 +25,67 @@ from logger import logger
async def fetch_url_content(url: str, identifier: str) -> list[str]:
"""
Получает содержимое подписки по URL и декодирует его.
Args:
url: URL для получения содержимого
identifier: Идентификатор пользователя (tg_id или email)
Returns:
Список строк из подписки
"""
try:
logger.info(f"Получение URL: {url} для идентификатора: {identifier}")
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, ssl=False) as response:
if response.status == 200:
content = await response.text()
logger.info(f"Успешно получен контент с {url} для идентификатора: {identifier}")
return base64.b64decode(content).decode("utf-8").split("\n")
else:
logger.error(
f"Не удалось получить {url} для идентификатора: {identifier}, статус: {response.status}"
)
return []
except TimeoutError:
logger.error(f"Таймаут при получении {url} для идентификатора: {identifier}")
return []
except Exception as e:
logger.error(f"Ошибка при получении {url} для идентификатора: {identifier}: {e}")
return []
except Exception:
return []
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]:
"""
Объединяет строки подписки из нескольких URL, удаляя дубликаты.
Args:
urls: Список URL для получения подписок
identifier: Идентификатор пользователя (tg_id или email)
query_string: Строка запроса для добавления к URL
Returns:
Список уникальных строк из всех подписок
"""
if SUPERNODE:
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
if not urls:
return []
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
return await fetch_url_content(url_with_query, identifier)
logger.info(f"Начинаем объединение подписок для идентификатора: {identifier}, запрос: {query_string}")
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
logger.info(f"Составлены URL-адреса: {urls_with_query}")
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
results = await asyncio.gather(*tasks)
results = await asyncio.gather(*tasks, return_exceptions=True)
all_lines = set()
for lines in results:
all_lines.update(filter(None, lines))
logger.info(
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для идентификатора: {identifier}"
)
return list(all_lines)
async def get_subscription_urls(server_id: str, email: str, conn) -> list[str]:
"""
Получает список URL-адресов для подписки в зависимости от режима выбора страны.
Args:
server_id: Идентификатор сервера или кластера
email: Email пользователя
conn: Соединение с базой данных
Returns:
Список URL-адресов для подписки
"""
async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]:
urls = []
if USE_COUNTRY_SELECTION:
logger.info(f"Режим выбора страны активен. Ищем сервер {server_id} в БД.")
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
if not server_data:
logger.warning(f"Не найден сервер {server_id} в БД!")
return []
subscription_url = server_data["subscription_url"]
urls = [f"{subscription_url}/{email}"]
logger.info(f"Используем подписку {urls[0]}")
return urls
if server_data and server_data["subscription_url"]:
urls.append(f"{server_data['subscription_url']}/{email}")
else:
servers = await get_servers(conn)
cluster_servers = servers.get(server_id, [])
for server in cluster_servers:
if url := server.get("subscription_url"):
urls.append(f"{url}/{email}")
if include_remnawave_key:
urls.append(include_remnawave_key)
servers = await get_servers(conn)
logger.info(f"Режим выбора страны отключен. Используем кластер {server_id}.")
cluster_servers = servers.get(server_id, [])
if not cluster_servers:
logger.warning(f"Не найдены сервера для {server_id}")
return []
urls = [f"{server['subscription_url']}/{email}" for server in cluster_servers]
logger.info(f"Найдено {len(urls)} URL-адресов в кластере {server_id}")
return urls
def get_transition_timestamp() -> int:
"""
Получает временную метку перехода с учетом часового пояса Москвы.
Returns:
Временная метка перехода в миллисекундах
"""
moscow_tz = pytz.timezone("Europe/Moscow")
transition_date_naive = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S")
transition_date = moscow_tz.localize(transition_date_naive)
transition_timestamp_ms = int(transition_date.timestamp() * 1000)
return transition_timestamp_ms
def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None) -> str:
"""
Рассчитывает информацию о трафике на основе подписок.
Args:
cleaned_subscriptions: Список строк подписки
expiry_time_ms: Время истечения подписки в миллисекундах
Returns:
Строка с информацией о трафике
"""
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)
@@ -178,89 +94,50 @@ def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | No
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}"
def clean_subscription_line(line: str) -> str:
"""
Очищает строку подписки, оставляя только нужную информацию.
Args:
line: Исходная строка подписки
Returns:
Очищенная строка подписки
"""
if "#" not in line:
return line
try:
base, meta = line.split("#", 1)
except ValueError:
return line
parts = meta.split("-")
country = parts[0].strip() if parts else ""
traffic = ""
for part in parts[1:]:
part_decoded = urllib.parse.unquote(part).strip()
if re.search(r"\d+(?:[.,]\d+)?\s*(?:GB|MB|KB|TB)", part_decoded, re.IGNORECASE):
traffic = part_decoded
break
meta_clean = f"{country} - {traffic}" if traffic else country
return base + "#" + meta_clean
def format_time_left(expiry_time_ms: int | None) -> str:
"""
Форматирует оставшееся время подписки.
Args:
expiry_time_ms: Время истечения подписки в миллисекундах
Returns:
Отформатированная строка с оставшимся временем
"""
if not expiry_time_ms:
return "N/A"
now_ms = int(time.time() * 1000)
remaining_sec = max((expiry_time_ms - now_ms) / 1000, 0)
days = int(remaining_sec // 86400)
hours = int((remaining_sec % 86400) // 3600)
return f"{days}D,{hours}H ⏳" if days else f"{hours}H ⏳"
def prepare_headers(
user_agent: str, project_name: str, subscription_info: str, subscription_userinfo: str
) -> dict[str, str]:
"""
Подготавливает заголовки ответа в зависимости от User-Agent клиента.
Args:
user_agent: User-Agent клиента
project_name: Название проекта
subscription_info: Информация о подписке
subscription_userinfo: Информация о трафике
Returns:
Словарь с заголовками ответа
"""
if "Happ" in user_agent:
encoded_project_name = f"{project_name}"
announce_str = f"↖️Бот | {subscription_info} | Поддержка↗️"
@@ -275,15 +152,25 @@ def prepare_headers(
"subscription-userinfo": subscription_userinfo,
}
elif "Hiddify" in user_agent:
parts = subscription_info.split(' - ')[0].split(': ')
parts = subscription_info.split(" - ")[0].split(": ")
key_info = parts[1] if len(parts) > 1 else parts[0]
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
return {
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
elif "v2raytun" in user_agent:
encoded_project_name = f"{project_name}\n{subscription_info}"
announce_str = "🔑 Выберите сервер ⬇️ | 💬 Поддержка ➡️"
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"update-always": "true",
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce-url": f"{SUPPORT_CHAT_URL}",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
}
else:
encoded_project_name = f"{project_name}\n{subscription_info}"
return {
@@ -294,62 +181,36 @@ def prepare_headers(
}
async def handle_subscription(request: web.Request, old_subscription: bool = False) -> web.Response:
"""
Обрабатывает запрос на подписку (старую или новую).
Args:
request: Объект запроса
old_subscription: Флаг, указывающий на тип подписки (старая или новая)
Returns:
Ответ с подпиской или сообщением об ошибке
"""
async def handle_subscription(request: web.Request) -> web.Response:
email = request.match_info.get("email")
tg_id = request.match_info.get("tg_id") if not old_subscription else None
tg_id = request.match_info.get("tg_id")
if not email or (not old_subscription and not tg_id):
logger.warning("Получен запрос с отсутствующими параметрами")
if not email or not tg_id:
return web.Response(text="❌ Неверные параметры запроса.", status=400)
logger.info(
f"Обработка запроса для {'старого' if old_subscription else 'нового'} клиента: email={email}, tg_id={tg_id}"
)
conn = await asyncpg.connect(DATABASE_URL)
try:
client_data = await get_key_details(email, conn)
if not client_data:
logger.warning(f"Клиент с email {email} не найден в базе.")
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
stored_tg_id = client_data.get("tg_id")
server_id = client_data["server_id"]
if not old_subscription and str(tg_id) != str(stored_tg_id):
logger.warning(f"Неверный tg_id для клиента с email {email}.")
if int(tg_id) != int(stored_tg_id):
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
if old_subscription:
created_at_ms = client_data["created_at"]
created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000)
logger.info(f"created_at для {email}: {created_at_datetime}, server_id: {server_id}")
transition_timestamp_ms = get_transition_timestamp()
logger.info(f"Время перехода (с учетом часового пояса Москвы): {transition_timestamp_ms}")
if created_at_ms >= transition_timestamp_ms:
logger.info(f"Клиент с email {email} является новым.")
return web.Response(text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.", status=400)
expiry_time_ms = client_data.get("expiry_time")
time_left = format_time_left(expiry_time_ms)
urls = await get_subscription_urls(server_id, email, conn)
urls = await get_subscription_urls(
server_id, email, conn, include_remnawave_key=client_data.get("remnawave_link")
)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
query_string = request.query_string if not old_subscription else ""
query_string = request.query_string
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string)
random.shuffle(combined_subscriptions)
@@ -362,17 +223,6 @@ async def handle_subscription(request: web.Request, old_subscription: bool = Fal
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
logger.info(f"Возвращаем объединенные подписки для email: {email}")
return web.Response(text=base64_encoded, headers=headers)
finally:
await conn.close()
async def handle_old_subscription(request: web.Request) -> web.Response:
"""Обработка запроса для старых клиентов."""
return await handle_subscription(request, old_subscription=True)
async def handle_new_subscription(request: web.Request) -> web.Response:
"""Обработка запроса для новых клиентов."""
return await handle_subscription(request, old_subscription=False)
+1
View File
@@ -5,6 +5,7 @@ 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)
+175 -122
View File
@@ -1,8 +1,10 @@
import asyncio
from datetime import datetime, timedelta
import asyncpg
import pytz
from aiogram import Bot, Router
from config import (
@@ -10,6 +12,7 @@ from config import (
NOTIFICATION_TIME,
NOTIFY_DELETE_DELAY,
NOTIFY_DELETE_KEY,
NOTIFY_INACTIVE_TRAFFIC,
NOTIFY_MAXPRICE,
NOTIFY_RENEW,
NOTIFY_RENEW_EXPIRED,
@@ -20,7 +23,9 @@ from config import (
from database import (
add_notification,
check_notification_time,
check_notifications_bulk,
delete_key,
delete_notification,
get_all_keys,
get_balance,
get_last_notification_time,
@@ -28,68 +33,92 @@ from database import (
update_key_expiry,
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED
from keyboards.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_NO_DELAY_MSG,
KEY_EXPIRY_10H,
KEY_EXPIRY_24H,
KEY_RENEWED,
KEY_RENEWED_TEMP_MSG,
)
from handlers.utils import format_hours, format_minutes
from logger import logger
from .notify_utils import send_notification
from .notify_utils import send_messages_with_limit, send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
notification_lock = asyncio.Lock()
async def periodic_notifications(bot: Bot):
"""
Обработчик, который:
1. Получает список всех ключей.
2. Отправляет уведомления пользователям о неактивном пробном периоде (если триал включен).
3. Отправляет уведомления об истекающих ключах (10h и 24h).
4. Проверяет истекшие ключи.
5. Проверяет пользователей с нулевым трафиком.
Периодическая проверка и отправка уведомлений.
Защищена от одновременного запуска с помощью asyncio.Lock.
"""
while True:
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
logger.info("Начало обработки уведомлений.")
if notification_lock.locked():
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
await asyncio.sleep(NOTIFICATION_TIME)
continue
async with notification_lock:
conn = None
try:
keys = await get_all_keys(session=conn)
except Exception as e:
logger.error(f"Ошибка при получении ключей: {e}")
keys = []
conn = await asyncpg.connect(DATABASE_URL)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
if not TRIAL_TIME_DISABLE:
await notify_inactive_trial_users(bot, conn)
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
logger.info("Запуск обработки уведомлений")
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:
await notify_inactive_trial_users(bot, conn)
await asyncio.sleep(0.5)
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)
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)
await notify_users_no_traffic(bot, conn, current_time, keys)
await asyncio.sleep(0.5)
logger.info("Завершена обработка уведомлений")
except Exception as e:
logger.error(f"Ошибка в periodic_notifications: {e}")
finally:
if conn:
await conn.close()
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):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
"""
logger.info("Начало проверки подписок, истекающих через 24 часа.")
expiring_keys = [
@@ -97,24 +126,29 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.get("email", "") for key in expiring_keys]
users = await check_notifications_bulk("key_24h", 24, conn, tg_ids=tg_ids, emails=emails)
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
expiry_timestamp = key.get("expiry_time")
notification_id = f"{email}_key_24h"
try:
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
except Exception as e:
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
continue
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_timestamp = key.get("expiry_time")
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
days_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -122,7 +156,7 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_24H.format(
email=email,
days_left_message=days_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
@@ -130,14 +164,19 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
else:
keyboard = build_notification_kb(email)
try:
await send_notification(bot, tg_id, "notify_24h.jpg", notification_text, keyboard)
logger.info(f"Отправлено уведомление об истечении подписки через 24 часа для пользователя {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_24h.jpg",
"keyboard": keyboard,
})
await add_notification(tg_id, notification_id, session=conn)
logger.info("✅ Обработка всех уведомлений за 24 часа завершена.")
if messages:
await send_messages_with_limit(bot, messages)
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 24 часа.")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
await asyncio.sleep(1)
@@ -152,24 +191,29 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.get("email", "") for key in expiring_keys]
users = await check_notifications_bulk("key_10h", 10, conn, tg_ids=tg_ids, emails=emails)
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
expiry_timestamp = key.get("expiry_time")
notification_id = f"{email}_key_10h"
try:
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
except Exception as e:
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
continue
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_timestamp = key.get("expiry_time")
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -177,7 +221,7 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_10H.format(
email=email,
hours_left_message=hours_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
@@ -190,14 +234,19 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
else:
keyboard = build_notification_kb(email)
try:
await send_notification(bot, tg_id, "notify_10h.jpg", notification_text, keyboard)
logger.info(f"Отправлено уведомление об истечении подписки через 10 часов для пользователя {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_10h.jpg",
"keyboard": keyboard,
})
await add_notification(tg_id, notification_id, session=conn)
logger.info("✅ Обработка всех уведомлений за 10 часов завершена.")
if messages:
await send_messages_with_limit(bot, messages)
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 10 часов.")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
await asyncio.sleep(1)
@@ -210,6 +259,12 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
tg_ids = [key["tg_id"] for key in expired_keys]
emails = [key.get("email", "") for key in expired_keys]
users = await check_notifications_bulk("key_expired", 0, conn, tg_ids=tg_ids, emails=emails)
messages = []
for key in expired_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
@@ -217,11 +272,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
server_id = key.get("server_id")
notification_id = f"{email}_key_expired"
try:
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Ошибка получения времени последнего уведомления для пользователя {tg_id}: {e}")
continue
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
if NOTIFY_RENEW_EXPIRED:
try:
@@ -236,7 +287,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
if balance >= renewal_cost:
try:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_expired.jpg", "Ваш ключ продлён!"
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
)
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
@@ -260,25 +311,17 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
keyboard = build_notification_expired_kb()
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
(
f"Ваша подписка {email} была удалена, так как вы не продлили её действие.\n\n"
"Перейдите в личный кабинет и получите новую!"
),
keyboard,
)
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
except Exception as e:
logger.error(f"Не удалось отправить уведомление об удалении пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": KEY_DELETED_MSG.format(email=email),
"photo": "notify_expired.jpg",
"keyboard": keyboard,
})
except Exception as e:
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
continue
if last_notification_time is None:
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
keyboard = build_notification_kb(email)
if NOTIFY_DELETE_DELAY > 0:
@@ -287,31 +330,33 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
if hours > 0:
if minutes > 0:
delay_message = f"⚠️ Ваша подписка {email} истекла.\n\nЕсли вы не продлите её, она будет удалена через {hours} час{'а' if hours == 1 else 'ов'} и {minutes} минут."
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
)
else:
delay_message = f"⚠️ Ваша подписка {email} истекла.\n\nЕсли вы не продлите её, она будет удалена через {hours} час{'а' if hours == 1 else 'ов'}."
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
email=email, hours_formatted=format_hours(hours)
)
else:
delay_message = f"⚠️ Ваша подписка {email} истекла.\n\nЕсли вы не продлите её, она будет удалена через {NOTIFY_DELETE_DELAY} минут."
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
email=email, minutes_formatted=format_minutes(minutes)
)
else:
delay_message = f"⚠ Ваша подписка {email} истекла!\n\nПродлите доступ, чтобы возобновить услуги."
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
delay_message,
keyboard,
)
await add_notification(tg_id, notification_id, session=conn)
logger.info(
f"📢 Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}."
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление о продлении подписки пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": delay_message,
"photo": "notify_expired.jpg",
"keyboard": keyboard,
})
await add_notification(tg_id, notification_id, session=conn)
logger.info("✅ Обработка истекших ключей завершена.")
if messages:
await send_messages_with_limit(bot, messages)
logger.info(f"Отправлено {len(messages)} уведомлений об истекших ключах.")
logger.info("Обработка истекших ключей завершена.")
await asyncio.sleep(1)
@@ -335,7 +380,6 @@ async def process_auto_renew_or_notify(
return
balance = await get_balance(tg_id)
except Exception as e:
logger.error(f"Ошибка получения данных для пользователя {tg_id}: {e}")
return
@@ -356,17 +400,19 @@ async def process_auto_renew_or_notify(
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
total_gb = int(renewal_period_months * TOTAL_GB * 1024**3)
logger.info(
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
)
try:
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, TOTAL_GB)
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, total_gb)
await update_balance(tg_id, -renewal_cost, session=conn)
await update_key_expiry(client_id, new_expiry_time, conn)
await add_notification(tg_id, renew_notification_id, session=conn)
await delete_notification(tg_id, notification_id, session=conn)
logger.info(
f"✅ Ключ {client_id} продлён на {renewal_period_months} мес. для пользователя {tg_id}. Списано {renewal_cost}."
@@ -377,15 +423,22 @@ async def process_auto_renew_or_notify(
)
keyboard = build_notification_expired_kb()
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
if result:
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
)
except KeyError as e:
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
except Exception as e:
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
else:
keyboard = build_notification_kb(email)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
if result:
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
else:
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
@@ -1,5 +1,7 @@
from aiogram.types import InlineKeyboardMarkup
from handlers.buttons import MAIN_MENU, RENEW_KEY
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
"""
@@ -9,8 +11,8 @@ def build_notification_kb(email: str) -> InlineKeyboardMarkup:
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.button(text="🔄 Продлить VPN", callback_data=f"renew_key|{email}")
builder.button(text="👤 Личный кабинет", callback_data="profile")
builder.button(text=RENEW_KEY, callback_data=f"renew_key|{email}")
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1)
return builder.as_markup()
@@ -23,5 +25,5 @@ def build_notification_expired_kb() -> InlineKeyboardMarkup:
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.button(text="👤 Личный кабинет", callback_data="profile")
builder.button(text=MAIN_MENU, callback_data="profile")
return builder.as_markup()
+95 -35
View File
@@ -1,52 +1,112 @@
import asyncio
import os
import aiofiles
from aiogram import Bot, types
from aiogram.exceptions import TelegramForbiddenError
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from logger import logger
async def send_messages_with_limit(bot: Bot, messages: list[dict], messages_per_second: int = 25):
"""
Отправляет сообщения с ограничением по количеству сообщений в секунду.
"""
batch_size = messages_per_second
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}")
await asyncio.sleep(1.0)
def rate_limited_send(func):
async def wrapper(*args, **kwargs):
while True:
try:
return await func(*args, **kwargs)
except TelegramRetryAfter as e:
retry_in = int(e.retry_after) + 1
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек.")
await asyncio.sleep(retry_in)
except TelegramForbiddenError:
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]
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
return False
return wrapper
async def send_notification(
bot: Bot,
tg_id: int,
image_filename: str,
image_filename: str | None,
caption: str,
keyboard: InlineKeyboardMarkup,
):
keyboard: InlineKeyboardMarkup | None = None,
) -> bool:
"""
Отправляет уведомление с изображением, если файл существует, иначе отправляет текстовое сообщение.
Если возникает TelegramForbiddenError (например, бот заблокирован пользователем),
функция логирует ошибку и прекращает попытки отправки уведомления.
Отправляет уведомление пользователю.
"""
if image_filename is None:
return await _send_text_notification(bot, tg_id, caption, keyboard)
photo_path = os.path.join("img", image_filename)
if os.path.isfile(photo_path):
try:
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)
except TelegramForbiddenError as e:
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
return
except Exception as e:
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
try:
await bot.send_message(tg_id, caption, reply_markup=keyboard)
except TelegramForbiddenError as e:
logger.error(f"Ошибка отправки fallback-сообщения для пользователя {tg_id}: {e}")
return
except Exception as e:
logger.error(f"Неизвестная ошибка при отправке fallback-сообщения для пользователя {tg_id}: {e}")
return
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
else:
logger.error(f"Файл с изображением не найден: {photo_path}")
try:
await bot.send_message(tg_id, caption, reply_markup=keyboard)
except TelegramForbiddenError as e:
logger.error(f"Ошибка отправки сообщения для пользователя {tg_id}: {e}")
return
except Exception as e:
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
return
logger.warning(f"Файл с изображением не найден: {photo_path}")
return await _send_text_notification(bot, tg_id, caption, keyboard)
@rate_limited_send
async def _send_photo_notification(
bot: Bot,
tg_id: int,
photo_path: str,
image_filename: str,
caption: str,
keyboard: InlineKeyboardMarkup | None = None,
) -> bool:
"""Отправляет уведомление с изображением."""
try:
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)
return True
except TelegramForbiddenError:
logger.error(f"Пользователь {tg_id} заблокировал бота")
return False
except Exception as e:
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
return await _send_text_notification(bot, tg_id, caption, keyboard)
@rate_limited_send
async def _send_text_notification(
bot: Bot,
tg_id: int,
caption: str,
keyboard: InlineKeyboardMarkup | None = None,
) -> bool:
"""Отправляет текстовое уведомление."""
try:
await bot.send_message(tg_id, caption, reply_markup=keyboard)
return True
except TelegramForbiddenError:
logger.error(f"Пользователь {tg_id} заблокировал бота")
return False
except Exception as e:
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
return False
+55 -82
View File
@@ -1,8 +1,10 @@
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
@@ -10,11 +12,18 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
from database import (
add_notification,
check_notification_time,
check_notifications_bulk,
create_blocked_user,
)
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import get_user_traffic
from handlers.texts import TRIAL_INACTIVE_BONUS_MSG, TRIAL_INACTIVE_FIRST_MSG, ZERO_TRAFFIC_MSG
from handlers.utils import format_days
from logger import logger
from handlers.utils import format_days
from .notify_utils import send_messages_with_limit, send_notification
router = Router()
@@ -29,88 +38,54 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
"""
logger.info("Проверка пользователей, не активировавших пробный период...")
inactive_trial_users = await conn.fetch(
"""
SELECT tg_id, username, first_name, last_name FROM users
WHERE tg_id IN (
SELECT tg_id FROM connections
WHERE trial IN (0, -1)
)
AND tg_id NOT IN (
SELECT tg_id FROM blocked_users
)
AND tg_id NOT IN (
SELECT DISTINCT tg_id FROM keys
)
"""
)
logger.info(f"👥 Найдено {len(inactive_trial_users)} неактивных пользователей.")
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
for user in inactive_trial_users:
messages = []
for user in users:
tg_id = user["tg_id"]
username = user["username"]
first_name = user["first_name"]
last_name = user["last_name"]
display_name = username or first_name or last_name or "Пользователь"
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
)
)
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
trial_extended = user["last_notification_time"] is not None
if trial_extended:
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
message = TRIAL_INACTIVE_BONUS_MSG.format(
display_name=display_name,
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
total_days_formatted=format_days(total_days),
)
await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
else:
message = TRIAL_INACTIVE_FIRST_MSG.format(
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
)
try:
can_notify = await check_notification_time(tg_id, "inactive_trial", hours=NOTIFY_INACTIVE, session=conn)
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
await add_notification(tg_id, "inactive_trial", session=conn)
if can_notify:
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
)
)
builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
keyboard = builder.as_markup()
trial_extended = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM notifications
WHERE tg_id = $1 AND notification_type = 'inactive_trial'
)
""",
tg_id,
)
if trial_extended:
message = (
f"<b>{display_name}</b>, у нас для тебя подарок! 🎁\n\n"
"<blockquote>"
f"Мы добавили тебе +{NOTIFY_EXTRA_DAYS} дополнительных дня к пробному периоду!\n"
f"Теперь у тебя есть еще шанс протестировать наш VPN целых {NOTIFY_EXTRA_DAYS + TRIAL_TIME} дня!\n"
"</blockquote>"
"Нажми на кнопку ниже, чтобы активировать доступ с бонусом +2 дня! 👇"
)
await conn.execute("UPDATE connections SET trial = -1 WHERE tg_id = $1", tg_id)
else:
message = (
f"👋 <b>Привет, {display_name}!</b>\n\n"
"<blockquote>"
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
"Не упусти возможность попробовать наш VPN прямо сейчас.\n"
"</blockquote>"
"Нажми на кнопку ниже, чтобы активировать пробный доступ! 👇"
)
try:
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
await add_notification(tg_id, "inactive_trial", session=conn)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
await create_blocked_user(tg_id, conn)
except Exception as e:
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
except 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}")
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
await asyncio.sleep(1)
@@ -167,7 +142,7 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
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} ГБ")
@@ -176,22 +151,20 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
message = (
f"⚠ <b>Ваша VPN-подписка {email} активна, но трафик не используется.</b>\n\n"
"<blockquote>Если у вас возникли сложности с подключением, "
"нажмите кнопку ниже, чтобы связаться с поддержкой.</blockquote>\n\n"
"🛠 Мы поможем вам разобраться! 💡"
)
message = ZERO_TRAFFIC_MSG.format(email=email)
try:
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
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)
+85 -18
View File
@@ -1,3 +1,7 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -10,9 +14,23 @@ from config import (
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
)
from database import get_last_payments
from handlers.buttons import (
BALANCE_HISTORY,
COUPON,
CRYPTOBOT,
MAIN_MENU,
PAYMENT,
ROBOKASSA,
STARS,
YOOKASSA,
YOOMONEY,
)
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
from .utils import edit_or_send_message
router = Router()
@@ -23,56 +41,105 @@ async def handle_pay(callback_query: CallbackQuery):
if YOOKASSA_ENABLE:
builder.row(
InlineKeyboardButton(
text="💳 ЮКасса: быстрый перевод",
text=YOOKASSA,
callback_data="pay_yookassa",
)
)
if YOOMONEY_ENABLE:
builder.row(
InlineKeyboardButton(
text="💳 ЮМани: оплата по карте",
text=YOOMONEY,
callback_data="pay_yoomoney",
)
)
if CRYPTO_BOT_ENABLE:
builder.row(
InlineKeyboardButton(
text="💰 CryptoBot: криптовалюта",
text=CRYPTOBOT,
callback_data="pay_cryptobot",
)
)
if STARS_ENABLE:
builder.row(
InlineKeyboardButton(
text="⭐ Оплата Звездами",
text=STARS,
callback_data="pay_stars",
)
)
if ROBOKASSA_ENABLE:
builder.row(
InlineKeyboardButton(
text="⭐ RoboKassa",
text=ROBOKASSA,
callback_data="pay_robokassa",
)
)
builder.row(InlineKeyboardButton(text="🎟️ Активировать купон", callback_data="activate_coupon"))
if DONATIONS_ENABLE:
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
payment_text = (
"💸 <b>Выберите удобный способ пополнения баланса:</b>\n"
"<blockquote>"
"• Быстро и безопасно\n"
"• Поддержка разных платежных систем\n"
"• Моментальное зачисление средств 🚀\n"
"</blockquote>"
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=payment_text,
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
balance = int(balance)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
image_path = os.path.join("img", "pic.jpg")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "balance_history")
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
records = await get_last_payments(callback_query.from_user.id, session)
if records:
history_text = BALANCE_HISTORY_HEADER
for record in records:
amount = record["amount"]
payment_system = record["payment_system"]
status = record["status"]
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
history_text += (
f"<b>Сумма:</b> {amount}\n"
f"<b>Способ оплаты:</b> {payment_system}\n"
f"<b>Статус:</b> {status}\n"
f"<b>Дата:</b> {date}\n\n"
)
else:
history_text = "❌ У вас пока нет операций с балансом."
await edit_or_send_message(
target_message=callback_query.message,
text=history_text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+25 -13
View File
@@ -19,18 +19,20 @@ from config import (
from robokassa import HashAlgorithm, Robokassa
from database import (
add_connection,
add_user,
check_user_exists,
add_payment,
check_connection_exists,
get_key_count,
get_temporary_data,
update_balance,
)
from handlers.payments.utils import send_payment_success_notification
from handlers.texts import PAYMENT_OPTIONS
from handlers.texts import PAYMENT_OPTIONS, ENTER_SUM, DEFAULT_PAYMENT_MESSAGE
from handlers.utils import edit_or_send_message
from logger import logger
from handlers.buttons import BACK, PAY_2
router = Router()
@@ -89,15 +91,25 @@ 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="⬅️ Назад", callback_data="pay"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay"))
key_count = await get_key_count(tg_id)
if key_count == 0:
exists = await check_connection_exists(tg_id)
exists = await check_user_exists(tg_id)
if not exists:
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"Created new connection for user {tg_id} with balance 0.0.")
from_user = callback_query.from_user
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
await callback_query.message.delete()
@@ -151,14 +163,14 @@ async def process_amount_selection(callback_query: types.CallbackQuery, state: F
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Оплатить", url=payment_url)],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")],
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
[InlineKeyboardButton(text=BACK, callback_data="pay_robokassa")],
]
)
await edit_or_send_message(
target_message=callback_query.message,
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
force_text=True,
)
@@ -230,11 +242,11 @@ async def process_custom_amount_selection(callback_query: types.CallbackQuery, s
logger.info(f"User {tg_id} chose to enter a custom amount.")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="pay_robokassa"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
await edit_or_send_message(
target_message=callback_query.message,
text="Пожалуйста, введите сумму пополнения.",
text=ENTER_SUM,
reply_markup=builder.as_markup(),
force_text=True,
)
@@ -285,7 +297,7 @@ async def handle_custom_amount_input(
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
if state_type == "waiting_for_payment":
message_text = (
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+49 -174
View File
@@ -1,49 +1,44 @@
import html
import os
from typing import Any
import asyncpg
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
DATABASE_URL,
INLINE_MODE,
GIFT_BUTTON,
INSTRUCTIONS_BUTTON,
NEWS_MESSAGE,
REFERRAL_OFFERS,
RENEWAL_PLANS,
TRIAL_TIME,
USERNAME_BOT,
REFERRAL_BUTTON,
SHOW_START_MENU_ONCE,
)
from database import get_balance, get_key_count, get_last_payments, get_referral_stats, get_trial
from handlers.buttons.profile import (
from database import get_balance, get_key_count, get_trial
from handlers.buttons import (
ABOUT_VPN,
ADD_SUB,
BACK,
BALANCE,
BALANCE_HISTORY,
GIFTS,
INSTRUCTIONS,
INVITE,
MAIN_MENU,
MY_SUBS,
PAYMENT,
TRIAL_SUB,
)
from handlers.texts import get_referral_link, invite_message_send, profile_message_send
from keyboards.admin.panel_kb import AdminPanelCallback
from logger import logger
import html
from .admin.panel.keyboard import AdminPanelCallback
from .texts import profile_message_send
from .utils import edit_or_send_message
router = Router()
@@ -55,14 +50,27 @@ async def process_callback_view_profile(
admin: bool,
):
if isinstance(callback_query_or_message, CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
username = html.escape(callback_query_or_message.from_user.full_name)
chat = callback_query_or_message.message.chat
from_user = callback_query_or_message.from_user
chat_id = chat.id
target_message = callback_query_or_message.message
else:
chat_id = callback_query_or_message.chat.id
username = html.escape(callback_query_or_message.from_user.full_name)
chat = callback_query_or_message.chat
from_user = callback_query_or_message.from_user
chat_id = chat.id
target_message = callback_query_or_message
user = chat if chat.type == "private" else from_user
if getattr(user, "full_name", None):
username = html.escape(user.full_name)
elif getattr(user, "first_name", None):
username = html.escape(user.first_name)
elif getattr(user, "username", None):
username = "@" + html.escape(user.username)
else:
username = "Пользователь"
image_path = os.path.join("img", "profile.jpg")
logger.info(f"Переход в профиль. Используется изображение: {image_path}")
@@ -82,23 +90,32 @@ async def process_callback_view_profile(
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
builder = InlineKeyboardBuilder()
if trial_status == 0 or key_count == 0:
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
else:
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.row(
InlineKeyboardButton(text=INVITE, callback_data="invite"),
InlineKeyboardButton(text=GIFTS, callback_data="gifts"),
)
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())
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="start"))
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,
@@ -110,145 +127,3 @@ async def process_callback_view_profile(
)
finally:
await conn.close()
@router.callback_query(F.data == "balance")
async def balance_handler(callback_query: CallbackQuery, session: Any):
result = await session.fetchrow(
"SELECT balance FROM connections WHERE tg_id = $1",
callback_query.from_user.id,
)
balance = result["balance"] if result else 0.0
balance = int(balance)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = f"<b>Управление вашим балансом 💰</b>\n\nВаш баланс: {balance}"
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "balance_history")
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
records = await get_last_payments(callback_query.from_user.id, session)
if records:
history_text = "📊 <b>Последние 3 операции с балансом:</b>\n\n"
for record in records:
amount = record["amount"]
payment_system = record["payment_system"]
status = record["status"]
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
history_text += (
f"<b>Сумма:</b> {amount}\n"
f"<b>Способ оплаты:</b> {payment_system}\n"
f"<b>Статус:</b> {status}\n"
f"<b>Дата:</b> {date}\n\n"
)
else:
history_text = "❌ У вас пока нет операций с балансом."
await edit_or_send_message(
target_message=callback_query.message,
text=history_text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
@router.message(F.text == "/tariffs")
@router.callback_query(F.data == "view_tariffs")
async def view_tariffs_handler(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
image_path = os.path.join("img", "tariffs.jpg")
tariffs_message = "<b>🚀 Доступные тарифы VPN:</b>\n\n" + "\n".join(
[
f"{months} {'месяц' if months == '1' else 'месяца' if int(months) in [2, 3, 4] else 'месяцев'}: "
f"{RENEWAL_PLANS[months]['price']} "
f"{'💳' if months == '1' else '🌟' if months == '3' else '🔥' if months == '6' else '🚀'} рублей"
for months in sorted(RENEWAL_PLANS.keys(), key=int)
]
)
await edit_or_send_message(
target_message=callback_query.message,
text=tariffs_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "invite")
@router.message(F.text == "/invite")
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
chat_id = None
if isinstance(callback_query_or_message, CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
target_message = callback_query_or_message.message
else:
chat_id = callback_query_or_message.chat.id
target_message = callback_query_or_message
referral_link = get_referral_link(chat_id)
referral_stats = await get_referral_stats(chat_id)
invite_message = invite_message_send(referral_link, referral_stats)
image_path = os.path.join("img", "pic_invite.jpg")
builder = InlineKeyboardBuilder()
if INLINE_MODE:
builder.button(text="👥 Пригласить друга", switch_inline_query="invite")
else:
invite_text = f"\nПриглашаю тебя пользоваться действительно быстрым VPN вместе:\n\n{referral_link}"
builder.button(text="👥 Пригласить друга", switch_inline_query=invite_text)
builder.button(text="👤 Личный кабинет", callback_data="profile")
builder.adjust(1)
await edit_or_send_message(
target_message=target_message,
text=invite_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
async def inline_referral_handler(inline_query: InlineQuery):
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
results: list[InlineQueryResultArticle] = []
for index, offer in enumerate(REFERRAL_OFFERS):
description = offer["description"][:64]
message_text = offer["message"].format(trial_time=TRIAL_TIME)[:4096]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
results.append(
InlineQueryResultArticle(
id=str(index),
title=offer["title"],
description=description,
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
)
await inline_query.answer(results=results, cache_time=86400, is_personal=True)
+261
View File
@@ -0,0 +1,261 @@
import os
from io import BytesIO
from typing import Any
import asyncpg
import qrcode
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
ADMIN_ID,
DATABASE_URL,
INLINE_MODE,
TOP_REFERRAL_BUTTON,
TRIAL_TIME,
USERNAME_BOT,
)
from database import add_referral, add_user, check_user_exists, get_referral_by_referred_id, get_referral_stats
from handlers.buttons import (
BACK,
INVITE,
MAIN_MENU,
QR,
TOP_FIVE,
)
from handlers.texts import (
INVITE_TEXT_NON_INLINE,
NEW_REFERRAL_NOTIFICATION,
REFERRAL_OFFERS,
REFERRAL_SUCCESS_MSG,
TOP_REFERRALS_TEXT,
)
from logger import logger
from .texts import get_referral_link, invite_message_send
from .utils import edit_or_send_message, format_days
router = Router()
@router.callback_query(F.data == "invite")
@router.message(F.text == "/invite")
async def invite_handler(callback_query_or_message: Message | CallbackQuery):
chat_id = None
if isinstance(callback_query_or_message, CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
target_message = callback_query_or_message.message
else:
chat_id = callback_query_or_message.chat.id
target_message = callback_query_or_message
referral_link = get_referral_link(chat_id)
referral_stats = await get_referral_stats(chat_id)
invite_message = invite_message_send(referral_link, referral_stats)
image_path = os.path.join("img", "pic_invite.jpg")
builder = InlineKeyboardBuilder()
if INLINE_MODE:
builder.button(text=INVITE, switch_inline_query="invite")
else:
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
builder.button(text=INVITE, switch_inline_query=invite_text)
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
if TOP_REFERRAL_BUTTON:
builder.button(text=TOP_FIVE, callback_data="top_referrals")
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1)
await edit_or_send_message(
target_message=target_message,
text=invite_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
async def inline_referral_handler(inline_query: InlineQuery):
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
trial_time_formatted = format_days(TRIAL_TIME)
results: list[InlineQueryResultArticle] = []
for index, offer in enumerate(REFERRAL_OFFERS):
description = offer["description"][:64]
message_text = offer["message"].format(trial_time=TRIAL_TIME, trial_time_formatted=trial_time_formatted)[:4096]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
results.append(
InlineQueryResultArticle(
id=str(index),
title=offer["title"],
description=description,
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
)
await inline_query.answer(results=results, cache_time=86400, is_personal=True)
@router.callback_query(F.data.startswith("show_referral_qr|"))
async def show_referral_qr(callback_query: CallbackQuery):
try:
chat_id = callback_query.data.split("|")[1]
referral_link = get_referral_link(chat_id)
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(referral_link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_referral_{chat_id}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="📷 <b>Ваш QR-код для реферальной ссылки.</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR-кода для реферальной ссылки: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
@router.callback_query(F.data == "top_referrals")
async def top_referrals_handler(callback_query: CallbackQuery):
conn = await asyncpg.connect(DATABASE_URL)
try:
user_referral_count = (
await conn.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", callback_query.from_user.id)
or 0
)
personal_block = "Твоё место в рейтинге:\n"
if user_referral_count > 0:
user_position = await conn.fetchval(
"""
SELECT COUNT(*) + 1 FROM (
SELECT COUNT(*) as cnt
FROM referrals
GROUP BY referrer_tg_id
HAVING COUNT(*) > $1
) AS better_users
""",
user_referral_count,
)
personal_block += f"{user_position}. {callback_query.from_user.id} - {user_referral_count} чел."
else:
personal_block += "Ты еще не приглашал пользователей в проект."
top_referrals = await conn.fetch(
"""
SELECT referrer_tg_id, COUNT(*) as referral_count
FROM referrals
GROUP BY referrer_tg_id
ORDER BY referral_count DESC
LIMIT 5
"""
)
is_admin = callback_query.from_user.id in ADMIN_ID
rows = ""
for i, row in enumerate(top_referrals, 1):
tg_id = str(row["referrer_tg_id"])
count = row["referral_count"]
display_id = tg_id if is_admin else f"{tg_id[:5]}*****"
rows += f"{i}. {display_id} - {count} чел.\n"
text = TOP_REFERRALS_TEXT.format(personal_block=personal_block, rows=rows)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="invite"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
finally:
await conn.close()
async def handle_referral_link(referral_code: str, message: Message, state: FSMContext, session: Any):
try:
referrer_tg_id = int(referral_code)
user_exists_now = await check_user_exists(message.chat.id)
if referrer_tg_id == message.chat.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)
if existing_referral:
await message.answer("❌ Вы уже использовали реферальную ссылку.")
return
await add_referral(message.chat.id, referrer_tg_id, session)
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,
)
try:
await bot.send_message(
referrer_tg_id,
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id),
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
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
+167 -223
View File
@@ -1,4 +1,5 @@
import os
from typing import Any
from aiogram import F, Router
@@ -19,24 +20,34 @@ from config import (
CHANNEL_REQUIRED,
CHANNEL_URL,
DONATIONS_ENABLE,
SHOW_START_MENU_ONCE,
SUPPORT_CHAT_URL,
)
from database import (
add_connection,
add_referral,
check_connection_exists,
get_referral_by_referred_id,
add_user,
check_user_exists,
get_trial,
update_balance,
)
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT, TRIAL_SUB
from handlers.captcha import generate_captcha
from handlers.keys.key_management import create_key
from handlers.texts import WELCOME_TEXT, get_about_vpn
from keyboards.admin.panel_kb import AdminPanelCallback
from handlers.coupons import activate_coupon
from handlers.payments.gift import handle_gift_link
from handlers.profile import process_callback_view_profile
from handlers.texts import (
NOT_SUBSCRIBED_YET_MSG,
SUBSCRIPTION_CHECK_ERROR_MSG,
SUBSCRIPTION_CONFIRMED_MSG,
SUBSCRIPTION_REQUIRED_MSG,
WELCOME_TEXT,
get_about_vpn,
)
from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .refferal import handle_referral_link
from .utils import edit_or_send_message
router = Router()
@@ -49,17 +60,18 @@ async def handle_start_callback_query(
@router.message(Command("start"))
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True):
"""Обрабатывает команду /start, включая логику проверки подписки, рефералов и подарков."""
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
if CAPTCHA_ENABLE and captcha:
captcha_data = await generate_captcha(message, state)
await edit_or_send_message(
target_message=message,
text=captcha_data["text"],
reply_markup=captcha_data["markup"],
)
return
user_exists = await check_user_exists(message.chat.id)
if not user_exists:
captcha_data = await generate_captcha(message, state)
await edit_or_send_message(
target_message=message,
text=captcha_data["text"],
reply_markup=captcha_data["markup"],
)
return
state_data = await state.get_data()
text_to_process = state_data.get("original_text", message.text)
@@ -73,12 +85,14 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=f"Для использования бота, пожалуйста, подпишитесь на наш канал: {CHANNEL_URL}",
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
else:
logger.info(f"Пользователь {message.chat.id} подписан на канал (статус: {member.status}). Продолжаем работу.")
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)
@@ -86,202 +100,13 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=f"Пожалуйста, подпишитесь на наш канал: {CHANNEL_URL}",
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
await process_start_logic(message, state, session, admin, text_to_process)
async def process_start_logic(
message: Message, state: FSMContext, session: Any, admin: bool, text_to_process: str = None
):
text = text_to_process if text_to_process is not None else message.text
if text:
try:
connection_exists = await check_connection_exists(message.chat.id)
logger.info(f"Проверка существования подключения: {connection_exists}")
if not connection_exists:
await add_connection(tg_id=message.chat.id, session=session)
logger.info(f"Пользователь {message.chat.id} успешно добавлен в базу данных.")
if "coupons_" in text:
logger.info(f"Обнаружена ссылка на купон: {text}")
coupon_code = text.split("coupons_")[1].strip()
logger.info(f"Пользователь {message.chat.id} ввёл купон: {coupon_code}")
coupon = await session.fetchrow(
"SELECT id, code, amount, usage_limit, usage_count, is_used FROM coupons WHERE code = $1",
coupon_code,
)
if coupon is None:
logger.warning(f"Купон {coupon_code} не найден.")
await message.answer("❌ Купон не найден!")
return await show_start_menu(message, admin, session)
usage_exists = await session.fetchval(
"SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon["id"],
message.chat.id,
)
if usage_exists:
logger.info(f"Пользователь {message.chat.id} уже активировал купон {coupon_code}.")
await message.answer("❌ Вы уже использовали этот купон!")
return await show_start_menu(message, admin, session)
if coupon["is_used"] or coupon["usage_count"] >= coupon["usage_limit"]:
logger.info(f"Купон {coupon_code} уже использован или исчерпан.")
await message.answer("❌ Этот купон уже использован!")
return await show_start_menu(message, admin, session)
await update_balance(message.chat.id, coupon["amount"])
logger.info(f"Начислено {coupon['amount']} единиц для пользователя {message.chat.id}")
new_usage_count = coupon["usage_count"] + 1
is_used = new_usage_count >= coupon["usage_limit"]
await session.execute(
"UPDATE coupons SET usage_count = $1, is_used = $2 WHERE code = $3",
new_usage_count,
is_used,
coupon_code,
)
await session.execute(
"INSERT INTO coupon_usages (coupon_id, user_id, used_at) VALUES ($1, $2, NOW())",
coupon["id"],
message.chat.id,
)
logger.info(
f"Купон {coupon_code} успешно использован пользователем {message.chat.id}, начислено {coupon['amount']} RUB."
)
await message.answer(f"🎉 Ваш баланс пополнен на {coupon['amount']} RUB по купону!")
return await show_start_menu(message, admin, session)
if "gift_" in text:
logger.info(f"Обнаружена ссылка на подарок: {text}")
parts = text.split("gift_")[1].split("_")
if len(parts) < 2:
logger.error("Неверный формат ссылки на подарок: недостаточно частей после 'gift_'")
await message.answer("❌ Неверный формат ссылки на подарок.")
return await show_start_menu(message, admin, session)
gift_id = parts[0]
recipient_tg_id = message.chat.id
gift_info = await session.fetchrow(
"""
SELECT sender_tg_id, selected_months, expiry_time, is_used, recipient_tg_id
FROM gifts WHERE gift_id = $1
""",
gift_id,
)
if gift_info is None:
logger.warning(f"Подарок с ID {gift_id} уже был использован или не существует.")
await message.answer("Этот подарок уже был использован или не существует.")
return await show_start_menu(message, admin, session)
if gift_info["is_used"]:
logger.warning(f"Подарок с ID {gift_id} уже был активирован ранее.")
await message.answer("Этот подарок уже был использован.")
return await show_start_menu(message, admin, session)
if gift_info["sender_tg_id"] == recipient_tg_id:
logger.warning(f"Пользователь {recipient_tg_id} попытался активировать свой же подарок.")
await message.answer("❌ Вы не можете получить подарок от самого себя.")
return await show_start_menu(message, admin, session)
if gift_info["recipient_tg_id"] is not None:
logger.warning(
f"Подарок {gift_id} уже привязан к другому пользователю ({gift_info['recipient_tg_id']})."
)
await message.answer("❌ Этот подарок уже был активирован другим пользователем.")
return await show_start_menu(message, admin, session)
if not connection_exists:
await add_referral(recipient_tg_id, gift_info["sender_tg_id"], session)
logger.info(
f"Пользователь {recipient_tg_id} теперь является рефералом отправителя {gift_info['sender_tg_id']}."
)
await session.execute(
"""
UPDATE connections SET trial = 1 WHERE tg_id = $1
""",
recipient_tg_id,
)
selected_months = gift_info["selected_months"]
expiry_time = gift_info["expiry_time"].replace(tzinfo=None)
logger.info(f"Подарок с ID {gift_id} успешно найден для пользователя {recipient_tg_id}.")
await create_key(recipient_tg_id, expiry_time, state, session, message)
logger.info(f"Ключ создан для пользователя {recipient_tg_id} на срок {selected_months} месяцев.")
await session.execute(
"""
UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1
WHERE gift_id = $2
""",
recipient_tg_id,
gift_id,
)
await message.answer(
f"🎉 Ваш подарок на {selected_months} "
f"{'месяц' if selected_months == 1 else 'месяца' if selected_months in [2, 3, 4] else 'месяцев'} активирован!"
)
logger.info(f"Подарок на {selected_months} месяцев активирован для пользователя {recipient_tg_id}.")
return
elif "referral_" in text:
try:
referrer_tg_id = int(text.split("referral_")[1])
if connection_exists:
logger.info(f"Пользователь {message.chat.id} уже зарегистрирован и не может стать рефералом.")
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
return await show_start_menu(message, admin, session)
if referrer_tg_id == message.chat.id:
logger.warning(f"Пользователь {message.chat.id} попытался стать рефералом самого себя.")
await message.answer("❌ Вы не можете быть рефералом самого себя.")
return await show_start_menu(message, admin, session)
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
if existing_referral:
logger.info(f"Реферал с ID {message.chat.id} уже существует.")
return await show_start_menu(message, admin, session)
await add_referral(message.chat.id, referrer_tg_id, session)
logger.info(f"Реферал {message.chat.id} использовал ссылку от пользователя {referrer_tg_id}")
await message.answer(f"Вы стали рефералом пользователя с ID {referrer_tg_id}")
try:
await bot.send_message(referrer_tg_id, f"🎉 Ваш реферал {message.chat.id} успешно зарегистрировался!")
logger.info(f"Уведомление отправлено пользователю {referrer_tg_id} о новом реферале {message.chat.id}")
except Exception as e:
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
return await show_start_menu(message, admin, session)
except (ValueError, IndexError) as e:
logger.error(f"Ошибка при обработке реферальной ссылки: {e}")
return
else:
logger.info(f"Пользователь {message.chat.id} зашел без реферальной ссылки, подарка или купона.")
await show_start_menu(message, admin, session)
except (ValueError, IndexError) as e:
logger.error(f"Ошибка при обработке сообщения пользователя {message.chat.id}: {e}")
await message.answer("❌ Произошла ошибка. Пожалуйста, попробуйте снова.")
else:
await show_start_menu(message, admin, session)
@router.callback_query(F.data == "check_subscription")
async def check_subscription_callback(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool):
user_id = callback_query.from_user.id
@@ -291,15 +116,15 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
logger.info(f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}")
if member.status not in ["member", "administrator", "creator"]:
await callback_query.answer("Вы еще не подписаны на канал!", show_alert=True)
await callback_query.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await callback_query.message.edit_text(
f"Для использования бота, пожалуйста, подпишитесь на наш канал: {CHANNEL_URL}",
SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
else:
await callback_query.answer("Подписка подтверждена!")
await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG)
data = await state.get_data()
original_text = data.get("original_text")
if not original_text:
@@ -308,7 +133,118 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
except Exception as e:
logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True)
await callback_query.answer("Ошибка проверки подписки, повторите попытку", show_alert=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
):
text = text_to_process or message.text or message.caption
if not text:
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {message.chat.id}")
await show_start_menu(message, admin, session)
return
if text.startswith("/start "):
parts = text.split(maxsplit=1)
if len(parts) > 1:
text = parts[1]
try:
gift_detected = False
text_parts = text.split("-")
for part in text_parts:
if "coupons" in part:
logger.info(f"Обнаружена ссылка на купон: {part}")
coupon_code = part.split("coupons")[1].strip("_")
await activate_coupon(message, state, session, coupon_code=coupon_code, admin=admin)
continue
if "gift" in part:
gift_raw = part.split("gift")[1].strip("_")
parts = gift_raw.split("_")
if len(parts) < 2:
await message.answer("❌ Неверный формат ссылки на подарок.")
return await process_callback_view_profile(message, state, admin)
gift_id = parts[0]
sender_id = parts[1]
logger.info(f"[GIFT] Обнаружен подарок {gift_id} от {sender_id}")
await handle_gift_link(gift_id, message, state, session)
gift_detected = True
break
if "referral" in part:
referrer_tg_id = part.split("referral")[1].strip("_")
try:
referrer_tg_id = int(referrer_tg_id)
await handle_referral_link(referrer_tg_id, message, state, session)
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)
continue
if gift_detected:
return
user_exists = await check_user_exists(message.chat.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,
)
trial_status = await get_trial(message.chat.id, session)
if SHOW_START_MENU_ONCE:
if trial_status > 0:
await process_callback_view_profile(message, state, admin)
else:
await show_start_menu(message, admin, session)
else:
await show_start_menu(message, admin, session)
except Exception as e:
logger.error(f"Ошибка при обработке текста {message.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:
await message.answer("❌ UTM ссылка не найдена.")
async def show_start_menu(message: Message, admin: bool, session: Any):
@@ -323,26 +259,29 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
trial_status = await get_trial(message.chat.id, session)
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
if trial_status == 0:
builder.row(InlineKeyboardButton(text="🔗 Подключить VPN", callback_data="create_key"))
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
else:
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
else:
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
if not SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if CHANNEL_EXISTS:
builder.row(
InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL),
InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL),
InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL),
InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL),
)
else:
builder.row(InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if admin:
builder.row(
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
builder.row(InlineKeyboardButton(text="🌐 О VPN", callback_data="about_vpn"))
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
await edit_or_send_message(
target_message=message,
@@ -357,16 +296,21 @@ async def handle_about_vpn(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
if DONATIONS_ENABLE:
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
builder.row(InlineKeyboardButton(text="📞 Техническая поддержка", url=SUPPORT_CHAT_URL))
support_btn = InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)
if CHANNEL_EXISTS:
builder.row(InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="start"))
channel_btn = InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL)
builder.row(support_btn, channel_btn)
else:
builder.row(support_btn)
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
text = get_about_vpn("3.2.3-minor")
image_path = os.path.join("img", "pic.jpg")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
media_path=image_path,
force_text=False,
)
+175 -98
View File
@@ -7,95 +7,108 @@ import string
import aiofiles
import aiohttp
import asyncpg
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
from datetime import datetime
from bot import bot
from config import DATABASE_URL
from config import ADMIN_ID, DATABASE_URL
from database import get_all_keys, get_servers
from logger import logger
async def get_usd_rate():
try:
async with aiohttp.ClientSession() as session:
async with session.get("https://www.cbr-xml-daily.ru/daily_json.js") as response:
if response.status == 200:
data = await response.text()
usd = float(json.loads(data)["Valute"]["USD"]["Value"])
else:
usd = float(100)
except Exception as e:
logger.exception(f"Error fetching USD rate: {e}")
usd = float(100)
return usd
def sanitize_key_name(key_name: str) -> str:
"""
Очищает название ключа, оставляя только допустимые символы.
Args:
key_name (str): Исходное название ключа.
Returns:
str: Очищенное название ключа в нижнем регистре.
"""
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
def generate_random_email(length: int = 6) -> str:
def generate_random_email(length: int = 8) -> str:
"""
Генерирует случайный email с заданной длиной.
Args:
length (int, optional): Длина случайной строки. По умолчанию 6.
Returns:
str: Сгенерированная случайная строка.
"""
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
async def get_least_loaded_cluster() -> str:
"""
Определяет кластер с наименьшей загрузкой.
Returns:
str: Идентификатор наименее загруженного кластера.
Возвращает кластер с наименьшей загрузкой, где есть хотя бы один сервер с доступным лимитом.
"""
servers = await get_servers()
server_to_cluster = {}
cluster_loads = {}
cluster_loads: dict[str, int] = {cluster_id: 0 for cluster_id in servers.keys()}
for cluster_name, cluster_servers in servers.items():
cluster_loads[cluster_name] = 0
for server in cluster_servers:
server_to_cluster[server["server_name"]] = cluster_name
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:
cluster_id = key["server_id"]
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
logger.info(f"Cluster loads after database query: {cluster_loads}")
available_clusters = {}
for cluster_name, cluster_servers in servers.items():
for server in cluster_servers:
if server.get("enabled", True) and await check_server_key_limit(server, conn):
available_clusters[cluster_name] = cluster_loads[cluster_name]
break
if not cluster_loads:
logger.warning("No clusters found in database or configuration.")
if not available_clusters:
logger.warning("❌ Нет доступных кластеров с лимитом ключей!")
return "cluster1"
least_loaded_cluster = min(cluster_loads, key=lambda k: (cluster_loads[k], k))
logger.info(f"Least loaded cluster selected: {least_loaded_cluster}")
least_loaded_cluster = min(available_clusters, key=lambda k: (available_clusters[k], k))
logger.info(f"✅ Выбран наименее загруженный кластер с лимитом: {least_loaded_cluster}")
return least_loaded_cluster
async def check_server_key_limit(server_info: dict, conn) -> bool:
"""
Универсальная проверка лимита ключей для сервера в режимах кластеров и стран.
"""
server_name = server_info.get("server_name")
cluster_name = server_info.get("cluster_name")
max_keys = server_info.get("max_keys")
if not max_keys:
return True
identifier = cluster_name if cluster_name else server_name
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", identifier)
if total_keys >= max_keys:
logger.warning(f"[Key Limit] Сервер {server_name} достиг лимита: {total_keys}/{max_keys}")
return False
usage_percent = total_keys / max_keys
if usage_percent >= 0.9:
notif_key = f"server_warn_{server_name}"
already_sent = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM notifications WHERE tg_id = 0 AND notification_type = $1)", notif_key
)
if not already_sent:
for admin_id in ADMIN_ID:
try:
await bot.send_message(
admin_id,
f"⚠️ Сервер <b>{server_name}</b> почти заполнен ({int(usage_percent * 100)}%)."
f"\nРекомендуется создать новый для балансировки.",
)
except Exception:
pass
await conn.execute(
"INSERT INTO notifications (tg_id, notification_type) VALUES (0, $1) ON CONFLICT DO NOTHING",
notif_key,
)
return True
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
"""
Обрабатывает ошибку, отправляя сообщение пользователю.
Args:
tg_id (int): Идентификатор пользователя в Telegram.
callback_query (Optional[object], optional): Объект запроса обратного вызова. По умолчанию None.
message (str, optional): Текст сообщения об ошибке. По умолчанию пустая строка.
"""
try:
if callback_query and hasattr(callback_query, "message"):
@@ -110,41 +123,54 @@ async def handle_error(tg_id: int, callback_query: object | None = None, message
logger.error(f"Ошибка при обработке ошибки: {e}")
def format_time_until_deletion(seconds: int) -> str:
if seconds <= 0:
def get_plural_form(num: int, form1: str, form2: str, form3: str) -> str:
"""Универсальная функция для получения правильной формы множественного числа"""
n = abs(num) % 100
if 10 < n < 20:
return form3
return {1: form1, 2: form2, 3: form2, 4: form2}.get(n % 10, form3)
def format_months(months: int) -> str:
"""Форматирует количество месяцев с правильным склонением"""
if months <= 0:
return "0 месяцев"
return f"{months} {get_plural_form(months, 'месяц', 'месяца', 'месяцев')}"
def format_days(days: int) -> str:
"""
Форматирует количество дней с правильным склонением.
"""
if days <= 0:
return "0 дней"
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
def format_hours(hours: int) -> str:
"""Форматирует количество часов с правильным склонением"""
if hours <= 0:
return "0 часов"
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
def format_minutes(minutes: int) -> str:
"""Форматирует количество минут с правильным склонением"""
if minutes <= 0:
return "0 минут"
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
days = seconds // (3600 * 24)
hours = (seconds % (3600 * 24)) // 3600
minutes = (seconds % 3600 + 59) // 60
parts = []
def format_hours(hours: int) -> str:
"""Форматирует количество часов с правильным склонением"""
if hours <= 0:
return "0 часов"
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
if days > 0:
if days == 1:
parts.append(f"{days} день")
elif 2 <= days <= 4:
parts.append(f"{days} дня")
else:
parts.append(f"{days} дней")
if hours > 0:
if hours == 1:
parts.append(f"{hours} час")
elif 2 <= hours <= 4:
parts.append(f"{hours} часа")
else:
parts.append(f"{hours} часов")
if minutes > 0 and days == 0:
if minutes == 1:
parts.append("1 минута")
elif 2 <= minutes <= 4:
parts.append(f"{minutes} минуты")
else:
parts.append(f"{minutes} минут")
return " и ".join(parts) if parts else "менее минуты"
def format_minutes(minutes: int) -> str:
"""Форматирует количество минут с правильным склонением"""
if minutes <= 0:
return "0 минут"
return f"{minutes} {get_plural_form(minutes, 'минута', 'минуты', 'минут')}"
async def edit_or_send_message(
@@ -157,15 +183,6 @@ async def edit_or_send_message(
):
"""
Универсальная функция для редактирования исходного сообщения target_message.
- Если media_path указан и существует, считается, что сообщение содержит фото, и используется редактирование медиа
(замена фото и подписи) через edit_media. Если редактирование не удаётся, отправляется новое сообщение с фото.
- Если media_path не указан:
- Если force_text=False и target_message уже имеет caption, пытаемся отредактировать подпись (edit_caption).
- Иначе (или если редактирование caption не удалось) редактируем текст (edit_text).
В случае неудачи fallback отправка нового сообщения.
"""
if media_path and os.path.isfile(media_path):
async with aiofiles.open(media_path, "rb") as f:
@@ -199,7 +216,7 @@ async def edit_or_send_message(
disable_web_page_preview=disable_web_page_preview,
)
return
except Exception as e:
except Exception:
await target_message.answer(
text=text,
reply_markup=reply_markup,
@@ -210,11 +227,6 @@ async def edit_or_send_message(
def convert_to_bytes(value: float, unit: str) -> int:
"""
Конвертирует значение с указанной единицей измерения в байты.
Args:
value (float): Числовое значение.
unit (str): Единица измерения ('KB', 'MB', 'GB', 'TB').
Returns:
int: Количество байт.
"""
KB = 1024
MB = KB * 1024
@@ -222,3 +234,68 @@ def convert_to_bytes(value: float, unit: str) -> int:
TB = GB * 1024
units = {"KB": KB, "MB": MB, "GB": GB, "TB": TB}
return int(value * units.get(unit.upper(), 1))
async def is_full_remnawave_cluster(cluster_id: str, session) -> bool:
"""
Универсальная проверка:
- Если cluster_id это имя кластера, проверяет, что все его сервера используют Remnawave.
- Если cluster_id это имя одиночного сервера, проверяет, что он Remnawave.
"""
cluster_servers = await session.fetch(
"SELECT panel_type FROM servers WHERE cluster_name = $1",
cluster_id,
)
if cluster_servers:
panel_types = [s["panel_type"].lower() for s in cluster_servers if s.get("panel_type")]
return all(pt == "remnawave" for pt in panel_types)
server = await session.fetchrow(
"SELECT panel_type FROM servers WHERE server_name = $1",
cluster_id,
)
return server and server["panel_type"].lower() == "remnawave"
def sanitize_key_name(key_name: str) -> str:
"""
Очищает название ключа, оставляя только допустимые символы.
Args:
key_name (str): Исходное название ключа.
Returns:
str: Очищенное название ключа в нижнем регистре.
"""
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
RUSSIAN_MONTHS = {
'January': 'Января',
'February': 'Февраля',
'March': 'Марта',
'April': 'Апреля',
'May': 'Мая',
'June': 'Июня',
'July': 'Июля',
'August': 'Августа',
'September': 'Сентября',
'October': 'Октября',
'November': 'Ноября',
'December': 'Декабря'
}
def get_russian_month(date: datetime) -> str:
"""
Преобразует английское название месяца в русское.
Args:
date: Объект datetime, из которого извлекается месяц.
Returns:
Название месяца на русском языке.
"""
english_month = date.strftime("%B")
return RUSSIAN_MONTHS.get(english_month, english_month)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 586 KiB

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