986 Commits

Author SHA1 Message Date
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 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
Vladless 02fbf89d09 version 4.0 2025-03-02 21:53:20 +03:00
Oleg Kraev 305fbde885 Update README.md 2025-03-02 16:39:09 +03:00
Vladless 3d94037d4e version 4.0 2025-03-02 15:31:47 +03:00
Vladless 4a5aae06c3 add screening username 2025-03-02 12:33:52 +03:00
Vladislav Lisitsyn 19e476aa3f Update README.md 2025-03-02 03:23:07 +03:00
Vladless 5c62ed8d92 Added an extension check 2025-03-02 02:50:12 +03:00
Vladislav Lisitsyn f138572b38 Update README.md 2025-03-01 12:11:23 +03:00
Vladislav Lisitsyn f907906d04 Update README.md 2025-03-01 02:17:41 +03:00
Vladless 097ce51b2c add preview_2 2025-03-01 02:15:57 +03:00
Vladless 1fa553f246 add preview 2025-03-01 00:21:15 +03:00
Vladless 32c65b3194 up bot version 2025-02-28 22:44:39 +03:00
Vladless a4d11f67fa fix captha and stars 2025-02-28 20:30:04 +03:00
Vladless 1775d90bab fix Hiddify subscription 2025-02-26 12:46:33 +03:00
izzzzzi 1d6ae5fa07 Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
- Значительное упрощение middleware для ограничения частоты запросов
- Унифицирован механизм троттлинга с единым кэшем
- Добавлена поддержка уведомлений о превышении лимита запросов для CallbackQuery
- Удалены избыточные проверки и сложная логика
- Сокращено количество параметров инициализации
2025-02-26 02:33:26 +05:00
Vladless fa2d3eaa0a minor bug fixes 2025-02-25 22:06:44 +03:00
Vladless 148e99ea6f minor improvements 2025-02-25 20:19:41 +03:00
Vladless 64f99ac660 up bot version 2025-02-25 20:10:26 +03:00
Vladless bded99263a refactoring and minor improvements 2025-02-25 19:56:42 +03:00
Vladislav Lisitsyn aded5606a8 Merge pull request #160 from izzzzzi/main
Рефакторинг и оптимизация обработки подписок
2025-02-25 18:43:14 +03:00
izzzzzi 79982f5aa6 Рефакторинг и оптимизация импортов в модулях проекта
- Упорядочивание и группировка импортов в соответствии с PEP 8
- Удаление неиспользуемых импортов
- Перемещение локальных импортов в начало списка
- Добавление пустых строк для улучшения читаемости
- Систематизация порядка импортов в различных модулях проекта
2025-02-25 04:14:58 +05:00
izzzzzi dc27a6e69b Рефакторинг и улучшение модуля резервного копирования базы данных
- Добавлена расширенная типизация и аннотации типов
- Улучшена обработка ошибок и логирование в функциях бэкапа
- Оптимизирован процесс создания и очистки резервных копий с использованием pathlib
- Добавлены информативные docstring комментарии
- Усовершенствована отправка бэкапов администраторам с обработкой исключений
- Использованы современные подходы к работе с файловой системой и переменными окружения
2025-02-25 02:51:02 +05:00
izzzzzi 8af59ffbfe Улучшение обработки пользователей и middleware с возвратом данных
- Модифицирована функция upsert_user для возврата полных данных пользователя
- Обновлен UserMiddleware для сохранения данных пользователя в контексте
- Расширена функциональность register_middleware с поддержкой опционального исключения middleware
- Добавлена типизация и улучшена обработка middleware
2025-02-25 02:46:59 +05:00
izzzzzi 013b26ecc8 Оптимизация и улучшение middleware и обработки сессий базы данных
- Рефакторинг middlewares с добавлением типизации и улучшенной логики
- Оптимизация SessionMiddleware с использованием пула соединений
- Улучшение обработки пользователей и логирования в UserMiddleware
- Расширение функциональности ThrottlingMiddleware с более гибким управлением
- Добавление дополнительных проверок и логирования в AdminMiddleware
- Оптимизация функции upsert_user с поддержкой передачи существующей сессии
2025-02-25 02:39:44 +05:00
izzzzzi 7df831c084 Рефакторинг и оптимизация обработки подписок
- Значительное улучшение структуры кода в subscriptions.py
- Добавлены типизация и аннотации типов
- Разделение логики на отдельные функции с четкой ответственностью
- Улучшена обработка часовых поясов с использованием pytz
- Оптимизирована логика получения и обработки подписок
- Добавлены информативные логи и комментарии
2025-02-25 01:00:41 +05:00
Vladless 507e0b5e1d refactoring and fix refferal_functions 2025-02-23 23:04:08 +03:00
Vladless f2a12b8d98 fix country_mode 2025-02-23 17:03:31 +03:00
Vladless 1d089cce7d add_commands: coupon/invite 2025-02-22 19:50:53 +03:00
Vladislav Lisitsyn ba89c054fd Merge pull request #159 from izzzzzi/main
Refactor inline referral handler and admin stats time display
2025-02-22 14:12:53 +03:00
izzzzzi 33801f58c4 Refactor inline referral handler and admin stats time display
- Simplified inline referral handler by removing unnecessary try-except block
- Increased cache time for inline query results
- Added Moscow timezone for admin stats update time display
2025-02-22 14:27:56 +05:00
Vladislav Lisitsyn a963f4ad12 Merge pull request #158 from Vladless/feature/time-in-admin-stats
Время обновления в главном меню стастистики Администратора
2025-02-22 11:27:41 +03:00
hteppl b8472ee428 Add update_time in admin stats main page 2025-02-22 11:22:56 +03:00
Vladless 769f5eaf9a minor improvements 2025-02-21 22:36:03 +03:00
Vladislav Lisitsyn c9e791fa76 Merge pull request #157 from Capybara-z/patch-9
Оптимизация subscriptions.py
2025-02-21 17:33:16 +03:00
Capybara-z a32d9d422f Конвертатор байтов
- Добавлен конвертатор байтов.
2025-02-20 20:07:58 +03:00
Capybara-z 39baa1640c Оптимизация трафика
- Оптимизирован траифик, спасибо @hteppl
- Исправлен баг с логами в хедере хидифая.
2025-02-20 20:06:27 +03:00
Capybara-z 37ebf4cdee Апдейт
- Время подписок берется из бд
- Облегчен парсер данных
- Добавлена поддержка хидифай
- Улучшена работа отдачи и обновления подписки.
2025-02-19 22:26:01 +03:00
Vladislav Lisitsyn bb0085a438 Merge pull request #156 from Capybara-z/patch-8
changing the subscription issue
2025-02-19 02:33:33 +03:00
Capybara-z b05e57fb16 фикс парсера времени
- фикс парсера времени для старых панелей
2025-02-19 01:31:15 +03:00
Capybara-z 4c977d07ce чистка кода
- Почитсил коментарии.
2025-02-18 23:57:38 +03:00
Capybara-z bd39825cf9 фикс расчета остатка
- Не посмотрел что TOTAL_GB указывается в байтах
2025-02-18 19:40:47 +03:00
Capybara-z e01a7efe4a фикс подписок
- Поправлено время для SUPERNODA = False
- Добавлен подсчет использованного трафика для Happ
2025-02-18 17:35:40 +03:00
Capybara-z 8b159ab75d фикс парсинга времени
- исправлен парсинг времени если передается без запятой
2025-02-18 14:31:43 +03:00
Capybara-z 316bd191a2 Указал коменты 2025-02-18 13:45:59 +03:00
Capybara-z 29f6fe40e0 фикс отображения
- Исправлено отображение с трафиком и без для спуперноды вкл/выкл
- Исправлен парсинг в общем для разных режимов.
2025-02-18 13:30:46 +03:00
Capybara-z 40f1f1b14e Правки для Happ
- Добавлен хедер трафика и даты завершения
2025-02-18 12:12:34 +03:00
Capybara-z 31822ee634 поправлены отступы 2025-02-18 11:19:52 +03:00
Capybara-z 773db02746 Фикс отображения данных
- Фикс отображения данных для Happ
2025-02-18 11:17:33 +03:00
Capybara-z 2ba9615151 фикс отображения трафика
- Корректное отображения трафика
2025-02-18 10:51:51 +03:00
Capybara-z 19c4c0496a Единое время обновления подписки
- Единое время обновления подписки
2025-02-18 10:30:18 +03:00
Capybara-z 903cde9a78 фикс часов
- фискс отображения когда остаются только часы
2025-02-18 10:19:05 +03:00
Capybara-z 3592eccea9 Фикс подпсиок
- Убираем профиль
- Отказ от SUB_MESSAGE
- Перадача подпсики и времени работы вместо SUB_MESSAGE
- Отдельная итеграция для Happ по хукам.
2025-02-18 09:21:10 +03:00
Vladless 4afd1668c9 add_change_domain/bug_fixes_16 2025-02-18 02:05:14 +03:00
Vladislav Lisitsyn f799dd8766 Merge pull request #154 from Capybara-z/patch-7
subscriptions update
2025-02-18 00:09:00 +03:00
Capybara-z 254c489010 Апдейт подписок.
- Добавление профиля.
- Выводит имейл и время подписки
- Страны становятся чистыми
- Профиль всегда на 1 месте
- Перемашка стран.
2025-02-17 02:50:21 +03:00
Vladislav Lisitsyn 53bc6f6167 Merge pull request #153 from JustYay/main
small fix for showing error
2025-02-17 02:47:00 +03:00
Boris Kovalskii b48cb94b12 small fix for show error 2025-02-16 17:27:52 +10:00
Vladislav Lisitsyn 0813ea2897 Merge pull request #152 from JustYay/main
Optimize and fix refferals
2025-02-16 10:20:54 +03:00
Boris Kovalskii 770f40f4ff Opimize and fix coupons and refferals 2025-02-16 17:09:16 +10:00
Boris Kovalskii dba8752436 optimize database 2025-02-16 17:08:38 +10:00
Vladless 4b503ce31a edit_text/lots of improvements 2025-02-16 07:17:35 +03:00
Vladislav Lisitsyn 8b9c00f53e Merge pull request #151 from izzzzzi/main
Add client toggle functionality for 3x-ui servers
2025-02-15 00:10:53 +03:00
Zakhar Izmaylov 55811aff56 Add inline referral query handler with customizable offers 2025-02-14 23:20:40 +03:00
Zakhar Izmaylov 310aa9bce6 Add client toggle functionality for 3x-ui servers 2025-02-14 23:06:45 +03:00
Vladislav Lisitsyn 2dcd1e65db Update README.md 2025-02-14 05:17:20 +03:00
Vladislav Lisitsyn 4f840c171a Update README.md 2025-02-14 05:08:36 +03:00
Vladless bfefdef304 optimization notifications and get_traffic 2025-02-14 04:36:37 +03:00
Vladless 3de049e436 NOTIFICATIONS 2.0/bug fixes and improvments 2025-02-14 00:30:06 +03:00
Vladislav Lisitsyn 74bb6b3f6d Merge pull request #149 from izzzzzi/main
Add notification tracking to prevent duplicate notifications for key expiry and renewal
2025-02-10 21:57:46 +03:00
Zakhar Izmaylov 56e6dc3ae6 Migrate from requirements.txt to pyproject.toml for dependency management 2025-02-09 12:08:22 +03:00
Zakhar Izmaylov c34626aec0 Add logging support for inline queries in middleware 2025-02-09 10:52:02 +03:00
Zakhar Izmaylov d4187149b8 Add notification tracking to prevent duplicate notifications for key expiry and renewal 2025-02-08 11:10:11 +03:00
Zakhar Izmaylov e982a5b718 Ignore GitHub Actions workflow directory in .gitignore 2025-02-08 11:00:00 +03:00
Zakhar Izmaylov a9a17bd693 Remove Ruff code formatting GitHub Actions workflow 2025-02-08 10:59:50 +03:00
GitHub Actions aef1973720 Auto-format code with Ruff using pyproject.toml 2025-02-08 07:46:14 +00:00
Vladless 5a77f29bd5 add traffic statistic/share refferal link and more 2025-02-07 21:38:46 +03:00
Vladless 0789b3fe9d Improve stability and minor fixes 2025-02-06 19:01:20 +03:00
Vladless c82f64980b fix_amount 2025-02-06 14:56:47 +03:00
Vladless 7623f339bc ruff formatting 2025-02-06 13:53:02 +03:00
Vladless 3de1635efa Major bug fixes 2025-02-06 02:03:12 +03:00
Vladless eb824162d2 fixes: float_stats/delete_user/message_errors 2025-02-05 05:58:03 +03:00
Vladless 54d5df72af minor_fixes 2025-02-03 00:08:55 +03:00
Vladless 74c991cf85 limit_coupon_usage 2025-02-02 23:15:55 +03:00
Vladless 46e6102412 minor fixes 2025-02-02 19:40:17 +03:00
Vladless 698b8a9484 minor fixes 2025-02-02 19:00:22 +03:00
Vladless 94f3c00628 fix_negative cashback 2025-02-02 17:49:43 +03:00
Vladless 630cf61f84 change_location/CASHBACK/logging/bug_fixes 2025-02-02 05:50:14 +03:00
Vladless d3b384aa61 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:42:28 +03:00
Vladislav Lisitsyn d209cf11c7 Merge pull request #147 from Capybara-z/patch-5
reorganizing notifications
2025-02-02 02:14:08 +03:00
Vladless 49eb0e68a1 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:11:57 +03:00
Capybara-z 3e7bebf189 Корректировка уведомлений.
- Исправлена работа оповещений 10/24 часа.
- Восстановлена и переработана работа истекших подписок.
- Переработана работа уведомлений при их удалении.
2025-02-02 00:58:07 +03:00
Vladless 73fbc22324 fix coupons error 2025-02-01 19:15:47 +03:00
Vladless 9774db74e5 Merge remote-tracking branch 'origin/fix/admin-actions' 2025-02-01 18:32:38 +03:00
Vladislav Lisitsyn 82d8b50956 Merge pull request #144 from izzzzzi/main
Enhance error handling with start menu fallback
2025-02-01 18:11:23 +03:00
Zakhar Izmaylov 587bf76c61 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-02-01 11:34:33 +03:00
Zakhar Izmaylov c2e4719f18 Enhance error handling with start menu fallback
- Add fallback mechanism to display start menu after error occurs
- Attempt to show start command or start callback query in error handler
- Log any additional errors during start menu display
2025-02-01 11:28:02 +03:00
Vladislav Lisitsyn 7b4bddfa28 Merge pull request #143 from izzzzzi/main
Update get_key_details query and return value
2025-01-31 21:40:13 +03:00
Zakhar Izmaylov 8be57829fb Update get_key_details query and return value
- Reorder SELECT columns in database query for get_key_details
- Add server_id to the returned key details dictionary
- Ensure consistent and complete key information retrieval
2025-01-31 21:05:55 +03:00
Vladislav Lisitsyn ceb3e160da Merge pull request #141 from izzzzzi/main
Fix key
2025-01-31 14:30:13 +03:00
Zakhar Izmaylov 007f3a7bcf Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 11:01:40 +03:00
Zakhar Izmaylov cc42900142 Update server name input handler with session parameter
- Add session parameter to server name input handler
- Modify server name uniqueness check to use session and cluster name
- Improve data retrieval and validation in server creation flow
2025-01-31 10:54:42 +03:00
hteppl 1009bb4001 Temporary fix for key expiry change 2025-01-31 06:46:53 +03:00
hteppl e4727dd0a4 Missing doc for field in update_key_expiry 2025-01-31 06:45:56 +03:00
GitHub Actions 3d6acf51f6 Auto-format code with Ruff using pyproject.toml 2025-01-31 03:45:45 +00:00
hteppl 1634588be9 Delete admin_user_editor.py 2025-01-31 06:45:24 +03:00
Zakhar Izmaylov c379011fd2 Fix key renewal and expiry update function signatures
- Update `change_expiry_time` to pass session to `update_key_expiry`
- Adjust `complete_key_renewal` connection closing order
- Minor code structure improvements in key handling functions
2025-01-31 06:44:36 +03:00
hteppl 8483ef1df0 Fix cluster selection 2025-01-31 06:10:37 +03:00
hteppl 9be291c1e8 Oops legacy codestyle 2025-01-31 05:19:52 +03:00
hteppl 6871f2d15e Get back cluster sync option 2025-01-31 05:14:40 +03:00
hteppl 60c9a4f1e6 Get back cluster sender option 2025-01-31 05:03:07 +03:00
hteppl 5a894e0e10 Fix type mismatch for build_admin_back_btn 2025-01-31 04:21:03 +03:00
Zakhar Izmaylov 45b9c56282 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 03:14:14 +03:00
Zahar Izmailov 88c4d940e8 Merge branch 'Vladless:main' into main 2025-01-31 03:09:45 +03:00
Zakhar Izmaylov 3741e4f3bf Standardize import order in handler modules
- Reorder imports in multiple handler files to follow a consistent pattern
- Move type-specific imports like Message and CallbackQuery to be more organized
- Ensure clean and uniform import statements across admin and handler modules
2025-01-31 03:01:46 +03:00
Zakhar Izmaylov 28e7c78601 Update type hints in admin and handler modules
- Replace types.Message and types.CallbackQuery with explicit Message and CallbackQuery imports
- Improve type annotations in multiple handler files
- Ensure consistent type hinting across admin and handler modules
2025-01-31 02:59:57 +03:00
Zakhar Izmaylov 37fbd35e8f Add trailing comma in handlers router inclusion
- Minor formatting adjustment in router configuration
- Ensure consistent code style with trailing comma
2025-01-31 02:44:48 +03:00
Zakhar Izmaylov c41b77d365 Update database query and key handling in admin user editor
- Modify get_keys function to return all columns in keys table
- Update admin user editor to handle full key record instead of just email
- Cast tg_id to integer in user record processing
- Adjust key display and callback data generation to use full key record
2025-01-31 02:41:40 +03:00
GitHub Actions 1d19d8b744 Auto-format code with Ruff using pyproject.toml 2025-01-30 21:15:14 +00:00
Zakhar Izmaylov b04bc4dca5 Remove user router and related blocking/unblocking logic
- Deleted handlers/user.py file
- Removed user_router from main handlers router
- Eliminated user blocking/unblocking event handlers
2025-01-31 00:14:56 +03:00
Vladislav Lisitsyn 4dd4d829d7 Merge pull request #139 from izzzzzi/main
Fix
2025-01-31 00:09:44 +03:00
Zakhar Izmaylov bf35156455 Remove check_online_users function and minor import reorganization
- Removed unused check_online_users function from notifications handler
- Reorganized imports in notifications.py and utils.py
- Fixed TimeoutError import in subscriptions.py
2025-01-31 00:08:31 +03:00
Zakhar Izmaylov e6a1b55045 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:37:28 +03:00
Zakhar Izmaylov 455ab76c8a Minor text formatting in captcha generation message 2025-01-30 23:36:56 +03:00
GitHub Actions 720c145aa3 Auto-format code with Ruff using pyproject.toml 2025-01-30 20:36:20 +00:00
Zakhar Izmaylov 580ce90442 Remove unused is_bot_blocked function from notifications handler 2025-01-30 23:36:02 +03:00
Zakhar Izmaylov 239426f6d5 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:35:50 +03:00
Zakhar Izmaylov 3c4ae34997 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 22:36:10 +03:00
Zakhar Izmaylov 899da825cb Refactor and improve security with cryptographically secure random generation
- Replace `random` module with `secrets` for cryptographically secure random choices
- Update type hints for async API methods in client.py
- Optimize file reading with aiofiles in multiple handlers
- Improve error handling and logging in various modules
- Update Python target version to 3.12 in pyproject.toml
- Remove unused imports and improve code quality
2025-01-30 22:35:09 +03:00
Vladislav Lisitsyn 59e0d965da Merge pull request #138 from Capybara-z/patch-2
Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
2025-01-30 21:58:33 +03:00
Vladislav Lisitsyn b70e27932d Merge pull request #137 from Capybara-z/patch-1
Задержка перед удалением подписки.
2025-01-30 21:58:22 +03:00
Capybara-z 69dcef073e Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
Добавлена format_time_until_deletion для расчета времени при уведомлении когда подписка истекала.
2025-01-30 21:50:29 +03:00
Capybara-z 89394ef630 Задержка перед удалением подписки.
Добавлена переменная:
DELETE_KEYS_DELAY = 0  # Задержка в секундах перед удалением ключа после истечения. Установите 0, если функция не требуется.

При ее работе не удаляет ключ, а ждет опредленное время.

Поправлена работа def handle_expired_keys, ранее брался запас 45 мин для выгрузки, сейчас EXPIRED_KEYS_CHECK_INTERVAL * 1.5 для корректной проверки и запаса при повторных проверках.

При работе DELETE_KEYS_DELAY добавляется кнопка продления.
2025-01-30 21:48:19 +03:00
GitHub Actions 79a9a95ffc Auto-format code with Ruff using pyproject.toml 2025-01-29 04:18:09 +00:00
Zakhar Izmaylov ee9e65d692 Minor whitespace adjustment in database connection closing 2025-01-29 07:17:53 +03:00
Zakhar Izmaylov 9bc135a51a Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:17:28 +03:00
Zakhar Izmaylov fabdfc6ab6 Improve type hinting for database backup function
Update return type annotation for `_create_database_backup()` to use explicit tuple type syntax, enhancing type clarity and compatibility with modern Python type hinting practices.
2025-01-29 07:16:21 +03:00
GitHub Actions dff378ad81 Auto-format code with Ruff using pyproject.toml 2025-01-29 04:15:40 +00:00
Zakhar Izmaylov b1739cf4ea Optimize import statements and remove unused imports across multiple files
This commit focuses on cleaning up import statements by:
- Removing unused imports
- Organizing import order
- Removing redundant imports
- Ensuring consistent import style across different modules
2025-01-29 07:15:20 +03:00
GitHub Actions d47952fe3e Auto-format code with Ruff using pyproject.toml 2025-01-29 04:01:20 +00:00
Zakhar Izmaylov 74d58ce310 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:01:01 +03:00
Vladislav Lisitsyn 5f43bb6e55 add admin-panel by hteppl
reorganizing and optimizing the admin panel
2025-01-28 19:36:54 +03:00
Oleg Kraev f7c906f608 Merge branch 'main' into dev 2025-01-24 23:35:19 +03:00
Zakhar Izmaylov e97e3060f7 Add inline button for user profile in admin panel message processing. Enhanced user interaction by including a button that directs users to their personal account, improving the overall user experience. 2025-01-23 14:43:39 +03:00
Zakhar Izmaylov b6ebfd5c1e Enhance error handling in backup cleanup process. Added a generic exception handler to log errors during the removal of old backups, improving robustness and maintainability of the backup functionality. 2025-01-23 12:10:08 +03:00
Zakhar Izmaylov 3d67311e61 Update button labels from "🔙 Назад" to "⬅️ Назад" across multiple admin handlers and key management files for improved user interface consistency. This change enhances the visual clarity of navigation options within the application. 2025-01-23 12:06:38 +03:00
Zakhar Izmaylov 7bfbf89054 Refactor trial management function names for clarity. Renamed set_trial to update_trial in database.py and updated all related handler calls in start.py, admin_user_editor.py, and trial_key.py to reflect this change. This improves code readability and consistency across the codebase. 2025-01-23 12:03:01 +03:00
GitHub Actions 43959a996e Auto-format code with Ruff using pyproject.toml 2025-01-23 09:00:26 +00:00
Zakhar Izmaylov 591370ce60 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 12:00:10 +03:00
Zakhar Izmaylov bb803ab145 Implement new database functions for server and coupon management. Added check_server_name_by_cluster, get_coupon_details, get_referral_by_referred_id, and get_all_keys to enhance data retrieval capabilities. Updated handlers to utilize these new functions, improving code clarity and maintainability. Refactored existing database calls in start.py, utils.py, and keys.py for consistency and better session management. 2025-01-23 11:58:35 +03:00
GitHub Actions 07d14a301b Auto-format code with Ruff using pyproject.toml 2025-01-23 08:40:44 +00:00
Zakhar Izmaylov 56af0a7885 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 11:40:25 +03:00
Zakhar Izmaylov c2ba4e9ab6 Refactor server management and database interactions. Renamed get_servers_from_db to get_servers for clarity and updated related handlers to utilize session management. Refactored add_server_to_db to create_server for consistency. Improved code readability and maintainability across coupon and notification handlers by streamlining database calls and enhancing error handling. 2025-01-23 11:36:15 +03:00
GitHub Actions 7b372e095e Auto-format code with Ruff using pyproject.toml 2025-01-23 08:28:39 +00:00
Zakhar Izmaylov 33f98276ce Enhance database interaction in coupon and server management. Updated check_unique_server_name to accept session and cluster name parameters for improved uniqueness checks. Introduced new functions: get_coupon_by_code, delete_server, create_coupon_usage, check_coupon_usage, and update_coupon_usage_count for better coupon management. Refactored handlers to utilize these new functions, improving code clarity and maintainability. 2025-01-23 11:28:21 +03:00
Zakhar Izmaylov 8818d91232 Refactor database interaction in key management functions to utilize session objects for improved performance and consistency. Updated get_keys, get_keys_by_server, update_balance, and update_key_expiry functions to accept a session parameter, allowing for better session management across handlers. Adjusted related handler functions to pass the session, enhancing code clarity and maintainability. 2025-01-23 11:06:25 +03:00
Zakhar Izmaylov 0aab2096a5 Refactor database interaction functions in database.py and update related handlers. Renamed functions for clarity: save_temporary_data to create_temporary_data, add_blocked_user to create_blocked_user, remove_blocked_user to delete_blocked_user, and delete_coupon_from_db to delete_coupon. Updated trial management function from restore_trial to set_trial to better reflect its purpose. Adjusted corresponding calls in notifications, start, user, and admin handlers for consistency and improved readability. 2025-01-23 10:38:32 +03:00
Zakhar Izmaylov 5a3f54c3a7 Refactor client management in key_utils.py and trial_key.py to utilize ClientConfig dataclass for improved parameter handling. This change enhances code clarity and maintainability by consolidating client configuration parameters into a single structured object, streamlining the add_client function calls across multiple handlers. 2025-01-23 10:24:16 +03:00
Zakhar Izmaylov 79ebe6b6bc Refactor backup sending logic in backup.py to remove redundant document sending for admin IDs. Streamlined the process by ensuring only the necessary admin IDs receive the backup document, enhancing code clarity and reducing potential errors in the backup notification process. 2025-01-23 08:54:22 +03:00
Zakhar Izmaylov 6c323dc7df Refactor client configuration and improve code clarity in client.py and admin_servers.py. Added a new line in ClientConfig for better readability and updated the extend_client_key function signature for consistency. Corrected a TODO comment format in admin_servers.py for improved code standards. 2025-01-23 08:50:04 +03:00
Zakhar Izmaylov 94980d4524 Refactor client management functions in client.py to use a dataclass for client configuration. Update add_client, extend_client_key, and delete_client functions to improve parameter handling and logging. Enhance error handling and documentation for better clarity and maintainability. 2025-01-23 08:49:03 +03:00
GitHub Actions 78c00b5454 Auto-format code with Ruff using pyproject.toml 2025-01-23 05:42:01 +00:00
Zakhar Izmaylov 9437ee9c90 Refactor key retrieval and management logic across multiple handlers. Update get_key_details to include email in the fetched record and replace direct SQL queries with dedicated functions for improved code clarity and maintainability. Add a TODO comment in admin_servers.py for future enhancements. 2025-01-23 08:41:44 +03:00
Zakhar Izmaylov 4addb772a0 Add server management functionality by introducing add_server_to_db function for streamlined database interactions. Refactor server addition logic in sync_servers_with_db and admin handlers to utilize the new function, enhancing code clarity and session management. 2025-01-23 08:03:58 +03:00
Zakhar Izmaylov a1d051a3f1 Refactor key management functions to improve database interactions by consolidating key deletion logic into a single function. Introduce timezone-aware expiry date calculations for keys and enhance key retrieval processes across various handlers. Update the start command to utilize a dedicated function for adding connections, ensuring cleaner code and better session management. 2025-01-23 07:49:21 +03:00
Zakhar Izmaylov c4d3c921bd Refactor code to utilize aiofiles for asynchronous file handling across multiple modules. Added a new function to remove blocked users from the database and updated user blocking/unblocking logic to use this new function. Improved database interaction in admin panel handlers by passing session objects instead of creating new connections. This enhances performance and maintains consistency in file operations. 2025-01-23 06:34:45 +03:00
GitHub Actions 1596c85bee Auto-format code with Ruff using pyproject.toml 2025-01-23 03:15:11 +00:00
Zakhar Izmaylov 3d4a72d3fd Update .gitignore to include logs and setup.py, and remove unnecessary entries 2025-01-23 06:14:48 +03:00
Zakhar Izmaylov ad9c49f841 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 06:14:33 +03:00
Zakhar Izmaylov cc2b9b52c5 Update balance retrieval to round values to one decimal place in database.py 2025-01-23 05:49:24 +03:00
Vladless 5a1d4c1b24 minor_fixes 2025-01-22 18:46:16 +03:00
Zakhar Izmaylov 99cc4b26bc Add directory creation for database backups in backup.py 2025-01-22 10:31:03 +03:00
Vladless 970511ca1d location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
Vladless b81a0f289f location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
hteppl 13259b435b Implement .github folder for github-related data 2025-01-21 18:04:41 +03:00
Zakhar Izmaylov 44cadc29ba Refactor file handling to use aiofiles for asynchronous operations
- Replaced synchronous file reading with asynchronous file handling using aiofiles in multiple handlers and the backup module.
- Improved performance and responsiveness when sending images and backups by utilizing async file operations.
- Ensured consistent use of BufferedInputFile across all relevant functions for better code maintainability.
2025-01-21 12:03:56 +03:00
Zakhar Izmaylov a2c71a49f2 Refactor and clean up code formatting across multiple files
- Consolidated and simplified code formatting by removing unnecessary line breaks and improving readability in various functions.
- Updated the handling of backup file sending in backup.py for better clarity.
- Streamlined error handling and logging messages in several handlers to enhance consistency.
- Adjusted the Makefile to exclude specific files during Ruff checks and formatting.
- Made minor adjustments to function signatures and parameter handling for improved clarity and consistency.

This commit enhances code maintainability and readability without altering functionality.
2025-01-21 08:57:12 +03:00
Zakhar Izmaylov e7d54ae317 Refactor private message filter in bot.py
- Renamed IsPrivate to IsPrivateFilter for clarity and consistency.
- Updated message and callback query filters to use the new IsPrivateFilter class.
2025-01-20 22:38:52 +03:00
Zakhar Izmaylov 1459bdacfa Refactor captcha response handling in check_captcha function
- Removed the deletion of the original message upon correct captcha response.
- Changed the method of sending the captcha text and markup from edit_text to answer for better user experience.
2025-01-20 22:31:14 +03:00
Zakhar Izmaylov a9e111bd4f Add captcha functionality to start command handler
- Included captcha router in main handlers.
- Integrated captcha generation in the start command, conditional on CAPTCHA_ENABLE setting.
2025-01-20 22:27:39 +03:00
Zakhar Izmaylov b63c3fb115 Update work from blocked_users 2025-01-20 03:47:16 +03:00
Vladislav Lisitsyn a0a6686058 Merge pull request #133 from hteppl/admin-redo
Обновление логики работы панели для Администраторов
2025-01-20 03:39:38 +03:00
Zakhar Izmaylov 4c4a1a297c Fix errors_handler 2025-01-19 09:16:56 +03:00
Zakhar Izmaylov fc4bb88db8 Fix and update Middleware 2025-01-19 09:15:42 +03:00
Zakhar Izmaylov e17cde7c72 Update errors_handler 2025-01-19 08:59:43 +03:00
Zakhar Izmaylov 3d7684b72b Small fix 2025-01-19 08:49:46 +03:00
Zakhar Izmaylov 51558aeeda Update requirements.txt 2025-01-19 08:36:18 +03:00
Zakhar Izmaylov d83da5daaa Add filter and middleware 2025-01-19 08:29:10 +03:00
hteppl 67b2da3447 Implement expiry time printing in key names v2 2025-01-19 06:05:45 +03:00
hteppl eeb330b21f Implement expiry time printing in key names 2025-01-19 06:05:22 +03:00
hteppl efff92b2b3 Fix some imports 2025-01-19 05:37:01 +03:00
hteppl 4d5cc954b5 Introduce changing key expiry date keyboard 2025-01-19 05:35:20 +03:00
hteppl 1e204a3e29 Remove get_key_details unused fields for now 2025-01-18 04:54:37 +03:00
hteppl 98d0c03aee Feat https://github.com/Vladless/Solo_bot/commit/b6184d4c90deed7162f618bdebefa44f3e0ee348 2025-01-18 04:36:59 +03:00
hteppl 1a29bb6dd5 Fix bouncing messages while handle user input for changing balance 2025-01-18 04:21:40 +03:00
hteppl 0959742f5b Fix some telegram exceptions while editing the same content + add forward option to users search 2025-01-18 04:09:21 +03:00
hteppl 1c505b74d5 Add and fix some keyboards while creating new cluster or server 2025-01-18 04:08:31 +03:00
Oleg Kraev 96a6612124 Merge pull request #9 from hteppl/main
Changes from upstream
2025-01-17 21:51:08 +03:00
hteppl 78181e7230 Changes from upstream 2025-01-17 21:50:35 +03:00
hteppl 66bba43ec4 Reimage update_subscription callback to work as function 2025-01-17 21:44:54 +03:00
hteppl 4140fbae78 Update balance changing system + fix some formatting 2025-01-17 20:42:45 +03:00
hteppl e4a4d68758 Oops fix imports 2025-01-17 18:25:50 +03:00
hteppl 478d2ee0e6 Introduce utils module + move csv exports logic 2025-01-17 18:25:09 +03:00
Zakhar Izmaylov 588c32453d Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-17 01:54:11 +03:00
Zakhar Izmaylov ce6186fa17 Update backlog 2025-01-17 01:49:24 +03:00
Zahar Izmailov a617b940c1 Merge branch 'Vladless:main' into main 2025-01-17 01:43:03 +03:00
hteppl c370d32154 Reorganise backups creation 2025-01-16 11:00:38 +03:00
hteppl 89f329458c Rename handlers functions with actual names + delete parse_mode 2025-01-16 09:20:33 +03:00
hteppl 4df9140cf8 Delete legacy admin_user_editor.py 2025-01-16 09:11:18 +03:00
hteppl 8e74c12bca Implement bot version in admin main menu 2025-01-16 09:08:35 +03:00
Vladislav Lisitsyn 4e280d1b3e Merge pull request #132 from JustYay/main
optimize subscriptions
2025-01-16 04:16:15 +03:00
Vladless c158c1339f backup_update 2025-01-16 04:15:17 +03:00
Vladless 9db8fdf79b expired_time 2025-01-16 04:03:53 +03:00
Vladless b6184d4c90 fix cluster_name in admin_panel 2025-01-16 03:03:37 +03:00
Vladless 981dc0db01 pytz for notifications 2025-01-16 01:50:34 +03:00
Oleg Kraev 0403ad5688 Merge pull request #6 from hteppl/main
Changes from upstream
2025-01-15 22:05:55 +03:00
hteppl f05b7416ef Admin update 2025-01-15 22:05:32 +03:00
Vladless 1d433d2cfd fix trial_key for SuperNode 2025-01-15 20:16:52 +03:00
Vladless 17a0fa0b21 SuperNode/SyncTimeout/pytz 2025-01-15 18:46:33 +03:00
hteppl 7154374fdc Update stats, fix some text input flows to working with delete middleware 2025-01-15 14:29:33 +03:00
hteppl 34b1927c22 Some missing logic and fixups 2025-01-15 14:07:49 +03:00
Boris Kovalskii 1e9367ab93 optimize subscriptions
Get and update subscriptions faster
2025-01-15 21:05:00 +10:00
hteppl a7d0cca66c Do not import bot directly in admin sender 2025-01-15 09:01:56 +03:00
hteppl e3bb7f6663 Remove direct key search, use common user searcher anytime 2025-01-15 09:01:10 +03:00
hteppl 450b8e17f0 Update delete middleware: pass all admin callbacks and states 2025-01-15 08:59:54 +03:00
hteppl f176a11ff9 Remove legacy admin_user_editor from release 2025-01-15 08:02:04 +03:00
hteppl aadd2edf45 Optimize and fix admin handlers logic 2025-01-15 08:01:16 +03:00
hteppl a02062a4b4 Optimize and fix keyboards 2025-01-15 08:00:41 +03:00
hteppl 0092764f8f Implement banned by users handler 2025-01-15 07:58:46 +03:00
hteppl 55b065f784 Admin keyboards adjustments 2025-01-15 03:37:01 +03:00
Zakhar Izmaylov eafe0a78d2 Add page coupons 2025-01-15 02:39:01 +03:00
Zakhar Izmaylov 9da808a024 Delete parse_mode 2025-01-15 02:15:21 +03:00
Vladless 3d295743e2 SuperNode/SyncTimeout/pytz 2025-01-14 23:35:19 +03:00
Vladless 6f8bc2b08d SuperNode/SyncTimeout/pytz 2025-01-14 15:53:27 +03:00
Vladless 32d0ac81bd SuperNode/SyncTimeout/pytz 2025-01-14 15:12:06 +03:00
Vladless 35f214a45f SuperNode/SyncTimeout/pytz 2025-01-14 12:16:14 +03:00
Vladless 8fdc78c2e8 FlowPayRenew/fixNotify/pay_history/custom/statistic/minorFixes 2025-01-14 04:06:55 +03:00
Vladless 6943794e50 add_stats 2025-01-13 03:58:58 +03:00
Vladless 086e3fe5b0 fix_profile 2025-01-13 02:03:09 +03:00
Vladislav Lisitsyn d247b764f7 Merge pull request #130 from izzzzzi/main
Backlog
2025-01-13 02:00:32 +03:00
Zakhar Izmaylov 283016f043 Delete Throttle 2025-01-12 18:19:49 +03:00
hteppl 7c19492c05 Fix some typo and quotes 2025-01-12 17:35:42 +03:00
hteppl 054d710d6c Register all the missing routers 2025-01-12 17:32:11 +03:00
hteppl f11b014acf Implement admin sender handler 2025-01-12 17:29:48 +03:00
hteppl ab27328cb7 Implement admin servers handler 2025-01-12 17:07:57 +03:00
hteppl 9b98faedfd Rename admin users editor handler to users handler 2025-01-12 17:01:39 +03:00
hteppl e957fbdfa1 Implement admin users editor handler 2025-01-12 16:58:17 +03:00
Oleg Kraev 94273b5ee2 Merge pull request #5 from hteppl/upstream1
Changes from upstream
2025-01-12 16:56:52 +03:00
Oleg Kraev 87d92b7dae Merge branch 'admin-redo' into upstream1 2025-01-12 16:56:41 +03:00
Zakhar Izmaylov 9a823eddf3 Add check_users_and_update_blocked 2025-01-11 20:29:01 +03:00
Zakhar Izmaylov 52d124c879 Add timeout 2025-01-11 18:43:14 +03:00
Zakhar Izmaylov 287d0a635b Update stats 2025-01-11 18:10:43 +03:00
Zakhar Izmaylov 1057fd6ef6 Add balance_history 2025-01-11 18:03:59 +03:00
Zakhar Izmaylov d935a9cd49 Update config lint 2025-01-11 17:51:24 +03:00
hteppl 97d598a293 Implement admin stats handler 2025-01-11 02:24:16 +03:00
hteppl c9664c136b Implement admin restart handler + also handle AdminPanelCallback for back buttons 2025-01-11 02:17:54 +03:00
hteppl 0b0fca8648 Add more base functions for creating singleton admin keyboards 2025-01-11 02:17:02 +03:00
Vladislav Lisitsyn 9577c8272f Merge pull request #129 from izzzzzi/main
Add stats
2025-01-11 02:01:40 +03:00
Zakhar Izmaylov 0d82294bdb Add antiflood and lint 2025-01-10 14:51:13 +03:00
Zakhar Izmaylov d40c845d62 Add stats 2025-01-10 14:38:28 +03:00
Vladless 70e3f5c714 syncClaster/picGifts/mailingCluster/renewButtin and more 2025-01-10 00:02:13 +03:00
hteppl 8a15d0320c Implement admin backups handler 2025-01-09 15:49:23 +03:00
hteppl 73bf682f97 Update admin coupons handler 2025-01-09 15:48:32 +03:00
hteppl ba53e9e199 Update admin main panel 2025-01-09 15:43:22 +03:00
hteppl e13937b965 Introducing keyboards package 2025-01-09 15:13:36 +03:00
Vladless 7758415ddf fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 06:00:50 +03:00
Vladless a19c325e15 fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 03:16:53 +03:00
Vladless 10df771a15 fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 02:40:39 +03:00
Vladislav Lisitsyn 6cd7e8acee Merge pull request #128 from izzzzzi/main
Fix process_message_to_all
2025-01-06 19:20:45 +03:00
Zakhar Izmaylov d8944a9c52 Update version 2025-01-06 15:29:24 +03:00
Zahar Izmailov 930ce0fcdc Merge branch 'Vladless:main' into main 2025-01-06 12:10:08 +03:00
Zakhar Izmaylov fceff14d0a Fix process_message_to_all 2025-01-06 12:09:15 +03:00
Vladislav Lisitsyn 16e31d4abb Merge pull request #127 from izzzzzi/main
Backlog
2025-01-06 02:57:04 +03:00
Zakhar Izmaylov 5289548fd9 Fix float get_usd_rate 2025-01-05 23:46:45 +03:00
Zakhar Izmaylov 681fd7d92c Add get_usd_rate 2025-01-05 23:43:04 +03:00
Zakhar Izmaylov 932ef51de4 Add update_all_keys 2025-01-05 23:32:18 +03:00
Zakhar Izmaylov 080a99cd55 Update update_subscription user 2025-01-05 23:24:01 +03:00
Zakhar Izmaylov 93ac01592f Update send_to_alls 2025-01-05 23:19:45 +03:00
Zakhar Izmaylov 1742519659 Add update user 2025-01-05 23:11:05 +03:00
Vladislav Lisitsyn 954c6b6ae7 Merge pull request #126 from hteppl/fix-trial
Фикс проверки тестовых подписок
2025-01-05 18:54:58 +03:00
hteppl 50811afa30 fix: typo in error message 2025-01-04 20:34:49 +03:00
hteppl afc913a2ff fix: check trial key already used 2025-01-04 15:30:34 +03:00
Vladislav Lisitsyn bab6a807f3 Merge pull request #125 from hteppl/fix-tv-preview
Удаление превью ссылок в сообщении текста для ТВ
2024-12-29 02:26:05 +03:00
hteppl 6329dfd3ef Fix CONNECT_TV_TEXT: add disable_web_page_preview=True 2024-12-29 02:05:47 +03:00
Vladless 6484bf2c6e TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:46:20 +03:00
Vladless 1e64817e3f TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:28:38 +03:00
Vladless 15fef698d9 TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:22:56 +03:00
Vladislav Lisitsyn c108ec4b2d Merge pull request #124 from hteppl/mics-updates
Небольшие изменения
2024-12-26 23:03:38 +03:00
hteppl 5d70576582 Fix key back button 2024-12-26 03:40:26 +03:00
hteppl 177cbb8881 Remove Telegram URL prefix when finding a user by username 2024-12-26 03:36:54 +03:00
hteppl 7d8050ce1c Monthly payments in admin stats 2024-12-26 03:36:28 +03:00
Vladislav Lisitsyn fadafecb43 Merge pull request #123 from hteppl/license
Лицензия и обновленный README
2024-12-24 17:40:48 +03:00
Vladless ab6af88114 gift_links 2024-12-24 15:02:48 +03:00
Vladless 3b7bb5436b buttons_pay/security_update/MacOS 2024-12-24 02:30:53 +03:00
Vladless bfe8ed8b3c buttons_pay/security_update 2024-12-24 02:30:09 +03:00
hteppl 95b338a1c2 Update README.md 2024-12-23 16:01:16 +03:00
hteppl 072a90a028 Add CC BY-NC 4.0 LICENSE 2024-12-23 16:00:48 +03:00
hteppl 933c8100e4 Remove legacy installation scripts 2024-12-23 16:00:26 +03:00
Vladless 4951a5cf78 minor update 2024-12-23 01:23:34 +03:00
Vladless 5e904f3045 gift_code/buttons/commands 2024-12-23 01:14:28 +03:00
Vladless 0840a07ed9 add support for business/optimization 2024-12-20 23:29:27 +03:00
Vladless 2becee7f02 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:39:07 +03:00
Vladless ae22950ce3 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:37:41 +03:00
Vladislav Lisitsyn 169b276ef1 Merge pull request #122 from hteppl/fix-referrals
WIP: фикс уявзимости, где айди приглашенного реферала, совпадает с айди пригласившего
2024-12-16 23:41:29 +03:00
hteppl 5966555ae9 Fix self-referred bug 2024-12-16 23:19:39 +03:00
Vladislav Lisitsyn de27fa24d4 Merge pull request #120 from crell2100/main
add CHANEL_EXISTS variable
2024-12-16 23:09:42 +03:00
crell2100 0b0b002c82 donats_enable->donations_enable (rename variable) 2024-12-16 16:40:01 +03:00
crell2100 0e4f59a242 Merge branch 'main' into main 2024-12-15 22:51:32 +03:00
crell2100 051714e613 add DONATS_ENABLE variable 2024-12-15 22:30:20 +03:00
crell2100 c780b1ffd7 add CHANEL_EXISTS variable 2024-12-15 18:07:52 +03:00
Vladless 88caa0bab3 remove gifts 2024-12-15 00:23:08 +03:00
Vladless e6b071f64d add import 2024-12-15 00:23:08 +03:00
Vladless 9a809aa646 add import 2024-12-15 00:23:08 +03:00
Vladislav Lisitsyn 52bdf10e0e Update README.md 2024-12-14 21:24:42 +03:00
Vladislav Lisitsyn 1642bde410 Merge pull request #119 from TrackLine/main
Add Yoomoney pay service
2024-12-14 19:07:25 +03:00
TrackLine 2fdb29d517 Add Yoomoney pay service 2024-12-14 19:49:54 +04:00
Vladless ad8d28f7f0 sub_name 2024-12-14 18:46:43 +03:00
Vladless a4776d7682 fix middleware 2024-12-14 17:39:39 +03:00
Vladislav Lisitsyn 3c7451e8fc Merge pull request #118 from izzzzzi/main
Migrate to ruff
2024-12-14 02:02:09 +03:00
Vladislav Lisitsyn f65ed3eada Merge branch 'main' into main 2024-12-14 02:02:00 +03:00
Vladless b4c47faac9 vat_code 2024-12-14 00:37:20 +03:00
Vladless 217bb457a1 sub_name/delete user 2024-12-13 23:01:58 +03:00
Zakhar Izmaylov ce79caa58d Fix requirements.txt 2024-12-13 12:13:50 +03:00
Zakhar Izmaylov 76d72ba203 Migrate to ruff 2024-12-13 12:09:20 +03:00
Vladless 035ef47619 rm wrench 2024-12-11 15:41:45 +03:00
Vladislav Lisitsyn 4792abc6d5 Merge pull request #113 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 04:14:24 +02:00
Vladless 42ac740589 pic for tariffs 2024-12-10 05:13:54 +03:00
Vladless d5371e4c04 pic for tariffs 2024-12-10 05:12:49 +03:00
Vladless 7c883b07f9 Revert "Merge pull request #112 from Vladless/hteppl-optimize-sources"
This reverts commit fa681fac92, reversing
changes made to b31c9d2e10.
2024-12-10 03:53:07 +03:00
Vladislav Lisitsyn fa681fac92 Merge pull request #112 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 02:27:34 +02:00
Vladless d1a9691c87 Merge branch 'optimize-sources' of https://github.com/hteppl/Solo_bot into hteppl-optimize-sources 2024-12-10 03:26:42 +03:00
Vladislav Lisitsyn b31c9d2e10 Merge pull request #108 from hteppl/optimize-routing-system-v2
Оптимизация системы рутинга 2 // LP
2024-12-10 00:54:32 +02:00
Vladislav Lisitsyn 11176a16bc Merge pull request #104 from hteppl/create-sql-schema
Оптимизация создания базы данных // LP
2024-12-10 00:54:22 +02:00
hteppl 39aaa88532 Make utils as package + fix python unresolved imports 2024-12-08 05:32:23 +03:00
hteppl cf6fcdbb76 Move payments utils into utils folder 2024-12-08 05:13:38 +03:00
hteppl 4e09e04dd9 Move keys utils into utils folder 2024-12-08 05:11:31 +03:00
hteppl e0639c7116 Move utils.py to the utils folder 2024-12-08 05:02:41 +03:00
hteppl 1d915f849c Move util-related files into separated folder + refactor imports 2024-12-08 04:57:20 +03:00
Oleg Kraev 404d502d2f Merge branch 'Vladless:main' into optimize-routing-system-v2 2024-12-08 04:37:31 +03:00
hteppl bcfb4c7942 Implement global router 2024-12-08 04:36:59 +03:00
hteppl bd5cc56441 Include routers only if payment method enabled in the config 2024-12-08 04:23:59 +03:00
hteppl bc42b0e52b Create and implement router for admin 2024-12-08 04:21:37 +03:00
hteppl 71e549866b Create and implement router for instructions 2024-12-08 04:20:09 +03:00
hteppl ca9dd17450 Create and implement router for keys 2024-12-08 04:19:16 +03:00
hteppl 91f12fe627 Create and implement router for payments 2024-12-08 04:17:50 +03:00
hteppl 1406e87ab3 Create basic main router for handlers 2024-12-08 04:15:52 +03:00
Vladislav Lisitsyn 9398199f25 Merge pull request #107 from hteppl/linux-start-scripts
Linux cкрипты для автоматической настройки зависимостей и запуска
2024-12-07 15:32:56 +02:00
Vladless 6ed83097de add pay button 2024-12-07 16:32:16 +03:00
Vladislav Lisitsyn 4d38ab0d3a Update README.md 2024-12-07 00:27:53 +03:00
Vladislav Lisitsyn d955a6ccfc Update README.md 2024-12-07 00:17:48 +03:00
hteppl 82da0cbf53 Add linux shell scripts to configure dependencies automatically 2024-12-06 18:39:35 +03:00
Vladless 360d73dd26 ping servers and notifications 2024-12-06 05:19:44 +03:00
Vladless 14400018c9 ping servers and notifications 2024-12-06 04:48:58 +03:00
Vladless f845baa3d0 back button in custom pay 2024-12-06 02:25:40 +03:00
Vladless 54e6c2aaaa back button in custom pay 2024-12-06 02:25:18 +03:00
Vladless 240ad42beb preview pc_instruction disable 2024-12-06 01:42:14 +03:00
Vladless a8d8972855 Server availability 2024-12-06 01:14:25 +03:00
Vladless b70b432d05 logs folder and autoremove 2024-12-06 00:46:54 +03:00
Vladless 77d6b8c67c sales in texts 2024-12-05 23:56:06 +03:00
Vladless 9251087d2c backup for 3x-ui 2024-12-05 23:31:48 +03:00
Vladless 5f2f3ad683 fix refferal 2024-12-05 22:59:34 +03:00
Vladless 0f507aa101 fix refferal 2024-12-05 22:45:10 +03:00
Vladless 99574ce751 backup time 2024-12-05 21:09:37 +03:00
hteppl 441e7ef696 Create schema.sql database template + optimize init_db 2024-12-05 07:59:49 +03:00
Vladless 8395c29ae2 fix trial 2024-12-04 17:25:32 +03:00
Vladless ff37c48579 change version 2024-12-04 03:10:15 +03:00
Vladless 8e778eb7af unique server_name/ link moderation 2024-12-04 03:02:29 +03:00
Vladless 2f6b85d4a6 unique server_name 2024-12-04 02:46:57 +03:00
Vladless bb034ba6c6 plan on create new key after trial 2024-12-04 01:28:28 +03:00
Vladless d86acbfb9d admin_update 2024-12-04 00:16:23 +03:00
Vladless baf0e2d695 add button for MacOS 2024-12-04 00:06:21 +03:00
Vladless b58079d86f Clusters on/off in config 2024-12-02 04:58:14 +03:00
Vladless ee2ae3f14d profile/reorganization and news 2024-12-02 04:46:05 +03:00
Vladless 5420c38592 sale size 2024-12-02 04:20:32 +03:00
Vladless 2a3d2098c9 rename server info 2024-12-02 04:08:36 +03:00
Vladless 36a44f3669 move button 2024-12-02 04:06:46 +03:00
Vladless ccbe82a140 pc button for trial_key 2024-12-02 03:54:07 +03:00
Vladless 8b6afcf571 pc button for trial_key 2024-12-02 03:37:28 +03:00
Vladless 74353143cf add/delete servers in admin 2024-12-02 03:25:06 +03:00
Vladless 7f48f5b7b5 servers in admin_editor 2024-12-02 00:28:33 +03:00
Vladless 5bb83fe904 servers in database 2024-12-01 23:40:37 +03:00
Vladless bfdf5f9f41 servers in database 2024-12-01 23:24:53 +03:00
Vladless d7d0e3da42 autoconfig servers in database 2024-12-01 23:24:13 +03:00
Vladless 11c84eae82 notification inactive users 2024-12-01 22:24:00 +03:00
Vladless cd7db1330e sudo in restart 2024-11-29 00:01:48 +03:00
Vladless 24c1f8b9cb autoname for trial in profile 2024-11-28 20:48:34 +03:00
Vladless 8c62573664 /start 2024-11-28 15:37:21 +03:00
Vladless fc1dda5597 link Successful payment 2024-11-27 21:18:07 +03:00
Vladless 9e899f4ec6 fix stars 2024-11-27 17:30:53 +03:00
Vladless bb2fea1396 update subs 2024-11-26 20:10:14 +03:00
Vladless 9a05ff82e4 fix admin 2024-11-26 20:03:21 +03:00
Vladless 21e82eed79 notifications 2024-11-26 17:48:41 +03:00
Vladless ca1a279144 fix renew 2024-11-26 17:30:41 +03:00
Vladless 4e6eb4ea7f random email for add device 2024-11-26 17:21:15 +03:00
Vladislav Lisitsyn e3787a041c Merge pull request #102 from izzzzzi/main
Refactor
2024-11-26 03:25:33 +03:00
Zakhar Izmaylov cd411c6b49 Fix start 2024-11-26 03:21:43 +03:00
Zakhar Izmaylov 9acc9d8304 Small fix 2024-11-26 02:54:32 +03:00
Zakhar Izmaylov cb19c3829f Enhanced notifications and admin: CSV export for users/payments, better feedback/navigation, DEV_MODE checks for notifications, .csv in .gitignore, fixed trial expiry with TRIAL_TIME. 2024-11-26 02:52:15 +03:00
Zahar Izmailov b42cc6b484 Merge branch 'Vladless:main' into main 2024-11-26 00:27:58 +03:00
Vladless 4cf1f5dad8 back button 2024-11-25 01:16:06 +03:00
Vladless d863eb1ed9 fix session 2024-11-25 00:58:46 +03:00
Zakhar Izmaylov cbd0902e42 Refactor notification and admin user editor handlers to improve user feedback. Comment out inactive trial user notifications in notifications.py. Enhance error messages with consistent "Назад" button for better navigation in admin_user_editor.py. 2024-11-25 00:29:06 +03:00
Zakhar Izmaylov fa2b45104c Update button texts in start and admin user editor handlers for improved navigation. Change "Личный кабинет" to "Поддержка" in start.py and adjust "Назад" callback data in admin_user_editor.py for consistency. 2024-11-25 00:16:12 +03:00
Zakhar Izmaylov bac2dbaf85 Refactor import statements in notification, profile, key management, and keys handlers for improved organization and consistency. Ensure RENEWAL_PLANS is included in all relevant files. 2024-11-24 23:45:38 +03:00
Zakhar Izmaylov f9e29f4fa9 Refactor notification and profile handlers to include RENEWAL_PLANS from config. Update key management and keys handlers to streamline imports and enhance code organization. 2024-11-24 23:45:27 +03:00
Zahar Izmailov 82985aa96e Merge branch 'Vladless:main' into main 2024-11-24 23:37:58 +03:00
Vladless d34f49c9b2 3.2 2024-11-24 23:10:22 +03:00
Vladless bbe355008a reset traffic 2024-11-24 23:02:34 +03:00
Zakhar Izmaylov ae115ba13b Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 22:37:23 +03:00
Zakhar Izmaylov dc7da4441a Remove unused __init__.py file and update payment handler callback buttons to unify navigation. Change "back_to_profile" callback to "pay" in multiple payment processing files for consistency. 2024-11-24 22:35:43 +03:00
Vladless d91465c435 ID_Inbound 2024-11-24 22:29:32 +03:00
Vladless 9cb9b9441a ID_Inbound 2024-11-24 22:29:05 +03:00
Vladislav Lisitsyn ac3a4a397f Merge pull request #100 from izzzzzi/main
Refactoring and Update
2024-11-24 21:54:53 +03:00
Vladislav Lisitsyn 995964f70a Merge branch 'main' into main 2024-11-24 21:54:30 +03:00
Zakhar Izmaylov 708b0be31f Refactor database notification query to remove unnecessary string conversion for hours parameter, improving data handling efficiency. 2024-11-24 20:48:16 +03:00
Zakhar Izmaylov 052124d483 Refactor database and notification handlers for improved readability and consistency. Clean up import statements, enhance logging messages, and streamline function calls across multiple files. Ensure proper session handling in payment processing functions. 2024-11-24 20:29:22 +03:00
Vladless b40bf3f833 fix back cryptobot 2024-11-24 20:19:23 +03:00
Zakhar Izmaylov 11d7005954 Enhance notification system for inactive trial users by adding notification time checks and logging. Implement functionality to record notifications sent to users, ensuring they are notified no more than once every 24 hours. 2024-11-24 20:02:40 +03:00
Zakhar Izmaylov 7893be6ef8 Refactor referral system to support multi-level bonuses and enhance logging. Update database interactions for referral handling and statistics retrieval. Change key display format in notifications and admin user editor for better readability. 2024-11-24 19:38:59 +03:00
Zakhar Izmaylov 18ca2b275c Update table 2024-11-24 14:14:06 +03:00
Zakhar Izmaylov bc333b12c2 Fix delete msg 2024-11-24 13:51:18 +03:00
Zakhar Izmaylov c3e052f73c notify_inactive_trial_users 2024-11-24 11:58:43 +03:00
Zakhar Izmaylov 29f606736b Fix admin 2024-11-24 11:51:17 +03:00
Zakhar Izmaylov ce17181be5 Update connection_exists 2024-11-24 11:42:29 +03:00
Zakhar Izmaylov 041c8e1511 Fix srssion 2024-11-24 11:26:54 +03:00
Zakhar Izmaylov 270f01511d Update id 2024-11-24 11:07:52 +03:00
Zakhar Izmaylov a352901d85 Fix 2024-11-24 10:54:08 +03:00
Zakhar Izmaylov 8e5ce5768a Fix get_trial 2024-11-24 10:35:12 +03:00
Zakhar Izmaylov 97c03d0396 Add logging 2024-11-24 09:15:09 +03:00
Zakhar Izmaylov 1b9b3e6729 Fix trial_status 2024-11-24 08:27:27 +03:00
Zakhar Izmaylov 3f336919ad Fix 2024-11-24 08:13:52 +03:00
Zakhar Izmaylov 8f8e89667d Update InlineKeyboardBuilder 2024-11-24 01:57:42 +03:00
Zakhar Izmaylov 88b05547de Fix 2024-11-24 01:47:37 +03:00
Zakhar Izmaylov c35d067306 Add notify_inactive_trial_users 2024-11-24 01:34:39 +03:00
Zakhar Izmaylov 06489967f8 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:24:10 +03:00
Zakhar Izmaylov 47e45cf596 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:19:06 +03:00
Zakhar Izmaylov bc77c6a5ff Fix typing 2024-11-24 01:19:00 +03:00
Zakhar Izmaylov 31eb551b6d Refactoring 2024-11-24 01:18:42 +03:00
Vladless 122b82253e update clusters 2024-11-23 20:21:47 +03:00
Vladless 5c79302cd6 fix users 2024-11-23 17:30:39 +03:00
Vladislav Lisitsyn 34a99ceb98 Merge pull request #98 from izzzzzi/main
fix ref/serach from username and new stats in admin
2024-11-23 08:18:10 +03:00
Zakhar Izmaylov 576a315baa Fix admin 2024-11-23 07:14:58 +03:00
Zakhar Izmaylov 73965b64dd Add pylint 2024-11-22 21:34:56 +03:00
Zakhar Izmaylov 9673f10af7 Update config 2024-11-22 21:24:55 +03:00
Zakhar Izmaylov 8a9825f629 Fix ref 2024-11-22 17:45:38 +03:00
Zakhar Izmaylov fc830a427c Update text admin 2024-11-22 01:14:23 +03:00
Zakhar Izmaylov 26e8e91cb7 Update push 2024-11-22 01:12:19 +03:00
Zakhar Izmaylov 4cae37ef33 Add search from username 2024-11-22 00:43:23 +03:00
Zakhar Izmaylov a04a6bd5d2 Update user_stats_menu 2024-11-21 21:14:44 +03:00
Zakhar Izmaylov ac632a5e96 Update instructions 2024-11-21 20:49:58 +03:00
Zakhar Izmaylov 6701222697 Update text and add view_tariffs 2024-11-21 20:31:24 +03:00
Zakhar Izmaylov f42fe4236e Fix send_to_alls 2024-11-20 17:30:10 +03:00
Zakhar Izmaylov 3041e0a208 Update admin 2024-11-20 17:18:28 +03:00
Zakhar Izmaylov 61bf3cd129 Add new table and add stats in admin 2024-11-20 16:47:21 +03:00
Zakhar Izmaylov fb34af114f Update logger 2024-11-20 15:46:46 +03:00
Zakhar Izmaylov 56678abbb4 Fix img and logger add button back for donate 2024-11-20 15:36:25 +03:00
Zahar Izmailov 347805a8ae Merge branch 'Vladless:main' into main 2024-11-20 15:28:44 +03:00
Zakhar Izmaylov 2438ac58e1 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-20 09:43:22 +03:00
Zakhar Izmaylov 23b60389c0 Fix 2024-11-20 09:43:16 +03:00
Vladless a58e45133a fix bugs 2024-11-20 00:18:29 +03:00
Vladless 6fc26754bf clusters update 2024-11-19 19:31:31 +03:00
Vladless 8bfdc6e319 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-19 14:58:25 +03:00
Vladless 8d4e32c86c security update 1.0 2024-11-19 14:58:07 +03:00
Vladislav Lisitsyn f16226e48e Update start.py 2024-11-19 11:52:06 +03:00
Vladless 05bef3d389 security update 1.0 2024-11-19 11:43:22 +03:00
Vladless d811ba5623 security update 1.0 2024-11-19 11:21:34 +03:00
Vladislav Lisitsyn abd6c03ba0 Merge pull request #97 from izzzzzi/main
Fix key and session
2024-11-19 09:03:20 +03:00
Zakhar Izmaylov 84a7c1c930 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-19 08:32:33 +03:00
Zakhar Izmaylov 96b8ad8555 Fix key and session 2024-11-19 08:32:30 +03:00
Vladislav Lisitsyn e4f9301591 Merge pull request #96 from izzzzzi/main
Add back button for coupons
2024-11-18 22:51:16 +03:00
Vladless 1eb60dc0c2 renew/delete 2024-11-18 22:40:59 +03:00
Zakhar Izmaylov cbcae665e4 Fix admin 2024-11-18 22:19:17 +03:00
Zakhar Izmaylov 4da82282de Add back button for coupons 2024-11-18 22:03:51 +03:00
Vladless 1548907ead add time in robokassa 2024-11-18 20:03:45 +03:00
Vladless 854c6fa536 fix update_sub 2024-11-18 16:04:29 +03:00
Vladless 09ca65de0c Fixed unexpected behavior of the device selection button 2024-11-18 14:57:12 +03:00
Vladless 52613d9feb Fixed unexpected behavior of the device selection button 2024-11-18 14:56:41 +03:00
Vladislav Lisitsyn 4878cc2eab Merge pull request #95 from izzzzzi/main
Add admin middleware
2024-11-18 12:54:05 +03:00
Zakhar Izmaylov 7e2f56f20b Update Middleware 2024-11-17 23:42:09 +03:00
Zakhar Izmaylov 6b2914bff4 Fix 2024-11-17 16:24:12 +03:00
Zakhar Izmaylov 10b45ec226 Add button 2024-11-17 16:19:47 +03:00
Zakhar Izmaylov 23ec7e3ccb Formatting 2024-11-17 16:15:51 +03:00
Zakhar Izmaylov 3aaa8d1ea3 Fix Middleware 2024-11-17 16:14:35 +03:00
Zakhar Izmaylov a46e8d3a5a Fix admin 2024-11-17 16:10:58 +03:00
Zakhar Izmaylov 6f9ceb1a89 Fix admin 2024-11-17 16:05:20 +03:00
Zakhar Izmaylov d5dbb39444 Add default admin 2024-11-17 16:01:18 +03:00
Zakhar Izmaylov 0f0af0c194 Fix start 2024-11-17 15:53:31 +03:00
Zakhar Izmaylov 545345a020 Fix commands 2024-11-17 15:47:31 +03:00
Zakhar Izmaylov 0766cda29e Fix start 2024-11-17 15:44:41 +03:00
Zakhar Izmaylov 6563b7dc67 Fix 2024-11-17 15:39:53 +03:00
Zakhar Izmaylov 5b8b950aaf Fix admin middleware 2024-11-17 15:39:27 +03:00
Zakhar Izmaylov 8020fd9a7d Fix send backup 2024-11-17 15:29:56 +03:00
Zakhar Izmaylov 8836dc577e Fix admin and create coupons 2024-11-17 15:26:03 +03:00
Zakhar Izmaylov a2be86b134 Fix admin 2024-11-17 15:10:28 +03:00
Zakhar Izmaylov e9bc47680a Update img 2024-11-17 14:17:51 +03:00
Zakhar Izmaylov da62ab5a90 Fix img 2024-11-17 14:15:27 +03:00
Zakhar Izmaylov dd84c8dd3a Fix 2024-11-17 14:09:05 +03:00
Zakhar Izmaylov 7e40a1f96c Update delete models 2024-11-17 14:08:51 +03:00
Zakhar Izmaylov 69f880af2b Add new table and middleware 2024-11-17 14:05:31 +03:00
Zakhar Izmaylov 7c93c8cc31 Refactoring and fix 2024-11-17 13:33:17 +03:00
Zakhar Izmaylov c2d06e10b8 Add admin middleware 2024-11-17 13:11:49 +03:00
Zahar Izmailov 9c8fafabb1 Merge branch 'Vladless:main' into main 2024-11-17 10:42:43 +03:00
Vladless c63708cb83 version 2024-11-17 03:20:06 +03:00
Vladless 8f9b84277b add version 2024-11-17 01:32:52 +03:00
Vladless 8363d319c2 robokassa release 2024-11-17 00:01:26 +03:00
Zakhar Izmaylov a353d41738 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-16 23:56:54 +03:00
Zakhar Izmaylov 0cfd656d86 #TODO Admin and bash 2024-11-16 23:56:45 +03:00
Vladislav Lisitsyn 5001fc94fe Merge pull request #94 from izzzzzi/main
Add scripts
2024-11-16 22:54:50 +03:00
Zahar Izmailov a014596d51 Merge branch 'Vladless:main' into main 2024-11-16 21:06:04 +03:00
Zakhar Izmaylov 64b4cfd1b2 Delete nginx config 2024-11-16 21:04:06 +03:00
Zakhar Izmaylov dcc7d5e218 Update 2024-11-16 21:03:39 +03:00
Zakhar Izmaylov ca8c5691d3 Add bash for node 2024-11-16 19:05:09 +03:00
Vladislav Lisitsyn b257d8c66d Merge pull request #93 from izzzzzi/main
Add dry logging
2024-11-16 18:46:12 +03:00
Zakhar Izmaylov 601598dfd2 Add dry logging 2024-11-16 18:01:28 +03:00
Vladless aef9c344ab fix delete 2024-11-16 00:44:12 +03:00
Vladless f6f1227dd8 ip 2024-11-15 23:59:42 +03:00
Vladless 6d750c91f8 fix back_button 2024-11-15 23:27:08 +03:00
Vladless 533d4fa64c Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 23:15:41 +03:00
Vladless 0be6ed0631 fix logging 2024-11-15 23:15:19 +03:00
Vladislav Lisitsyn 346dfaee2e Update start.py 2024-11-15 22:06:23 +03:00
Vladless e2796d22e2 pc instructions and button 2024-11-15 21:49:27 +03:00
Vladless b96c42c153 fix cluster 2024-11-15 20:30:57 +03:00
Vladless 83b5a8608a changing notifications 2024-11-15 20:15:19 +03:00
Vladless 1abe1c7166 logging for add client 2024-11-15 19:24:09 +03:00
Vladless c35bd82ebe formatting 2024-11-15 19:19:13 +03:00
Vladless b6ed49ad82 rm DEBUG 2024-11-15 19:16:37 +03:00
Vladless e70a2d74ba total_gb 2024-11-15 19:16:06 +03:00
Vladislav Lisitsyn d3f00acd5c Merge pull request #92 from izzzzzi/main
Fix pay stars
2024-11-15 18:03:04 +03:00
Zakhar Izmaylov 00b0823eb3 Fix router 2024-11-15 17:28:54 +03:00
Zakhar Izmaylov ab488ed0a3 Fix pay stars 2024-11-15 17:28:06 +03:00
Zakhar Izmaylov 12046b9c5c Fix pay stars 2024-11-15 17:25:42 +03:00
Vladless a457598109 fix donate 2024-11-15 15:48:52 +03:00
Vladless 1f2f6ee82a fix donate 2024-11-15 15:40:20 +03:00
Vladislav Lisitsyn 8d41b19767 Merge pull request #91 from izzzzzi/main
Add donate
2024-11-15 15:09:46 +03:00
Vladless cb59a1d176 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 15:09:01 +03:00
Vladless 2961477ecf robokassa_beta 2024-11-15 15:07:51 +03:00
Zakhar Izmaylov 5db30c05f7 Add donate 2024-11-15 14:38:14 +03:00
Vladislav Lisitsyn 90aa095621 Merge pull request #90 from izzzzzi/main
Added support for the list of admins
2024-11-15 13:51:33 +03:00
Vladless c7d5ff94c0 fix message delete 2024-11-15 13:51:12 +03:00
Zahar Izmailov 136832b22a Merge branch 'Vladless:main' into main 2024-11-15 13:37:57 +03:00
Zakhar Izmaylov 79c723ec06 Added support for the list of admins 2024-11-15 13:37:24 +03:00
Vladislav Lisitsyn 43883a3c06 Merge pull request #89 from izzzzzi/main
Fix stars
2024-11-15 13:30:46 +03:00
Zakhar Izmaylov 0eba072826 Fix stars 2024-11-15 13:25:57 +03:00
Vladislav Lisitsyn 0604675be6 Merge pull request #88 from izzzzzi/main
Add dev mode and fix menu and fix stars and cryptobot
2024-11-15 12:53:05 +03:00
Zakhar Izmaylov 75147eb5a1 Add multy pay 2024-11-15 11:58:48 +03:00
Zakhar Izmaylov 5dd1fab7e9 Add dev mode and fix menu and fix stars and cryptobot 2024-11-15 11:40:25 +03:00
Vladless 31fa90aef1 coupons initial 2024-11-15 03:03:24 +03:00
Vladless 4f0df51abd fix payments 2024-11-14 15:13:52 +03:00
Vladless 584fec2dc8 servers in clusters/total_gb 2024-11-14 14:17:16 +03:00
Vladless 5744d03ad9 servers in clusters/total_gb 2024-11-14 03:04:23 +03:00
Vladless 4aa521c6b9 servers in clusters/total_gb 2024-11-14 03:03:32 +03:00
Vladless 545ceac0b8 custom trial time 2024-11-13 18:51:25 +03:00
Vladislav Lisitsyn bd699b5700 Merge pull request #87 from izzzzzi/main
Update keyboards and restructuring for filters
2024-11-13 18:04:38 +03:00
Vladless cb7684b015 custom bonus 2024-11-13 17:27:08 +03:00
Zakhar Izmaylov 567c04d74e Add models.py #TODO 2024-11-13 17:10:25 +03:00
Zakhar Izmaylov ac3451a209 Add DatabaseMiddleware #TODO 2024-11-13 17:03:18 +03:00
Zakhar Izmaylov 8f4403accd Restructuring for filters 2024-11-13 16:53:23 +03:00
Zakhar Izmaylov 8b19cf0956 Update keyboards, update texts.py 2024-11-13 16:48:56 +03:00
Vladless 5db5c8377b add TOTAL_GB 2024-11-13 16:18:16 +03:00
Vladless 59ca46908e small fix 2024-11-13 15:38:20 +03:00
Vladless efd56e513d small fix 2024-11-13 05:41:49 +03:00
Vladless 6857e2b9c8 redesign/bug fixes/admin trial 2024-11-13 04:05:43 +03:00
Vladless 72438fea7e redesign/bug fixes/admin trial 2024-11-13 04:04:40 +03:00
Vladless 7a4aa5099d redesign/bug fixes/admin trial 2024-11-13 04:01:13 +03:00
Vladless 13efb0cc45 redesign/bug fixes/admin trial 2024-11-13 03:59:34 +03:00
Vladislav Lisitsyn 0c65d378cd Merge pull request #86 from izzzzzi/main
Refactoring admin
2024-11-12 13:11:09 +03:00
Zakhar Izmaylov e73b8e6538 Refactoring user editor 2024-11-12 08:10:40 +03:00
Zakhar Izmaylov 5bbc248a4b Refactoring 2024-11-12 08:10:28 +03:00
Zakhar Izmaylov b37c724aa0 Refactoring admin 2024-11-12 07:51:54 +03:00
Vladislav Lisitsyn b89b0fe1ce Merge pull request #85 from izzzzzi/main
Fix formating and freekassa
2024-11-12 00:17:33 +03:00
Zakhar Izmaylov 577592479b Fix formating and freekassa 2024-11-12 00:11:33 +03:00
Vladless 20031eb843 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot into izzzzzi-main 2024-11-11 23:35:49 +03:00
Zakhar Izmaylov 35939d89b8 Fix texts 2024-11-11 23:35:01 +03:00
Zakhar Izmaylov 2ae03cb6b0 Small refactoring admin 2024-11-11 23:17:09 +03:00
Zakhar Izmaylov 47be269ddf Fix bot.py 2024-11-11 23:11:14 +03:00
Zakhar Izmaylov ee30f5ab95 Fix, Update menu 2024-11-11 23:07:54 +03:00
Vladislav Lisitsyn e473988750 Merge pull request #83 from izzzzzi/main
Add multypay
2024-11-11 23:03:26 +03:00
Zakhar Izmaylov 2a768568a9 Remove btn 2024-11-11 22:49:45 +03:00
Zakhar Izmaylov 1b45580390 Add multypay 2024-11-11 22:45:11 +03:00
Vladislav Lisitsyn 37f8cf9881 Merge pull request #82 from izzzzzi/main
Add stars pay(beta)
2024-11-11 22:38:17 +03:00
Vladless 6c84c873cf fix freekassa 2024-11-11 22:37:41 +03:00
Zakhar Izmaylov a2a3d85cf5 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-11 22:33:03 +03:00
Zakhar Izmaylov 59db7344a0 Fix cryptobot 2024-11-11 22:31:52 +03:00
Zahar Izmailov 8f2d027a83 Merge branch 'Vladless:main' into main 2024-11-11 22:28:00 +03:00
Zakhar Izmaylov 0a37596471 Add stars pay(beta) 2024-11-11 22:26:10 +03:00
Vladislav Lisitsyn 2f0208a905 Merge pull request #81 from izzzzzi/main
Add crypto(beta), fix import, fix requirements.txt
2024-11-11 22:25:45 +03:00
Zakhar Izmaylov af8ecffb6d Add crypto(beta), fix import, fix requirements.txt 2024-11-11 21:51:34 +03:00
Vladislav Lisitsyn 08d38e651b Update README.md 2024-11-11 17:51:44 +03:00
Vladislav Lisitsyn 0db832473a Update README.md 2024-11-11 17:51:12 +03:00
Vladislav Lisitsyn e7f682f02d Merge pull request #80 from izzzzzi/main
Add base cryptobot
2024-11-11 17:48:31 +03:00
Vladless e002035518 minPython 3.10/py3xui/subs/rm_artefacts 2024-11-11 17:47:26 +03:00
Zakhar Izmaylov c16f2e8c72 Add base cryptobot 2024-11-11 14:26:00 +03:00
Vladless d588726838 isort settings 2024-11-11 09:22:14 +03:00
Vladless b5c8f4fa28 bug fix 2024-11-11 07:42:22 +03:00
Vladislav Lisitsyn dab1a8d754 Merge pull request #79 from izzzzzi/main
Add make and config for formatting
2024-11-10 23:12:42 +03:00
Zakhar Izmaylov 27f5b9d43a Add make and config for formatting 2024-11-10 11:02:15 +03:00
Vladless 4bf5b3a87d reset traffic 2024-11-10 03:31:19 +03:00
Vladless adaae41826 bug and fix 2024-11-10 02:25:36 +03:00
Vladislav Lisitsyn aaa754f66f Merge pull request #78 from izzzzzi/main
Refactoring and fix
2024-11-09 23:59:29 +03:00
Zakhar Izmaylov a294f123d2 Fix 2024-11-09 23:20:08 +03:00
Zakhar Izmaylov 617e599e74 Small fix 2024-11-09 16:23:39 +03:00
Zakhar Izmaylov 426e2a881a Refactoring admin_panel.py 2024-11-09 15:54:16 +03:00
Zakhar Izmaylov 96c9792dce Refactoring notifications.py and commands.py 2024-11-09 15:49:05 +03:00
Zakhar Izmaylov 0864a8a1ce Refactoring profile.py 2024-11-09 15:35:22 +03:00
Zakhar Izmaylov 5963cdc854 Refactoring start.py 2024-11-09 14:38:30 +03:00
Zakhar Izmaylov 04c7616661 Add middleware logging 2024-11-09 12:20:07 +03:00
Zakhar Izmaylov ca68eb58d3 Add middlewaer datatabase # TODO 2024-11-09 12:17:07 +03:00
Zakhar Izmaylov b8b6f29c48 Delete wraps 2024-11-09 12:01:24 +03:00
Zakhar Izmaylov 1d9aa3b432 isort and black 2024-11-09 11:44:15 +03:00
Zakhar Izmaylov b12bb33242 Migrate logging for loguru 2024-11-09 11:41:48 +03:00
Zakhar Izmaylov 1f7d0a263f Fix logging 2024-11-09 11:33:17 +03:00
Zakhar Izmaylov 34e213cc78 Fix callback_query 2024-11-09 11:17:11 +03:00
Zakhar Izmaylov 6651034413 Fix PAYMENT_OPTIONS and RENEWAL_PLANS 2024-11-09 10:52:41 +03:00
Zakhar Izmaylov 800a86c500 Refactoring and fix 2024-11-09 10:36:16 +03:00
Vladislav Lisitsyn 1806a3db4b Update README.md 2024-11-08 07:13:42 +03:00
Vladislav Lisitsyn 6a1f3f810e Update README.md 2024-11-08 07:08:20 +03:00
Vladislav Lisitsyn cf923e1e9a Merge pull request #77 from Vladless/subscriptions-feature
preview v2.
2024-11-08 07:07:00 +03:00
Vladless d122b59c6d preview v2. 2024-11-08 07:06:32 +03:00
Vladislav Lisitsyn 2bd4d802a3 Merge pull request #76 from Vladless/subscriptions-feature
preview v3.
2024-11-08 07:05:30 +03:00
Vladless 012ed787e8 preview v3. 2024-11-08 07:04:51 +03:00
Vladislav Lisitsyn 6ada73b6cb Merge pull request #75 from Vladless/subscriptions-feature
v3. Поправлены уведомления для новой версии.
2024-11-08 06:53:12 +03:00
Vladless 719865fe83 v3. Поправлены уведомления для новой версии. 2024-11-08 06:52:37 +03:00
Vladislav Lisitsyn c34c5ef7af Merge pull request #74 from Vladless/subscriptions-feature
v3. Админка переведена на новую версию.
2024-11-08 06:43:15 +03:00
Vladless 9c85ae31e5 v3. Админка переведена на новую версию. 2024-11-08 06:42:12 +03:00
Vladislav Lisitsyn 987602551e Merge pull request #73 from Vladless/subscriptions-feature
Админка
2024-11-08 06:03:34 +03:00
Vladless 9dcbc7d736 v3. Админка переведена на новую версию. 2024-11-08 06:02:29 +03:00
Vladless 92fbe6ced5 v3. Админка переведена на новую версию. 2024-11-08 06:02:07 +03:00
Vladislav Lisitsyn d79d7004ec Update README.md 2024-11-07 07:58:58 +03:00
Vladislav Lisitsyn 4d1a75939e Update README.md 2024-11-07 07:58:41 +03:00
Vladislav Lisitsyn 1c9af07a7d Merge pull request #72 from Vladless/subscriptions-feature
v3. Полноценные подписки
2024-11-07 07:56:55 +03:00
Vladless 8f16fa4c1a v3. Полноценные подписки 2024-11-07 07:53:29 +03:00
Vladislav Lisitsyn fe2ae18f20 Merge pull request #71 from Vladless/subscriptions-feature
Проверка ключей
2024-11-05 22:02:36 +03:00
Vladless aa73bdd066 Проверка ключей 2024-11-05 22:00:32 +03:00
Vladislav Lisitsyn a48bc4a0d8 Merge pull request #70 from Vladless/subscriptions-feature
Админка. Расширение
2024-11-04 01:10:40 +03:00
Vladless 3461f47cfd Админка. Расширение 2024-11-04 01:10:02 +03:00
Vladislav Lisitsyn 38d99b2a47 Merge pull request #69 from Vladless/subscriptions-feature
Переработка уведомлений
2024-11-03 23:42:07 +03:00
Vladless 25a51549ac Переработка уведомлений 2024-11-03 23:41:26 +03:00
Vladislav Lisitsyn bca136a55e Merge pull request #68 from Vladless/subscriptions-feature
Профиль. Логи
2024-11-03 21:54:11 +03:00
Vladless 1953e7c0cb Профиль. Логи 2024-11-03 21:52:59 +03:00
Vladislav Lisitsyn 6754a7da93 Merge pull request #67 from Vladless/subscriptions-feature
Уведомления/Профиль. Логгирование
2024-11-03 21:40:41 +03:00
Vladless 8fb3b1691f Профиль. Логи 2024-11-03 21:39:56 +03:00
Vladless 91bc384508 Уведомления. Правки 2024-11-03 21:38:26 +03:00
Vladislav Lisitsyn 666c3b34af Merge pull request #66 from Vladless/subscriptions-feature
Уведомления. Правки
2024-11-03 01:49:54 +03:00
Vladless 38817c82f7 Уведомления. Правки 2024-11-03 01:49:02 +03:00
Vladislav Lisitsyn 10a6179b7c Update README.md 2024-11-03 00:34:00 +03:00
Vladislav Lisitsyn 411647585d Merge pull request #65 from Vladless/subscriptions-feature
добавление freekassa
2024-11-03 00:32:38 +03:00
Vladless f5c15546e2 добавление freekassa 2024-11-03 00:31:57 +03:00
Vladislav Lisitsyn 109a968941 Update README.md 2024-11-02 04:30:23 +03:00
Vladislav Lisitsyn b32cf0a9a0 Update README.md 2024-11-02 04:30:01 +03:00
Vladislav Lisitsyn bbd68becfc Update README.md 2024-11-02 04:29:12 +03:00
Vladislav Lisitsyn c4400466dd Merge pull request #64 from Vladless/subscriptions-feature
Подписки. Обновление ключей. Кнопки
2024-11-02 04:25:49 +03:00
Vladislav Lisitsyn da4ed7dd22 Merge branch 'main' into subscriptions-feature 2024-11-02 04:25:39 +03:00
Vladless c0fc972ce5 удобная смена подписки 2024-11-02 04:17:54 +03:00
Vladless cdcfcb7987 перевод на v2raytun 2024-11-02 03:10:49 +03:00
Vladless 48528e611f перевод на v2raytun 2024-11-02 01:17:04 +03:00
Vladless 0e982e8361 перевод на v2raytun 2024-11-01 23:52:21 +03:00
Vladislav Lisitsyn cc9715ca7b Update README.md 2024-10-31 23:59:20 +03:00
Vladislav Lisitsyn 6cb6aa4c67 Merge pull request #63 from Vladless/subscriptions-feature
Кнопка автодобавления конфигурации в клиентское приложение
2024-10-30 20:07:12 +03:00
Vladless 040e32a50a Кнопка автодобавления конфигурации в клиентское приложение 2024-10-30 20:05:38 +03:00
Vladislav Lisitsyn 40092c7085 Merge pull request #62 from Vladless/subscriptions-feature
Подписки. Полная версия
2024-10-30 18:03:36 +03:00
Vladless 1bd138e78b Подписки. Полная версия 2024-10-30 18:03:04 +03:00
Vladless fae10faf7e ReadMe 2024-10-30 04:50:53 +03:00
Vladless 51d0b71e1d ReadMe 2024-10-30 03:57:27 +03:00
Vladless 9cf29cc59c Подписки. initial commit 2024-10-30 02:35:27 +03:00
Vladislav Lisitsyn 3e680fd31d Merge pull request #61 from Vladless/new1
правки
2024-10-30 00:02:43 +03:00
Vladislav Lisitsyn 2e5d581fb5 Delete handlers/__pycache__ directory 2024-10-30 00:02:27 +03:00
Vladless 8a235e129d правки 2024-10-30 00:00:18 +03:00
Vladislav Lisitsyn d899ddf663 Merge pull request #60 from Vladless/new1
исправлен траффик при продлении
2024-10-29 23:46:37 +03:00
Vladless cd6d05320e исправлен траффик при продлении 2024-10-29 23:45:27 +03:00
Vladislav Lisitsyn 4ef0ce9d19 Merge pull request #59 from Vladless/new1
Уведомления. Имя сервера вместо id
2024-10-29 23:20:22 +03:00
Vladless c78fee4a6b Уведомления. Имя сервера вместо id 2024-10-29 23:19:54 +03:00
Vladislav Lisitsyn abc46bf172 Merge pull request #57 from Vladless/new1
rm update_bot
2024-10-28 19:50:29 +03:00
Vladless 3680d0606a rm update_bot 2024-10-28 19:49:03 +03:00
Vladislav Lisitsyn 45e4b2f696 Merge pull request #56 from Vladless/new1
New1
2024-10-28 01:01:44 +03:00
Vladislav Lisitsyn f11e3f255e Merge branch 'main' into new1 2024-10-28 01:01:34 +03:00
Vladless 3915f37e55 ReadMe 2024-10-28 01:00:20 +03:00
Vladless 0a4e1e8c50 rm 2024-10-28 00:54:22 +03:00
Vladless 0d390bb9cc rm 2024-10-28 00:47:09 +03:00
Vladislav Lisitsyn 15f3e89e61 Merge pull request #55 from Vladless/new1
rm
2024-10-28 00:39:58 +03:00
Vladislav Lisitsyn 869a0e8472 rm 2024-10-28 00:35:07 +03:00
Vladislav Lisitsyn 4f0c64b786 rm 2024-10-28 00:30:27 +03:00
Vladislav Lisitsyn 10e10b7157 Merge pull request #54 from Vladless/new1
Уведомления. Продление по кнопке
2024-10-27 23:48:03 +03:00
Vladless b192b43bd3 Уведомления. Продление по кнопке 2024-10-27 22:58:56 +03:00
Vladislav Lisitsyn e129a06d9b Merge pull request #53 from Vladless/new1
/admin. GUI интерфейс в боте
2024-10-27 22:47:09 +03:00
Vladless c678a9bfce /admin. GUI интерфейс в боте 2024-10-27 22:45:16 +03:00
Vladislav Lisitsyn c8cdfc980b Merge pull request #52 from Vladless/new1
Админ. Установка времени ключей
2024-10-27 06:16:55 +03:00
Vladless 146512b2ab Админ. Установка времени ключей 2024-10-27 06:15:34 +03:00
Vladislav Lisitsyn 30c03677fe Merge pull request #51 from Vladless/new1
New1
2024-10-27 03:55:35 +03:00
Vladless 2baf63dccd обложка 2024-10-27 03:54:52 +03:00
Vladless 363bf80bf4 обложка 2024-10-27 03:54:19 +03:00
Vladislav Lisitsyn 50146be4a6 Delete handlers/__pycache__ directory 2024-10-27 03:20:45 +03:00
Vladislav Lisitsyn 09673a145c Merge pull request #50 from Vladless/new1
Полная реструктуризация. Тексты все в texts.py
2024-10-27 03:17:40 +03:00
Vladless 16561610b6 Полная реструктуризация. Тексты все в texts.py 2024-10-27 03:16:58 +03:00
Vladislav Lisitsyn a71afc554e Merge pull request #49 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:30:08 +03:00
Vladless 7512868219 Порядок. Исправления мелких недочетов 2024-10-27 01:29:34 +03:00
Vladislav Lisitsyn 9008ba3b99 Merge pull request #48 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:10:58 +03:00
Vladless 83d589fff5 Порядок. Исправления мелких недочетов 2024-10-27 01:03:00 +03:00
Vladislav Lisitsyn 5cb07aa68a Update trial_key.py 2024-10-26 16:05:58 +03:00
Vladislav Lisitsyn dd5463881d Merge pull request #47 from Vladless/new1
админ. команда баланса
2024-10-26 03:11:00 +03:00
Vladless 77084b1ff2 админ. команда баланса 2024-10-26 03:10:28 +03:00
Vladislav Lisitsyn e965652e1f Merge pull request #46 from Vladless/new1
админка в боте
2024-10-26 02:45:43 +03:00
Vladless 1c3d24527f админка в боте 2024-10-26 02:45:19 +03:00
Vladislav Lisitsyn 9bfb39f1ae Merge pull request #45 from Vladless/new1
админка в боте
2024-10-26 02:40:03 +03:00
Vladless 0c46a70f53 админка в боте 2024-10-26 02:39:35 +03:00
Vladislav Lisitsyn dedeac394b Merge pull request #44 from Vladless/new1
админка в боте
2024-10-26 02:34:39 +03:00
Vladless 57e813e3d4 админка в боте 2024-10-26 02:34:05 +03:00
Vladislav Lisitsyn 02847dbb93 Merge pull request #43 from Vladless/new1
ReadMe
2024-10-26 02:19:28 +03:00
Vladless 470a05018e ReadMe 2024-10-26 02:18:48 +03:00
Vladislav Lisitsyn 649d8060df Merge pull request #42 from Vladless/new1
Docker и docker-compose
2024-10-26 02:13:46 +03:00
Vladless 5a04b360db Docker и docker-compose 2024-10-26 02:12:34 +03:00
Vladislav Lisitsyn 76d2f28128 Merge pull request #41 from Vladless/new1
создание ключа в 1 кнопку
2024-10-26 00:57:22 +03:00
Vladless 37561c086f создание ключа в 1 кнопку 2024-10-26 00:55:03 +03:00
Vladislav Lisitsyn 262b7c9489 Merge pull request #40 from Vladless/new1
Автопродление ключа
2024-10-25 22:21:07 +03:00
Vladless 73cfe74c1c Автопродление ключа 2024-10-25 22:20:12 +03:00
Vladislav Lisitsyn 68f6994b94 Merge pull request #39 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:56:48 +03:00
Vladless 57ecdbbda5 Логи. Уведомления. Тексты 2024-10-25 20:56:02 +03:00
Vladislav Lisitsyn 3ff07e4c76 Merge pull request #38 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:46:56 +03:00
Vladless 8d4bec79b3 Логи. Уведомления. Тексты 2024-10-25 20:45:12 +03:00
Vladislav Lisitsyn 546f4c52cb Merge pull request #37 from Vladless/new1
New1
2024-10-22 02:07:33 +03:00
Vladless b223b4463a основной текст в отдельном файле 2024-10-22 02:06:58 +03:00
Vladless 258f8fe2f7 основной текст в отдельном файле 2024-10-22 02:02:17 +03:00
Vladislav Lisitsyn c697498cb4 Merge pull request #36 from Vladless/new1
основной текст в отдельном файле
2024-10-22 01:55:10 +03:00
Vladless 4b1e8b555a основной текст в отдельном файле 2024-10-22 01:53:50 +03:00
Vladless ec4217fc4f поправка подсчета людей 2024-10-22 01:00:56 +03:00
Vladless 49fa3693ad автоввод паролей 2024-10-22 00:30:30 +03:00
Vladless 845a7a287b Бэкапы. команда ручного бэкапа 2024-10-22 00:25:16 +03:00
Vladless b5928722eb Бэкапы. команда ручного бэкапа 2024-10-22 00:13:13 +03:00
Vladless 9f0d0acb53 Бэкапы. Отправка админу 2024-10-21 23:59:26 +03:00
Vladless b4af296384 requirements 2024-10-21 23:40:50 +03:00
Vladless 3466157550 версия бота. Релиз 2024-10-21 03:36:29 +03:00
Vladless 754a33cbc7 версия бота. Релиз 2024-10-21 02:52:34 +03:00
Vladless 8538050fb5 напоминание за сутки у активного клиента 2024-10-21 02:47:59 +03:00
Vladless 165e2955e0 ReadMe.md 2024-10-21 02:35:53 +03:00
Vladless bcce80a52d напоминание за сутки у активного клиента 2024-10-21 02:26:15 +03:00
Vladless 811ca4ff30 ReadMe.md 2024-10-21 02:16:37 +03:00
Vladless 80249609af завершение работы без убивания процесса 2024-10-21 02:12:15 +03:00
Vladless 63039e3537 завершщение работы без убивания процесса 2024-10-21 02:08:47 +03:00
Vladless def9d5a016 резервные копии базы данных и уведомления 2024-10-21 01:23:27 +03:00
Vladless 41dd64f7c8 автопродление ключа 2024-10-13 23:11:39 +03:00
Vladless a0bada3e76 уведомление о неиспользованном триале 2024-10-13 04:02:47 +03:00
Vladless a405f0a5ad ввод своей суммы для пополнения баланса 2024-10-13 03:37:14 +03:00
Vladless 7bd484528a значительная переработка интерфейса 2024-10-13 02:51:45 +03:00
Vladless 2c6b2b9784 значительная переработка интерфейса 2024-10-12 03:14:01 +03:00
Vladless 41ca9b704f ReadMe 2024-10-09 04:01:08 +03:00
Vladislav Lisitsyn 06a1360714 Merge pull request #35 from Vladless/youkassa
ReadMe
2024-10-09 03:41:23 +03:00
Vladislav Lisitsyn 6583f2e6cc Merge branch 'main' into youkassa 2024-10-09 03:41:11 +03:00
Vladless eae768615c ReadMe 2024-10-09 03:40:31 +03:00
Vladislav Lisitsyn 10b4562982 Merge pull request #34 from Vladless/youkassa
Уведомления
2024-10-09 02:16:07 +03:00
Vladless ed5a129b6a логика, отправка уведомлений всем 2024-10-09 02:14:41 +03:00
Vladless dc661dabcc логика, отправка уведомлений всем 2024-10-09 02:13:42 +03:00
Vladless c5e9cb9b18 логика, отправка уведомлений всем 2024-10-09 02:09:22 +03:00
Vladislav Lisitsyn 576a389028 Merge pull request #33 from Vladless/youkassa
aiohttp, уведомления, правки
2024-10-09 01:10:30 +03:00
Vladislav Lisitsyn 709c1064a5 Merge branch 'main' into youkassa 2024-10-09 01:10:23 +03:00
Vladless 8588b89421 aiohttp, уведомления, правки 2024-10-09 00:51:22 +03:00
Vladislav Lisitsyn f7886dcfea Merge pull request #31 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 03:10:09 +03:00
Vladless 60fa6cb4f1 правка сообщения пригласить 2024-10-04 03:09:32 +03:00
Vladislav Lisitsyn 98832d7ade Merge pull request #30 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 02:45:26 +03:00
Vladless dbfed0dc2a правка сообщения пригласить 2024-10-04 02:44:32 +03:00
Vladislav Lisitsyn a674c321de Merge pull request #29 from Vladless/youkassa
Youkassa
2024-10-04 02:39:12 +03:00
Vladless d91e9180ea правка сообщения пригласить 2024-10-04 02:35:48 +03:00
Vladless fbe7e9f820 реферальная система 2024-10-04 02:31:05 +03:00
Vladislav Lisitsyn c7fb1045c2 Merge pull request #28 from Vladless/youkassa
Youkassa
2024-10-04 02:13:12 +03:00
Vladless 4ba3e14961 реферальная система 2024-10-04 02:06:16 +03:00
Vlad 7746835832 чистота кода 2024-10-03 07:20:13 +03:00
Vladislav Lisitsyn 264d6186fa Merge pull request #27 from Vladless/youkassa
Youkassa
2024-10-03 07:09:08 +03:00
Vlad 3f5c449817 чистота кода 2024-10-03 07:08:17 +03:00
Vlad d03907b43c уведомления за 10 часов и при истечении 2024-10-03 06:52:24 +03:00
Vladislav Lisitsyn 56bd72c3bc Merge pull request #26 from Vladless/youkassa
Выбор сервера
2024-10-03 06:45:59 +03:00
Vlad f42ca88e01 поправлен тег 2024-10-03 06:45:07 +03:00
Vlad 87095772fe заполненность серверов в процентах 2024-10-03 06:42:40 +03:00
Vlad bfca706b31 работающий перенос ключей 2024-10-03 06:31:44 +03:00
Vlad bba0e3ebdb работающий перенос ключей 2024-10-03 06:20:38 +03:00
Vlad 4958061a19 выбор сервера, переработка ссылок 2024-10-03 04:43:55 +03:00
Vladislav Lisitsyn 8aec92fe43 Merge pull request #25 from Vladless/youkassa
смена слогана
2024-10-02 05:16:33 +03:00
Vlad f84293f270 смена слогана 2024-10-02 05:12:55 +03:00
Vladislav Lisitsyn aa830c9a37 Merge pull request #24 from Vladless/youkassa
Youkassa
2024-10-02 05:04:01 +03:00
Vlad 58357f2f8b смена слогана 2024-10-02 05:03:08 +03:00
Vlad 19f40c8fad смена слогана 2024-10-02 04:51:48 +03:00
Vlad e1aadf9781 смена слогана 2024-10-02 04:46:05 +03:00
Vladislav Lisitsyn 3198cd13f4 Merge pull request #23 from Vladless/youkassa
новостные сообщения, поправленный requirements
2024-10-02 04:16:36 +03:00
Vlad 67b85478aa новостные сообщения, поправленный requirements 2024-10-02 04:16:04 +03:00
Vladislav Lisitsyn 08cbeceea4 Merge pull request #22 from Vladless/youkassa
истекшие ключи автоматически удаляются
2024-10-02 02:29:44 +03:00
Vlad 60d7e89452 истекшие ключи автоматически удаляются 2024-10-02 02:06:42 +03:00
Vlad 3937cb105c порядок в стурктуре проекта, isort, импорты 2024-10-02 01:35:39 +03:00
Vlad 16c776bf82 Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:19:48 +03:00
Vladislav Lisitsyn 467557fb59 Merge pull request #21 from Vladless/youkassa
Доработка платежей, уведомления и переработка интерфейса
2024-10-02 01:13:35 +03:00
Vlad 140020838b Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:13:04 +03:00
Vladislav Lisitsyn 18f329b529 Merge pull request #20 from Vladless/youkassa
Оплата и пополнение баланса через Юкасса
2024-10-01 14:27:58 +03:00
Vlad 271ea09380 Оплата и пополнение баланса через Юкасса 2024-10-01 14:27:16 +03:00
Vladislav Lisitsyn d6dc9ab365 Merge pull request #19 from Vladless/new
WebHooks for YouKassa
2024-10-01 02:10:28 +03:00
Vlad 376cd07b5c WebHooks for YouKassa 2024-10-01 01:20:29 +03:00
Vladislav Lisitsyn 9fe28059c6 Merge pull request #18 from Vladless/new
дополнительные варианты оплаты и продления
2024-10-01 00:47:58 +03:00
Vlad 5742e507d0 дополнительные варианты оплаты и продления 2024-10-01 00:15:01 +03:00
Vladislav Lisitsyn 5549d0a55c Update README.md 2024-09-29 18:04:48 +03:00
Vladislav Lisitsyn dc76929283 Update README.md 2024-09-29 02:31:26 +03:00
Vladislav Lisitsyn 5d5f2b0556 Update README.md 2024-09-29 02:31:06 +03:00
Vladislav Lisitsyn 9d759b949d Update README.md 2024-09-29 02:23:52 +03:00
Vladislav Lisitsyn 69b4da4d43 Merge pull request #17 from Vladless/new
isort и правка импортов
2024-09-29 02:15:16 +03:00
Vlad f03f8a3711 isort и правка импортов 2024-09-29 02:14:49 +03:00
Vladislav Lisitsyn 594b97a752 Update README.md 2024-09-29 02:13:32 +03:00
Vladislav Lisitsyn 04cbcbd817 Create README.md 2024-09-29 02:11:50 +03:00
Vladislav Lisitsyn 7849d8c99f Merge pull request #15 from Vladless/new
правки и доработки интерфейса
2024-09-28 13:13:23 +03:00
Vlad d7ae68e17a правки и доработки интерфейса 2024-09-28 13:12:04 +03:00
Vladislav Lisitsyn 7adb81ca28 Merge pull request #14 from Vladless/new
правки и доработки интерфейса
2024-09-28 04:09:11 +03:00
Vlad d207b64a97 правки и доработки интерфейса 2024-09-28 03:49:43 +03:00
Vlad a104867a35 правки и доработки интерфейса 2024-09-28 03:44:08 +03:00
Vlad 7dd00a2f24 правки и доработки интерфейса 2024-09-28 03:26:28 +03:00
Vladislav Lisitsyn 0e4ca2ac47 Merge pull request #13 from Vladless/new
пополнение баланса даже без ключа
2024-09-26 03:12:12 +03:00
Vlad 19af987979 пополнение баланса даже без ключа 2024-09-26 03:11:22 +03:00
Vladislav Lisitsyn edaeb3a212 Merge pull request #12 from Vladless/new
отображение ключей и создание на сутки
2024-09-26 02:39:03 +03:00
Vlad 0e43b36901 отображение ключей и создание на сутки 2024-09-26 02:38:32 +03:00
Vladislav Lisitsyn cebcad4be1 Merge pull request #11 from Vladless/new
меню
2024-09-25 03:42:39 +03:00
Vlad 34f0205f6d меню 2024-09-25 02:24:25 +03:00
Vladislav Lisitsyn fdf5b5e93d Merge pull request #10 from Vladless/new
New
2024-09-25 01:58:47 +03:00
Vlad 015fbf1d72 добавление уведомлений 2024-09-25 01:58:10 +03:00
Vlad b3fb4984b6 изменена логика поиска ключей в базе данных 2024-09-25 01:51:26 +03:00
Vlad 146b61a1a8 изменена логика поиска ключей в базе данных 2024-09-25 01:45:37 +03:00
Vlad 5dd4fda577 правки 2024-09-24 02:36:30 +03:00
Vlad f83278af90 правки 2024-09-24 02:35:54 +03:00
Vlad 3fa93a9b72 правки 2024-09-23 19:08:41 +03:00
Vlad be35180703 правки 2024-09-23 18:54:16 +03:00
Vlad 4e8f5e71a7 правки 2024-09-23 16:48:32 +03:00
Vladislav Lisitsyn b89db922f7 Merge pull request #9 from Vladless/new
New
2024-09-23 16:42:31 +03:00
Vlad 60c7fa14c8 правки 2024-09-23 16:35:46 +03:00
Vlad 9413065ea3 isort 2024-09-21 03:08:57 +03:00
Vladislav Lisitsyn 98fdf362c3 Merge pull request #8 from Vladless/new
New
2024-09-21 03:00:53 +03:00
Vlad 9c2b732d55 валидация email 2024-09-21 02:59:25 +03:00
Vlad b95013eb8f удалены лишние импорты 2024-09-21 01:29:33 +03:00
Vlad b1e9b257c6 основные функции завершены 2024-09-21 01:21:35 +03:00
Vlad 22a462d193 пробник создается всего один раз 2024-09-21 01:13:12 +03:00
Vlad 16a497a8df правильная логика продления ключей 2024-09-21 00:56:08 +03:00
Vlad 51b46bfe65 переход на postresql 2024-09-21 00:21:23 +03:00
Vlad 4316f44390 исправление ошибки в моих ключах 2024-09-20 23:24:39 +03:00
Vlad 470f9a2f38 исправление ошибки в моих ключах 2024-09-20 14:25:22 +03:00
Vlad fe6fef3174 доработка кода 2024-09-20 05:48:09 +03:00
Vlad f8015ced70 редизайн, кнопки назад 2024-09-20 03:48:41 +03:00
Vlad a6d2203e78 апдейт юзеров 2024-09-19 03:41:44 +03:00
Vlad 22b1342d43 оптимизация 2024-09-18 04:09:38 +03:00
Vladislav Lisitsyn 0f321aab3a Merge pull request #7 from Vladless/new
New
2024-09-18 01:45:13 +03:00
Vlad c3e9cc2275 переработка интерфейса 2024-09-18 01:44:27 +03:00
Vlad cb9b6381ca работающая оплата с подтверждением после перевода 2024-09-17 23:51:03 +03:00
Vlad 7fa8d6f346 структура проекта 2024-09-17 03:11:36 +03:00
Vladislav Lisitsyn 9398d1ec7e Merge pull request #6 from Vladless/new
New
2024-09-17 03:08:57 +03:00
Vlad b81d515551 структура проекта 2024-09-17 03:08:07 +03:00
Vlad 2f523bf196 мои ключи 2024-09-17 02:38:52 +03:00
Vladislav Lisitsyn f82e9af84e Merge pull request #5 from Vladless/new
рабочий вариант выдачи ключей через подтверждение
2024-09-17 01:45:40 +03:00
Vlad 15181f0ef5 рабочий вариант выдачи ключей через подтверждение 2024-09-17 01:45:12 +03:00
Vladislav Lisitsyn 12295d276a Merge pull request #4 from Vladless/new
рабочий вариант выдачи ключей через подтверждение
2024-09-17 00:40:01 +03:00
Vlad c98c6af14c рабочий вариант выдачи ключей через подтверждение 2024-09-17 00:39:06 +03:00
Vladislav Lisitsyn fc5606faa8 Merge pull request #3 from Vladless/new
New
2024-09-16 23:01:51 +03:00
Vlad 412893b689 опрос 2024-09-16 23:01:15 +03:00
Vlad 4797759f9b опрос 2024-09-16 07:03:41 +03:00
Vlad 9398b78238 bot.1.0 2024-09-16 06:58:36 +03:00
Vlad 86c86c9198 ключ, статистика, срок 2024-09-16 06:50:53 +03:00
Vladislav Lisitsyn 40365a5324 Merge pull request #2 from Vladless/base
Base
2024-09-16 05:03:02 +03:00
Vlad 454ca55fd6 добавлена дата окончания подписки 2024-09-16 04:02:17 +03:00
Vlad 38aeaa9d17 логика статистики 2024-09-16 03:43:25 +03:00
Vlad 1e259eb6e3 кнопка статистики 2024-09-16 03:34:05 +03:00
Vladislav Lisitsyn b340d07f5c Delete bot_old.py 2024-09-16 03:16:01 +03:00
Vladislav Lisitsyn 7c57cf8102 Merge pull request #1 from Vladless/base
Добавлена начальная база данных
2024-09-13 03:40:05 +03:00
Vlad ae819e33dc Добавлена начальная база данных 2024-09-13 03:39:21 +03:00
Vlad c9f3aa4a20 Начальный этап бота с выдачей ключей 2024-09-13 02:13:24 +03:00
116 changed files with 11245 additions and 113480 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

+4 -2
View File
@@ -54,5 +54,7 @@ scripts
models.py
Dockerfile
.csv
setup.py
/logs
setup.py
.ruff_cache
.github/workflows/
+1
View File
@@ -0,0 +1 @@
3.12
+4 -3
View File
@@ -1,6 +1,7 @@
formatting:
@echo "Running Ruff..." && ruff check . --fix
@echo "Running Ruff format..." && ruff format .
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
lint:
@echo "Running Ruff checks..." && ruff check .
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
+71 -119
View File
@@ -2,153 +2,105 @@
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
Три версии — море возможностей:
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
- [v1.4](https://github.com/Vladless/Solo_bot/releases/tag/v1.4) — бот для продажи ключей VLESS:
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
![](preview.jpg)
# Основные возможности
- [v2.3.1](https://github.com/Vladless/Solo_bot/releases/tag/v2.3.1) — стабильная версия подписок вместо ключей с
кнопками автодобавления в приложение:
**SoloBot** реализует множество функций, включая:
![](preview_v_2.jpg)
### Управление подписками
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
- [v3.1](https://github.com/Vladless/Solo_bot/releases/tag/v3.1) — версия бота со значительным расширением возможностей.
### Полный контроль клиента над своими ключами
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
[Последние актуальные релизы.](https://github.com/Vladless/Solo_bot/releases)
Если вам не хватает функций — направьте их в [issue](https://github.com/Vladless/Solo_bot/issues), мы реализуем.
### Реферальная программа
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
## 📋 Оглавление
### Интеграция платёжных систем
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
1. [Описание](#-описание)
2. [Стек технологий](#-стек-технологий)
3. [Установка](#-установка)
4. [Конфигурация](#-конфигурация)
5. [Запуск](#-запуск)
6. [Контакты](#-solobot-в-telegram-и-полная-версия)
### Безопасность и стабильность
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
---
### Уведомления
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
## 📖 Описание
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
SoloBot реализует множество функций, включая:
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
- **Выдача подписок на различные периоды** (1 месяц, 3 месяца, полгода, год).
- **Использование тестового периода подписки.**
- **Продление ключей** на указанные периоды.
- **Полный контроль** клиента над своими ключами:
- **Просмотр** информации о ключе (сервер, оставшееся время, сам ключ).
- **Продление** ключей.
- **Удаление** ключей.
- **Смена локации** (перемещение ключа между серверами).
- **Поддержка нескольких ключей и устройств** для одного клиента.
- **Реферальная программа** с уникальной ссылкой для приглашений.
- Доступ к **инструкциям** для подключения к сервису.
- Пополнение баланса:
* **Юкасса** (Самозанятость и ИП)
* **Юмани** (Физические Лица) ([TrackLine](https://github.com/TrackLine))
* **FreeKassa** (Физические Лица)
* **Robokassa** (Физические Лица)
* **Cryptobot** (Криптовалюта) ([izzzzzi](https://github.com/izzzzzi))
* **Звезды Telegram**
- Периодические **бэкапы базы данных клиентов**.
- Уведомления:
- Произвольные сообщения через панель администратора.
- Уведомления о неиспользованных пробниках.
- Уведомления об истекающих ключах (за сутки, за 6 часов и в момент истечения).
- **Чат поддержки** и канал для связи.
- **Автоматическое продление ключа** при наличии достаточного баланса.
- **Удобная панель администратора прямо в боте**
- Серверы:
- **Мультисерверность** — добавление сервера прямо в админке
- **Проверка на доступность** — бот следит за серверами и их откликом
- **Балансировка** — бот выдает подписку на наименее загруженный сервер
---
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
## 💻 Стек технологий
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
Основные технологии, используемые в проекте:
### *Настраивайте как вам удобно!*
- **Python** версии 3.10 (3.12, для последних версий).
- **PostgreSQL** — для хранения данных.
- **aiogram** — для взаимодействия с Telegram API.
- **aiohttp** — для API запросов к панелям.
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
## Список версий
---
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
## ⚙️ Установка
### 1️⃣ Шаг 1: Клонирование репозитория
Клонируйте репозиторий и перейдите в его директорию:
```bash
git clone https://github.com/Vladless/Solo_bot.git
cd solo_bot
```
### 2️⃣ Шаг 2: Создание и подключение виртуального окружения
Создайте виртуальное окружение **Python** и перейдите в сессию:
```
python3 -m venv venv
source venv/bin/activate
```
### 3️⃣ Шаг 3: Установка зависимостей
Установите зависимости:
```
pip install -r requirements.txt
```
### 🛠️ Конфигурация
Для правильной работы вам нужно:
* Установить и запустить **PostgreSQL**, создать пользователя для работы с базой данных и выдать ему права
* Настроить ваш сервер для работы с ботом, выпустить SSL сертификат для домена
* Настроить вебхуки и указать пути до них через любой веб-сервер, например **nginx**
* Создать файл config.py в корневой папке проекта с вашими данными:
```
API_TOKEN = "токен вашего бота телеграм"
ADMIN_USERNAME = "логин от вашей панели x-ray"
ADMIN_PASSWORD = "пароль от вашей панели x-ray"
ADD_CLIENT_URL = f"{API_URL}/panel/api/inbounds/addClient"
GET_INBOUNDS_URL = f"{API_URL}/panel/api/inbounds/list/"
AUTH_URL = f"{API_URL}/login/"
```
**Мы высылаем детальный гайд и недостающие файлы в поддержке бота!**
### 🚀 Запуск
Введите команду из виртуального окружения `venv/bin/activate`
```
python3 main.py
```
### 🔗 SoloBot в Telegram и Полная версия
## 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/737fbc21-e4b1-4c69-9323-71284d34e454)
![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
#### Отзывы пользователей:
SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/fe13a00f-94d3-419c-afd0-d1c1efec5179)
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, а также задать необходимые вопросы!
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
## Права на использование
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
#### Перепродажа кода запрещена, ознакомьтесь с лицензией
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
+42 -1
View File
@@ -42,6 +42,24 @@ CREATE TABLE IF NOT EXISTS keys
PRIMARY KEY (tg_id, client_id)
);
DO $$
BEGIN
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 +72,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,
@@ -99,4 +128,16 @@ CREATE TABLE IF NOT EXISTS gifts
recipient_tg_id BIGINT,
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
);
);
CREATE TABLE IF NOT EXISTS temporary_data (
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS blocked_users (
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
);
+123 -65
View File
@@ -1,100 +1,158 @@
import os
import subprocess
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
import aiofiles
from aiogram.types import BufferedInputFile
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER
from bot import bot
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
from logger import logger
async def backup_database():
from bot import bot
async def backup_database() -> Exception | None:
"""
Создает резервную копию базы данных и отправляет ее администраторам.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
backup_file_path, exception = _create_database_backup()
if exception:
logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
return exception
try:
if backup_file_path := _create_database_backup():
await _send_backup_to_admin(bot, backup_file_path)
_cleanup_old_backups()
await _send_backup_to_admins(backup_file_path)
exception = _cleanup_old_backups()
if exception:
logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
return exception
return None
except Exception as e:
logger.error(f"Ошибка при создании или отправке бэкапа: {e}")
logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
return e
def _create_database_backup():
USER = DB_USER
HOST = "localhost"
BACKUP_DIR = BACK_DIR
DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S")
BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql"
def _create_database_backup() -> tuple[str | None, Exception | None]:
"""
Создает резервную копию базы данных PostgreSQL.
os.environ["PGPASSWORD"] = DB_PASSWORD
Returns:
Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
"""
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:
os.environ["PGPASSWORD"] = DB_PASSWORD
subprocess.run(
[
"pg_dump",
"-U",
USER,
DB_USER,
"-h",
HOST,
PG_HOST,
"-p",
PG_PORT,
"-F",
"c",
"-f",
BACKUP_FILE,
str(filename),
DB_NAME,
],
check=True,
capture_output=True,
text=True,
)
logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
return BACKUP_FILE
logger.info(f"Бэкап базы данных создан: {filename}")
return str(filename), None
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
return None
logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
return None, e
except Exception as e:
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
return None, e
finally:
del os.environ["PGPASSWORD"]
if "PGPASSWORD" in os.environ:
del os.environ["PGPASSWORD"]
async def _send_backup_to_admin(bot, backup_file_path):
def _cleanup_old_backups() -> Exception | None:
"""
Удаляет бэкапы старше 3 дней.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
try:
with open(backup_file_path, "rb") as backup_file:
backup_input_file = BufferedInputFile(
backup_file.read(), filename=os.path.basename(backup_file_path)
)
admin_ids: int | list[int] = ADMIN_ID
if isinstance(admin_ids, list):
for id in admin_ids:
await bot.send_document(id, backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {id}")
else:
await bot.send_document(admin_ids, backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
backup_dir = Path(BACK_DIR)
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)
if file_mtime < cutoff_date:
backup_file.unlink()
logger.info(f"Удален старый бэкап: {backup_file}")
logger.info("Очистка старых бэкапов завершена")
return None
except Exception as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
return e
async def create_backup_and_send_to_admins(client) -> None:
"""
Создает бэкап и отправляет администраторам через переданный клиент.
Args:
client: Клиент для работы с базой данных
"""
await client.login()
await client.database.export()
async def _send_backup_to_admins(backup_file_path: str) -> None:
"""
Отправляет файл бэкапа всем администраторам через Telegram.
Args:
backup_file_path: Путь к файлу бэкапа
Raises:
Exception: При ошибке отправки файла
"""
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
try:
async with aiofiles.open(backup_file_path, "rb") as backup_file:
backup_data = await backup_file.read()
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
for admin_id in ADMIN_ID:
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
def _cleanup_old_backups():
try:
subprocess.run(
[
"find",
BACK_DIR,
"-type",
"f",
"-name",
"*.sql",
"-mtime",
"+3",
"-exec",
"rm",
"{}",
";",
],
check=True,
)
logger.info("Старые бэкапы удалены.")
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
async def create_backup_and_send_to_admins(xui):
await xui.login()
await xui.database.export()
raise
+75 -27
View File
@@ -3,42 +3,90 @@ import traceback
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import ErrorEvent
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
from config import API_TOKEN
from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
from middlewares.admin import AdminMiddleware
from middlewares.database import DatabaseMiddleware
from middlewares.delete import DeleteMessageMiddleware
from middlewares.logging import LoggingMiddleware
from middlewares.user import UserMiddleware
from middlewares import register_middleware
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
version = "4.1-Release"
dp.message.middleware(AdminMiddleware())
dp.callback_query.middleware(AdminMiddleware())
dp.message.middleware(UserMiddleware())
dp.callback_query.middleware(UserMiddleware())
register_middleware(dp)
dp.message.middleware(DatabaseMiddleware())
dp.callback_query.middleware(DatabaseMiddleware())
dp.message.outer_middleware(DeleteMessageMiddleware())
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
@dp.error()
async def error_handler(event: ErrorEvent):
logger.error(
"Ошибка в боте:\n"
f"Исключение: {event.exception}\n"
f"Тип: {type(event.exception)}\n"
f"Update: {event.update}\n"
f"Трассировка:\n{traceback.format_exc()}"
)
@dp.errors(ExceptionTypeFilter(Exception))
async def errors_handler(
event: ErrorEvent,
bot: Bot,
) -> bool:
if isinstance(event.exception, TelegramForbiddenError):
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
return True
if isinstance(event.exception, TelegramBadRequest):
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid" in error_message
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
logger.warning("Отправляем стартовое меню.")
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
await start_command(
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
traceback.format_exc().encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
except TelegramBadRequest as exception:
logger.warning(f"Failed to send error details: {exception}")
except Exception as exception:
logger.error(f"Unexpected error in error handler: {exception}")
return True
+255
View File
@@ -0,0 +1,255 @@
import os
import sys
import subprocess
import requests
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.table import Table
from rich.text import Text
import re
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding='utf-8')
sys.stdout.reconfigure(encoding='utf-8')
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"
BACK_DIR = os.path.expanduser("~/.solobot_backup")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
console = Console()
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 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 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:
subprocess.run("bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
def restart_service():
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
def get_local_version():
path = os.path.join(PROJECT_DIR, "bot.py")
if not os.path.isfile(path):
return None
with open(path, "r", encoding="utf-8") as f:
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():
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
backup_project()
install_git_if_needed()
install_rsync_if_needed()
os.chdir(PROJECT_DIR)
console.print("[cyan]📥 Клонируем репозиторий dev во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
restart_service()
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
def update_from_release():
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
return
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]
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", shell=True, check=True)
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
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():
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", 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", "Выход")
console.print(table)
def main():
if os.geteuid() != 0:
console.print("[bold red]⛔ Требуется запуск от имени root или через sudo.[/bold red]")
sys.exit(1)
os.chdir(PROJECT_DIR)
print_logo()
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 9)])
if choice == "1":
os.system(f"sudo systemctl start {SERVICE_NAME}")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
os.system("sudo venv/bin/python main.py")
elif choice == "3":
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
os.system(f"sudo systemctl restart {SERVICE_NAME}")
elif choice == "4":
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
os.system(f"sudo systemctl stop {SERVICE_NAME}")
elif choice == "5":
os.system(f"sudo journalctl -u {SERVICE_NAME} -n 80 --no-pager")
elif choice == "6":
os.system(f"sudo systemctl status {SERVICE_NAME}")
elif choice == "7":
show_update_menu()
elif choice == "8":
console.print("[bold cyan] Выход из CLI. Удачного дня![/bold cyan]")
break
if __name__ == "__main__":
main()
-115
View File
@@ -1,115 +0,0 @@
import py3xui
from config import LIMIT_IP
from logger import logger
async def add_client(
xui,
client_id: str,
email: str,
tg_id: str,
limit_ip: int,
total_gb: int,
expiry_time: int,
enable: bool,
flow: str,
inbound_id: int,
):
"""
Adds a client to the server via 3x-ui.
"""
try:
await xui.login()
client = py3xui.Client(
id=client_id,
email=email.lower(),
limit_ip=limit_ip,
total_gb=total_gb,
expiry_time=expiry_time,
enable=enable,
tg_id=tg_id,
sub_id=email,
flow=flow,
)
response = await xui.client.add(inbound_id, [client])
logger.info(f"Клиент {email} успешно добавлен с ID {client_id}.")
return response if response else {"status": "failed"}
except Exception as e:
logger.error(f"Ошибка при добавлении клиента {email}: {e}")
return {"status": "failed", "error": str(e)}
async def extend_client_key(
xui, inbound_id, email: str, new_expiry_time: int, client_id: str, total_gb: int
):
"""
Функция для обновления срока действия ключа клиента по email.
"""
await xui.login()
try:
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} не найден.")
return
if not client.id:
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
return
logger.info(
f"Обновление ключа клиента {client.email} с ID {client.id} до нового времени: {new_expiry_time}"
)
client.id = client_id
client.expiry_time = new_expiry_time
client.flow = "xtls-rprx-vision"
client.sub_id = email
client.total_gb = total_gb
client.enable = True
client.limit_ip = LIMIT_IP
client.inbound_id = inbound_id
await xui.client.update(client.id, client)
await xui.client.reset_stats(inbound_id, email)
logger.info(
f"Ключ клиента {client.email} успешно продлён до {new_expiry_time}."
)
except Exception as e:
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
async def delete_client(
xui,
inbound_id: int,
email: str,
client_id: str,
) -> bool:
"""
Функция для удаления клиента с сервера 3x-ui.
Возвращает True при успешном удалении, иначе False.
"""
await xui.login()
try:
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
return False
client.id = client_id
await xui.client.delete(inbound_id, client.id)
logger.info(f"Клиент с ID {client_id} был удален успешно.")
return True
except Exception as e:
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
return False
+903 -393
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -1,4 +1,3 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
@@ -8,9 +7,6 @@ from config import ADMIN_ID
class IsAdminFilter(BaseFilter):
async def __call__(self, message: Message) -> bool:
try:
admin_ids: int | list[int] = ADMIN_ID
if isinstance(admin_ids, list):
return message.from_user.id in admin_ids
return message.from_user.id == admin_ids
return message.from_user.id in ADMIN_ID
except Exception:
return False
+8
View File
@@ -0,0 +1,8 @@
from aiogram.enums import ChatType
from aiogram.filters import BaseFilter
from aiogram.types import Chat, TelegramObject
class IsPrivateFilter(BaseFilter):
async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool:
return event_chat.type == ChatType.PRIVATE
+3
View File
@@ -3,6 +3,7 @@ __all__ = ("router",)
from aiogram import Router
from .admin import router as admin_router
from .captcha import router as captcha_router
from .coupons import router as coupons_router
from .donate import router as donate_router
from .instructions import router as instructions_router
@@ -13,10 +14,12 @@ from .payments import router as payments_router
from .profile import router as profile_router
from .start import router as start_router
router = Router(name="handlers_main_router")
router.include_routers(
start_router,
captcha_router,
profile_router,
pay_router,
donate_router,
+20 -5
View File
@@ -2,16 +2,31 @@ __all__ = ("router",)
from aiogram import Router
from .admin_coupons import router as coupons_router
from .admin_panel import router as panel_router
from .admin_servers import router as servers_router
from .admin_user_editor import router as user_editor_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,
sender_router,
coupons_router,
user_editor_router,
restart_router,
bans_router,
)
-164
View File
@@ -1,164 +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 InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database import create_coupon, delete_coupon_from_db, get_all_coupons
from filters.admin import IsAdminFilter
from logger import logger
class AdminCouponsState(StatesGroup):
waiting_for_coupon_data = State()
router = Router()
@router.callback_query(F.data == "coupons_editor", IsAdminFilter())
async def show_coupon_management_menu(
callback_query: types.CallbackQuery, state: FSMContext
):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=" Создать купон", callback_data="create_coupon")
)
builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await callback_query.message.answer(
"🛠 Меню управления купонами:", reply_markup=builder.as_markup()
)
@router.callback_query(F.data == "coupons", IsAdminFilter())
async def show_coupon_list(callback_query: types.CallbackQuery, session: Any):
try:
coupons = await get_all_coupons(session)
if not coupons:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await callback_query.message.answer(
"❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙",
reply_markup=builder.as_markup(),
)
return
coupon_list = "📜 Список всех купонов:\n\n"
builder = InlineKeyboardBuilder()
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\n"
)
builder.row(
InlineKeyboardButton(
text=f"❌ Удалить {coupon['code']}",
callback_data=f"delete_coupon_{coupon['code']}",
)
)
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await callback_query.message.answer(
coupon_list, reply_markup=builder.as_markup()
)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter())
async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any):
coupon_code = callback_query.data[len("delete_coupon_") :]
try:
result = await delete_coupon_from_db(coupon_code, session)
if result:
await show_coupon_list(callback_query, session)
else:
await callback_query.message.answer(
f"❌ Купон с кодом <b>{coupon_code}</b> не найден.",
)
await show_coupon_list(callback_query, session)
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
@router.callback_query(F.data == "create_coupon", IsAdminFilter())
async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
await callback_query.message.answer(
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n",
reply_markup=builder.as_markup(),
)
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
async def process_coupon_data(message: types.Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
if len(parts) != 3:
await message.answer(
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈",
reply_markup=builder.as_markup(),
)
return
try:
coupon_code = parts[0]
coupon_amount = float(parts[1])
usage_limit = int(parts[2])
except ValueError:
await message.answer(
"⚠️ <b>Проверьте правильность введенных данных!</b>\n"
"💱 Сумма должна быть числом, 🔢 а лимит — целым числом.",
reply_markup=builder.as_markup(),
)
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
result_message = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан! 🎉\n"
f"Сумма: <b>{coupon_amount} рублей</b> 💰\n"
f"Лимит использования: <b>{usage_limit} раз</b> 🔢."
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await message.answer(result_message, reply_markup=builder.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
-391
View File
@@ -1,391 +0,0 @@
import subprocess
from datetime import datetime
from io import BytesIO
from typing import Any
from aiogram import F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from backup import backup_database
from bot import bot
from filters.admin import IsAdminFilter
from logger import logger
router = Router()
class UserEditorState(StatesGroup):
waiting_for_tg_id = State()
displaying_user_info = State()
waiting_for_restart_confirmation = State()
waiting_for_message = State()
@router.callback_query(F.data == "admin", IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
await handle_admin_message(callback_query.message, state)
@router.message(Command("admin"), F.data == "admin", IsAdminFilter())
async def handle_admin_message(message: types.Message, state: FSMContext):
await state.clear()
BOT_VERSION = "3.2.4-beta" # Укажите текущую версию бота
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📊 Статистика пользователей", callback_data="user_stats"
)
)
builder.row(
InlineKeyboardButton(
text="👥 Управление пользователями", callback_data="user_editor"
)
)
builder.row(
InlineKeyboardButton(
text="🖥️ Управление серверами", callback_data="servers_editor"
)
)
builder.row(
InlineKeyboardButton(
text="🎟️ Управление купонами", callback_data="coupons_editor"
)
)
builder.row(
InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to_alls")
)
builder.row(
InlineKeyboardButton(
text="🤖 Управление Ботом", callback_data="bot_management"
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
await message.answer(
f"🤖 Панель администратора\n\nВерсия бота: <b>{BOT_VERSION}</b>",
reply_markup=builder.as_markup(),
parse_mode="HTML"
)
@router.callback_query(F.data == "bot_management")
async def handle_bot_management(callback_query: types.CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups")
)
builder.row(
InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
)
builder.row(
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin")
)
await callback_query.message.answer(
"🤖 Управление ботом",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "user_stats", IsAdminFilter())
async def user_stats_menu(callback_query: CallbackQuery, session: Any):
try:
total_users = await session.fetchval("SELECT COUNT(*) FROM connections")
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
total_payments_today = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"
)
total_payments_week = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
total_payments_month = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
total_payments_all_time = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments"
)
active_keys = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
int(datetime.utcnow().timestamp() * 1000),
)
expired_keys = total_keys - active_keys
stats_message = (
f"📊 <b>Подробная статистика проекта:</b>\n\n"
f"👥 Пользователи:\n"
f" 🌐 Зарегистрировано: <b>{total_users}</b>\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"
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats")
)
builder.row(
InlineKeyboardButton(
text="📥 Выгрузить пользователей в CSV",
callback_data="export_users_csv",
)
)
builder.row(
InlineKeyboardButton(
text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv"
)
)
builder.row(
InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")
)
await callback_query.message.answer(
stats_message, reply_markup=builder.as_markup()
)
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
@router.callback_query(F.data == "export_users_csv", IsAdminFilter())
async def export_users_csv(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
try:
users = await session.fetch(
"""
SELECT
u.tg_id,
u.username,
u.first_name,
u.last_name,
u.language_code,
u.is_bot,
c.balance,
c.trial
FROM users u
LEFT JOIN connections c ON u.tg_id = c.tg_id
"""
)
if not users:
await callback_query.message.answer(
"📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup()
)
return
csv_data = "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n"
for user in users:
csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n"
file_name = BytesIO(csv_data.encode("utf-8-sig"))
file_name.seek(0)
file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv")
await callback_query.message.answer_document(
file,
caption="📥 Экспорт пользователей в CSV",
reply_markup=builder.as_markup(),
)
file_name.close()
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
await callback_query.message.answer(
"❗ Произошла ошибка при экспорте пользователей.",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "export_payments_csv", IsAdminFilter())
async def export_payments_csv(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
try:
payments = await session.fetch(
"""
SELECT
u.tg_id,
u.username,
u.first_name,
u.last_name,
p.amount,
p.payment_system,
p.status,
p.created_at
FROM users u
JOIN payments p ON u.tg_id = p.tg_id
"""
)
if not payments:
await callback_query.message.answer(
"📭 Нет платежей для экспорта.", reply_markup=builder.as_markup()
)
return
csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV
for payment in payments:
csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n"
file_name = BytesIO(csv_data.encode("utf-8-sig"))
file_name.seek(0)
file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv")
await callback_query.message.answer_document(
file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup()
)
file_name.close()
except Exception as e:
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
await callback_query.message.answer(
"❗ Произошла ошибка при экспорте платежей.",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "send_to_alls", IsAdminFilter())
async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await callback_query.message.answer(
"✍️ Введите текст сообщения, который вы хотите отправить всем клиентам 📢🌐:",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_message)
@router.message(UserEditorState.waiting_for_message, IsAdminFilter())
async def process_message_to_all(
message: types.Message, state: FSMContext, session: Any
):
text_message = message.text
try:
tg_ids = await session.fetch("SELECT tg_id FROM connections")
total_users = len(tg_ids)
success_count = 0
error_count = 0
for record in tg_ids:
tg_id = record["tg_id"]
try:
await bot.send_message(chat_id=tg_id, text=text_message)
success_count += 1
except Exception as e:
error_count += 1
logger.error(
f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}"
)
await message.answer(
f"📤 Рассылка завершена:\n"
f"👥 Всего пользователей: {total_users}\n"
f"✅ Успешно отправлено: {success_count}\n"
f"❌ Не доставлено: {error_count}"
)
except Exception as e:
logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
await state.clear()
@router.callback_query(F.data == "backups", IsAdminFilter())
async def handle_backup(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.answer(
"💾 Инициализация резервного копирования базы данных..."
)
await backup_database()
await callback_query.message.answer(
"✅ Резервная копия успешно создана и отправлена администратору."
)
@router.callback_query(F.data == "restart_bot", IsAdminFilter())
async def handle_restart(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(UserEditorState.waiting_for_restart_confirmation)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да, перезапустить", callback_data="confirm_restart"
),
InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"),
)
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
await callback_query.message.answer(
"🤔 Вы уверены, что хотите перезапустить бота?",
reply_markup=builder.as_markup(),
)
@router.callback_query(
F.data == "confirm_restart",
UserEditorState.waiting_for_restart_confirmation,
IsAdminFilter(),
)
async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
try:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
capture_output=True,
text=True,
)
await state.clear()
await callback_query.message.answer(
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
)
except subprocess.CalledProcessError:
await callback_query.message.answer(
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
)
except Exception as e:
await callback_query.message.answer(
f"⚠️ Ошибка при перезагрузке бота: {e.stderr}",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "user_editor", IsAdminFilter())
async def user_editor_menu(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔍 Поиск по названию ключа",
callback_data="search_by_key_name",
)
)
builder.row(
InlineKeyboardButton(
text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id"
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Поиск по Username", callback_data="search_by_username"
)
)
builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin"))
await callback_query.message.answer(
"👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup()
)
-531
View File
@@ -1,531 +0,0 @@
import asyncpg
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
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, get_servers_from_db
from filters.admin import IsAdminFilter
router = Router()
class UserEditorState(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()
@router.callback_query(F.data == "servers_editor", IsAdminFilter())
async def handle_servers_editor(callback_query: types.CallbackQuery):
servers = await get_servers_from_db()
builder = InlineKeyboardBuilder()
for cluster_name, cluster_servers in servers.items():
builder.row(
InlineKeyboardButton(
text=f"⚙️ {cluster_name}", callback_data=f"manage_cluster|{cluster_name}"
)
)
builder.row(
InlineKeyboardButton(text=" Добавить кластер", callback_data="add_cluster")
)
builder.row(InlineKeyboardButton(text="🔙 Назад в админку", callback_data="admin"))
await callback_query.message.answer(
"<b>🔧 Управление кластерами</b>\n\n"
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "add_cluster", IsAdminFilter())
async def handle_add_cluster(callback_query: types.CallbackQuery, state: FSMContext):
await callback_query.message.answer(
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя кластера должно быть уникальным и на английском языке.</b>\n"
"<i>Пример:</i> <code>cluster2</code> или <code>us_east_1</code>",
parse_mode="HTML",
)
await state.set_state(UserEditorState.waiting_for_cluster_name)
@router.message(UserEditorState.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: types.Message, state: FSMContext):
cluster_name = message.text.strip()
if cluster_name == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not cluster_name:
await message.answer("❌ Имя кластера не может быть пустым. Попробуйте снова.")
return
await state.update_data(cluster_name=cluster_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_server_name)
@router.message(UserEditorState.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: types.Message, state: FSMContext):
server_name = message.text.strip()
if server_name == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not server_name:
await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.")
return
server_unique = await check_unique_server_name(server_name)
if not server_unique:
await message.answer(
"❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя."
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
await state.update_data(server_name=server_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
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",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_api_url)
@router.message(UserEditorState.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: types.Message, state: FSMContext):
api_url = message.text.strip()
if api_url == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not api_url.startswith("https://"):
await message.answer(
"❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
parse_mode="HTML",
)
return
api_url = api_url.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)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
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 в информации о клиенте.",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_subscription_url)
@router.message(UserEditorState.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: types.Message, state: FSMContext):
subscription_url = message.text.strip()
if subscription_url == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not subscription_url.startswith("https://"):
await message.answer(
"❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
parse_mode="HTML",
)
return
subscription_url = subscription_url.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)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_inbound_id)
@router.message(UserEditorState.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: types.Message, state: FSMContext):
inbound_id = message.text.strip()
if not inbound_id.isdigit():
await message.answer(
"❌ inbound_id должен быть числовым значением. Попробуйте снова."
)
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()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад к кластерам", callback_data="servers_editor"
)
)
await message.answer(
f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
reply_markup=builder.as_markup(),
)
await state.clear()
@router.callback_query(F.data.startswith("manage_cluster|"), IsAdminFilter())
async def handle_manage_cluster(callback_query: types.CallbackQuery, state: FSMContext):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🌍 {server['server_name']}",
callback_data=f"manage_server|{server['server_name']}",
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить сервер", callback_data=f"add_server|{cluster_name}"
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность серверов",
callback_data=f"server_availability|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап кластера",
callback_data=f"backup_cluster|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
)
)
await callback_query.message.answer(
f"🔧 Управление серверами для кластера {cluster_name}",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter())
async def handle_check_server_availability(callback_query: types.CallbackQuery):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.answer(f"Кластер '{cluster_name}' не содержит серверов.")
return
in_progress_message = await callback_query.message.answer(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
availability_message = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
)
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())
availability_message += (
f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
)
except Exception as e:
availability_message += f"{server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
)
)
await in_progress_message.edit_text(
availability_message, reply_markup=builder.as_markup()
)
await callback_query.answer()
@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter())
async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext):
server_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
server = None
cluster_name = None
for cluster, cluster_servers in servers.items():
server = next(
(s for s in cluster_servers if s["server_name"] == server_name), None
)
if server:
cluster_name = cluster
break
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🗑️ Удалить", callback_data=f"delete_server|{server_name}"
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
)
)
await callback_query.message.answer(
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}",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
else:
await callback_query.message.answer("❌ Сервер не найден.")
@router.callback_query(F.data.startswith("delete_server|"), IsAdminFilter())
async def handle_delete_server(callback_query: types.CallbackQuery, state: FSMContext):
server_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да", callback_data=f"confirm_delete_server|{server_name}"
),
InlineKeyboardButton(
text="❌ Нет", callback_data=f"manage_server|{server_name}"
),
)
await callback_query.message.answer(
f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter())
async def handle_confirm_delete_server(
callback_query: types.CallbackQuery, state: FSMContext
):
server_name = callback_query.data.split("|")[1]
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
DELETE FROM servers WHERE server_name = $1
""",
server_name,
)
await conn.close()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
)
)
await callback_query.message.answer(
f"🗑️ Сервер {server_name} успешно удален.", reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("add_server|"), IsAdminFilter())
async def handle_add_server(callback_query: types.CallbackQuery, state: FSMContext):
cluster_name = callback_query.data.split("|")[1]
await state.update_data(cluster_name=cluster_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await callback_query.message.answer(
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_server_name)
@router.callback_query(F.data.startswith("backup_cluster|"), IsAdminFilter())
async def handle_backup_cluster(callback_query: types.CallbackQuery):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
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)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
await callback_query.message.answer(
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await callback_query.answer()
-712
View File
@@ -1,712 +0,0 @@
import asyncio
from datetime import datetime
from typing import Any
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import TOTAL_GB
from database import delete_user_data, get_client_id_by_email, get_servers_from_db, restore_trial, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
delete_key_from_cluster,
delete_key_from_db,
renew_key_in_cluster,
)
from handlers.utils import sanitize_key_name
from logger import logger
router = Router()
class UserEditorState(StatesGroup):
waiting_for_tg_id = State()
waiting_for_username = State()
displaying_user_info = State()
waiting_for_new_balance = State()
waiting_for_key_name = State()
waiting_for_expiry_time = State()
waiting_for_message_text = State()
@router.callback_query(F.data == "search_by_tg_id", IsAdminFilter())
async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Введите Telegram ID клиента:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_tg_id)
@router.callback_query(F.data == "search_by_username", IsAdminFilter())
async def prompt_username(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Введите Username клиента:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_username)
@router.message(UserEditorState.waiting_for_username, IsAdminFilter())
async def handle_username_input(
message: types.Message, state: FSMContext, session: Any
):
# Extract the username from a message text by removing leading '@' and the Telegram URL prefix
username = message.text.strip().lstrip('@').replace('https://t.me/', '')
user_record = await session.fetchrow(
"SELECT tg_id FROM users WHERE username = $1", username
)
if not user_record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🔍 Пользователь с указанным username не найден. 🚫",
reply_markup=builder.as_markup(),
)
await state.clear()
return
tg_id = user_record["tg_id"]
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
if balance is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🚫 Пользователь с указанным tg_id не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(
InlineKeyboardButton(
text="📝 Изменить баланс",
callback_data=f"change_balance_{tg_id}",
)
)
builder.row(
InlineKeyboardButton(
text="🔄 Восстановить пробник",
callback_data=f"restore_trial_{tg_id}",
)
)
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(
InlineKeyboardButton(
text="✉️ Отправить сообщение",
callback_data=f"send_message_{tg_id}"
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("send_message_"))
async def handle_send_message(callback_query: types.CallbackQuery, state: FSMContext):
tg_id = callback_query.data.split("_")[2]
await state.update_data(target_tg_id=tg_id)
await callback_query.message.answer(
"✉️ Введите текст сообщения, которое вы хотите отправить пользователю."
)
await state.set_state(UserEditorState.waiting_for_message_text)
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
async def process_send_message(message: types.Message, state: FSMContext, bot: Bot):
data = await state.get_data()
target_tg_id = data.get("target_tg_id")
if not target_tg_id:
await message.answer("🚫 Ошибка: ID пользователя не найден.")
await state.clear()
return
try:
await bot.send_message(chat_id=target_tg_id, text=message.text)
await message.answer("✅ Сообщение успешно отправлено.")
except Exception as e:
await message.answer(f"❌ Не удалось отправить сообщение: {e}")
await state.clear()
@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter())
async def handle_tg_id_input(message: types.Message, state: FSMContext, session: Any):
tg_id = int(message.text)
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
if balance is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пользователь с указанным tg_id не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(
InlineKeyboardButton(
text="📝 Изменить баланс",
callback_data=f"change_balance_{tg_id}",
)
)
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(
InlineKeyboardButton(
text="🔄 Восстановить пробник",
callback_data=f"restore_trial_{tg_id}",
)
)
builder.row(
InlineKeyboardButton(
text="✉️ Отправить сообщение",
callback_data=f"send_message_{tg_id}"
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter())
async def handle_restore_trial(callback_query: types.CallbackQuery, session: Any):
tg_id = int(callback_query.data.split("_")[2])
await restore_trial(tg_id, session)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в меню администратора", callback_data="admin"
)
)
await callback_query.message.answer(
"✅ Триал успешно восстановлен.", reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("change_balance_"), IsAdminFilter())
async def process_balance_change(callback_query: CallbackQuery, state: FSMContext):
tg_id = int(callback_query.data.split("_")[2])
await state.update_data(tg_id=tg_id)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"💸 Введите новую сумму баланса:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_new_balance)
@router.message(UserEditorState.waiting_for_new_balance, IsAdminFilter())
async def handle_new_balance_input(
message: types.Message, state: FSMContext, session: Any
):
if not message.text.isdigit() or int(message.text) < 0:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пожалуйста, введите корректную сумму для изменения баланса.",
reply_markup=builder.as_markup(),
)
return
new_balance = int(message.text)
user_data = await state.get_data()
tg_id = user_data.get("tg_id")
await session.execute(
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
new_balance,
tg_id,
)
response_message = f"✅ Баланс успешно изменен на <b>{new_balance}</b>."
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в меню администратора",
callback_data="admin",
)
)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.clear()
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT 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
servers = await get_servers_from_db()
cluster_name = "Неизвестный кластер"
for cluster_name, cluster_servers in servers.items():
if any(
server["inbound_id"] == record["server_id"] for server in cluster_servers
):
cluster_name = cluster_name
break
expiry_date = datetime.utcfromtimestamp(record["expiry_time"] / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "<b>Ключ истек.</b>"
elif time_left.days > 0:
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
return {
"key": record["key"],
"expiry_date": expiry_date.strftime("%d %B %Y года"),
"days_left_message": days_left_message,
"server_name": cluster_name,
"balance": record["balance"],
"tg_id": record["tg_id"],
}
@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter())
async def process_key_edit(callback_query: CallbackQuery, session: Any):
email = callback_query.data.split("_", 2)[2]
key_details = await get_key_details(email, session)
if not key_details:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 <b>Информация о ключе не найдена.</b> 🚫",
reply_markup=builder.as_markup(),
)
return
response_message = (
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
f"🌐 Кластер: <b>{key_details['server_name']}</b>"
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="ℹ️ Получить информацию о юзере",
callback_data=f"user_info|{key_details['tg_id']}",
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Изменить время истечения",
callback_data=f"change_expiry|{email}",
)
)
builder.row(
InlineKeyboardButton(
text="❌ Удалить ключ",
callback_data=f"delete_key_admin|{email}",
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
@router.callback_query(F.data == "search_by_key_name", IsAdminFilter())
async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔑 Введите имя ключа:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_key_name)
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
async def handle_key_name_input(
message: types.Message, state: FSMContext, session: Any
):
key_name = sanitize_key_name(message.text)
key_details = await get_key_details(key_name, session)
if not key_details:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🚫 Пользователь с указанным именем ключа не найден.",
reply_markup=builder.as_markup(),
)
await state.clear()
return
response_message = (
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
f"🌐 Сервер: <b>{key_details['server_name']}</b>"
)
key_buttons = InlineKeyboardBuilder()
key_buttons.row(
InlineKeyboardButton(
text="ℹ️ Получить информацию о юзере",
callback_data=f"user_info|{key_details['tg_id']}",
)
)
key_buttons.row(
InlineKeyboardButton(
text="⏳ Изменить время истечения",
callback_data=f"change_expiry|{key_name}",
)
)
key_buttons.row(
InlineKeyboardButton(
text="❌ Удалить ключ",
callback_data=f"delete_key_admin|{key_name}",
)
)
key_buttons.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(response_message, reply_markup=key_buttons.as_markup())
await state.clear()
@router.callback_query(F.data.startswith("change_expiry|"), IsAdminFilter())
async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext):
email = callback_query.data.split("|")[1]
await callback_query.message.answer(
f"⏳ Введите новое время истечения для ключа <b>{email}</b> в формате <code>YYYY-MM-DD HH:MM:SS</code>:"
)
await state.update_data(email=email)
await state.set_state(UserEditorState.waiting_for_expiry_time)
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
async def handle_expiry_time_input(
message: types.Message, state: FSMContext, session: Any
):
user_data = await state.get_data()
email = user_data.get("email")
if not email:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"📧 Email не найден в состоянии. 🚫", reply_markup=builder.as_markup()
)
await state.clear()
return
try:
expiry_time_str = message.text
expiry_time = int(
datetime.strptime(expiry_time_str, "%Y-%m-%d %H:%M:%S").timestamp() * 1000
)
client_id = await get_client_id_by_email(email)
if client_id is None:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
)
await message.answer(
f"🚫 Клиент с email {email} не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
record = await session.fetchrow(
"SELECT server_id FROM keys WHERE client_id = $1", client_id
)
if not record:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
)
await message.answer(
"🚫 Клиент не найден в базе данных. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
clusters = await get_servers_from_db()
async def update_key_on_all_servers():
tasks = []
for cluster_name, cluster_servers in clusters.items():
for server in cluster_servers:
tasks.append(
asyncio.create_task(
renew_key_in_cluster(
cluster_name,
email,
client_id,
expiry_time,
total_gb=TOTAL_GB,
)
)
)
await asyncio.gather(*tasks)
await update_key_on_all_servers()
await update_key_expiry(client_id, expiry_time)
response_message = f"✅ Время истечения ключа для клиента {client_id} ({email}) успешно обновлено на всех серверах."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await message.answer(response_message, reply_markup=builder.as_markup())
except ValueError:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.",
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(e)
await state.clear()
@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter())
async def process_callback_delete_key(
callback_query: types.CallbackQuery, session: Any
):
email = callback_query.data.split("|")[1]
client_id = await session.fetchval(
"SELECT client_id FROM keys WHERE email = $1", email
)
if client_id is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Ключ не найден. 🚫", reply_markup=builder.as_markup()
)
return
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=f"confirm_delete_admin|{client_id}",
)
)
builder.row(
types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="user_editor")
)
await callback_query.message.answer(
"<b>❓ Вы уверены, что хотите удалить ключ?</b>",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter())
async def process_callback_confirm_delete(
callback_query: types.CallbackQuery, session: Any
):
client_id = callback_query.data.split("|")[1]
record = await session.fetchrow(
"SELECT email FROM keys WHERE client_id = $1", client_id
)
if record:
email = record["email"]
response_message = "✅ Ключ успешно удален."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
clusters = await get_servers_from_db()
async def delete_key_from_servers(email, client_id):
tasks = []
for cluster_name, cluster_servers in clusters.items():
for server in cluster_servers:
tasks.append(
delete_key_from_cluster(cluster_name, email, client_id)
)
await asyncio.gather(*tasks)
await delete_key_from_servers(email, client_id)
await delete_key_from_db(client_id, session)
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
else:
response_message = "🚫 Ключ не найден или уже удален."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("user_info|"), IsAdminFilter())
async def handle_user_info(
callback_query: types.CallbackQuery, state: FSMContext, session: Any
):
tg_id = int(callback_query.data.split("|")[1])
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(InlineKeyboardButton(text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}"))
builder.row(InlineKeyboardButton(text="🔄 Восстановить пробник", callback_data=f"restore_trial_{tg_id}"))
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await callback_query.message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("confirm_delete_user_"), IsAdminFilter())
async def confirm_delete_user(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
tg_id = int(callback_query.data.split("_")[3])
confirmation_markup = InlineKeyboardMarkup(
row_width=2,
inline_keyboard=[
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"delete_user_{tg_id}")],
[InlineKeyboardButton(text="❌ Отменить", callback_data="user_editor")],
],
)
await callback_query.message.answer(
f"Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=confirmation_markup
)
@router.callback_query(F.data.startswith("delete_user_"), IsAdminFilter())
async def delete_user(callback_query: types.CallbackQuery, session: Any):
tg_id = int(callback_query.data.split("_")[2])
key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
async def delete_keys_from_servers():
try:
tasks = []
for email, client_id in key_records:
servers = await get_servers_from_db()
for cluster_id, cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
await asyncio.gather(*tasks)
except Exception as e:
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
try:
await delete_user_data(session, tg_id)
back_button = InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await callback_query.message.answer(f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
await callback_query.message.answer(
f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .backups_handler import router
+31
View File
@@ -0,0 +1,31 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from backup import backup_database
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "backups"),
IsAdminFilter(),
)
async def handle_backups(callback_query: CallbackQuery):
kb = build_admin_back_kb("management")
await callback_query.message.edit_text(
text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
)
exception = await backup_database()
if exception:
text = f"❌ Ошибка при создании резервной копии: {exception}"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
await callback_query.message.edit_text(text=text, reply_markup=kb)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .bans_handler import router
+94
View File
@@ -0,0 +1,94 @@
from typing import Any
from aiogram import F, Router
from aiogram.types import BufferedInputFile, CallbackQuery
from database import delete_user_data
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_bans_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans"),
IsAdminFilter(),
)
async def handle_bans(callback_query: CallbackQuery):
text = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
await callback_query.message.edit_text(
text=text,
reply_markup=build_bans_kb(),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_export"),
IsAdminFilter(),
)
async def handle_bans_export(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("management")
try:
banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
import csv
import io
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "blocked_at"])
for user in banned_users:
writer.writerow([user["tg_id"], user["blocked_at"]])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт пользователей, заблокировавших бота в CSV",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Произошла ошибка при экспорте: {e}",
reply_markup=kb,
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_banned"),
IsAdminFilter(),
)
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("bans")
try:
blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
blocked_ids = [record["tg_id"] for record in blocked_users]
if not blocked_ids:
await callback_query.message.answer(
text="📂 Нет заблокировавших пользователей для удаления.",
reply_markup=kb,
)
return
for tg_id in blocked_ids:
await delete_user_data(session, tg_id)
await session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids)
await callback_query.message.answer(
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
reply_markup=kb,
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Произошла ошибка при удалении записей: {e}",
reply_markup=kb,
)
+13
View File
@@ -0,0 +1,13 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_bans_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📄 Выгрузить в CSV", callback_data=AdminPanelCallback(action="bans_export").pack())
builder.button(text="🗑️ Удалить из БД", callback_data=AdminPanelCallback(action="bans_delete_banned").pack())
builder.row(build_admin_back_btn("management"))
builder.adjust(1)
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .clusters_handler import router
+829
View File
@@ -0,0 +1,829 @@
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, TOTAL_GB, USE_COUNTRY_SELECTION
from database import check_unique_server_name, get_servers, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster, renew_key_in_cluster
from logger import logger
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_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"
"<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(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"
"Ссылку можно найти в поисковой строке браузера, при входе в 3X-UI.\n\n"
"️ Формат API URL:\n"
"<code>https://your_domain:port/panel_path/</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):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Ссылку можно найти в панели 3X-UI, в информации о клиенте.\n\n"
"️ Формат Subscription URL:\n"
"<code>https://your_domain:port_sub/sub_path/</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
subscription_url = message.text.strip().rstrip("/")
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("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()
if not inbound_id.isdigit():
await message.answer(
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
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("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
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_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
text = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
await callback_query.message.edit_text(text=text)
total_online_users = 0
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
for server in cluster_servers:
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
try:
await xui.login()
online_clients = await xui.client.online()
inbound_id = int(server["inbound_id"])
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{server['server_name']}</b> - {online_inbound_users} онлайн\n"
except Exception as e:
result_text += f"❌ <b>{server['server_name']}</b> - ошибка: {str(e) if str(e).strip() else 'Сервер недоступен'}\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
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
text = f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
await callback_query.message.edit_text(
text=text,
)
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.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 == "manage_cluster"), IsAdminFilter())
async def handle_manage_cluster_menu(callback_query: CallbackQuery, callback_data: AdminClusterCallback):
cluster_name = callback_data.data
await callback_query.message.edit_text(
text=f"<b>🛠 Управление кластером {cluster_name}</b>\nВыберите действие:",
reply_markup=build_cluster_management_kb(cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
cluster_name = callback_data.data
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
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 typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_btn
from ..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(
InlineKeyboardButton(
text="🛠 Управление",
callback_data=AdminClusterCallback(action="manage_cluster", 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=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=AdminClusterCallback(action="manage", data=cluster_name).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()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .coupons_handler import router
+489
View File
@@ -0,0 +1,489 @@
from datetime import datetime
import html
import pytz
from typing import Any
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 (
add_connection,
check_connection_exists,
create_coupon,
create_coupon_usage,
delete_coupon,
get_all_coupons,
get_keys,
update_key_expiry,
)
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.utils import format_days
from logger import logger
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()
waiting_for_key_selection = 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
)
@router.message(F.text.regexp(r"^/start coupons_(.+)$"))
async def handle_coupon_activation(
message: Message, state: FSMContext, session: Any, admin: bool = False, text: str = None, user_id: int = None
):
coupon_text = text if text is not None else message.text
logger.info(f"Текст купона в handle_coupon_activation: {coupon_text}")
coupon_code = coupon_text.split("coupons_")[1]
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
if not coupon:
await message.answer("❌ Купон не найден.")
return
if coupon["usage_count"] >= coupon["usage_limit"] or coupon["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
return
effective_user_id = user_id if user_id is not None else message.from_user.id
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon["id"],
effective_user_id
)
if usage:
await message.answer("❌ Вы уже активировали этот купон.")
return
connection_exists = await check_connection_exists(effective_user_id)
if not connection_exists:
await add_connection(tg_id=effective_user_id, session=session)
if coupon["amount"] > 0:
await session.execute(
"UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
coupon["amount"],
effective_user_id
)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], effective_user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon['amount']} рублей.")
await process_callback_view_profile(message, state, admin)
return
if coupon["days"] is not None and coupon["days"] > 0:
keys = await get_keys(effective_user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(AdminCouponsState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon["id"], user_id=effective_user_id)
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon_id,
callback_query.from_user.id
)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2",
callback_query.from_user.id,
client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
try:
await renew_key_in_cluster(
cluster_id=key["server_id"],
email=key["email"],
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M")
text = f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
await callback_query.message.answer(text)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Произошла ошибка при продлении подписки.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.answer("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
+70
View File
@@ -0,0 +1,70 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from 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: Optional[bool] = None
def build_coupons_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=" Создать купон", callback_data=AdminPanelCallback(action="coupons_create").pack())
builder.button(text="Купоны", callback_data=AdminPanelCallback(action="coupons_list").pack())
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for coupon in coupons:
coupon_code = coupon["code"]
builder.button(
text=f"{coupon_code}",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code).pack(),
)
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(action="coupons_list", page=current_page + 1).pack(),
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
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
+16
View File
@@ -0,0 +1,16 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
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())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
@@ -0,0 +1,75 @@
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
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()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .panel_handler import router
+61
View File
@@ -0,0 +1,61 @@
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, /, **data: Any) -> None:
if "page" not in data or data["page"] is None:
data["page"] = 1
super().__init__(**data)
def build_panel_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack())
builder.button(text="🔑 Поиск по названию ключа", callback_data=AdminPanelCallback(action="search_key").pack())
builder.row(
InlineKeyboardButton(text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.button(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack())
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
)
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1, 1, 2, 1, 2, 1)
return builder.as_markup()
def build_restart_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="✅ Да, перезагрузить", callback_data=AdminPanelCallback(action="restart_confirm").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
return build_admin_singleton_kb(BACK, action)
def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(build_admin_btn(text, action))
return builder.as_markup()
def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
return build_admin_btn(BACK, action)
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
+51
View File
@@ -0,0 +1,51 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.exceptions import TelegramBadRequest
from bot import version
from filters.admin import IsAdminFilter
from .keyboard import AdminPanelCallback, build_panel_kb
from logger import logger
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
await state.clear()
if callback_query.message.text:
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:
logger.error(f"Ошибка при удалении сообщения: {e}")
await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
@router.callback_query(F.data == "admin", IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
await handle_admin_message(callback_query.message, state)
@router.message(Command("admin"), IsAdminFilter())
async def handle_admin_message(message: Message, state: FSMContext):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
await state.clear()
await message.answer(text=text, reply_markup=build_panel_kb())
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .restart_handler import router
+42
View File
@@ -0,0 +1,42 @@
import subprocess
from aiogram import F, Router
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart"),
IsAdminFilter(),
)
async def handle_restart(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤔 Вы уверены, что хотите перезагрузить бота?",
reply_markup=build_restart_kb(),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart_confirm"),
IsAdminFilter(),
)
async def handle_restart_confirm(callback_query: CallbackQuery):
kb = build_admin_back_kb()
try:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
capture_output=True,
text=True,
)
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
except subprocess.CalledProcessError:
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
except Exception as e:
await callback_query.message.edit_text(text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", reply_markup=kb)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .sender_handler import router
+44
View File
@@ -0,0 +1,44 @@
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="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()
+147
View File
@@ -0,0 +1,147 @@
from datetime import datetime
from typing import Any
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
router = Router()
class AdminSender(StatesGroup):
waiting_for_message = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "sender"),
IsAdminFilter(),
)
async def handle_sender(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="✍️ Выберите группу пользователей для рассылки:",
reply_markup=build_sender_kb(),
)
@router.callback_query(
AdminSenderCallback.filter(F.type != "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback_text(
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
):
await callback_query.message.edit_text(
text="✍️ Введите текст сообщения для рассылки:",
reply_markup=build_admin_back_kb("sender"),
)
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
await state.set_state(AdminSender.waiting_for_message)
@router.callback_query(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
await callback_query.message.answer(
"✍️ Выберите кластер для рассылки сообщений:",
reply_markup=build_clusters_kb(clusters),
)
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session: Any):
"""
Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
"""
text_message = message.html_text if message.text else None
photo = message.photo[-1].file_id if message.photo else None
photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
if not text_message and message.caption:
text_message = message.caption
if not text_message and not photo and not photo_url:
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
return
state_data = await state.get_data()
send_to = state_data.get("type", "all")
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
WHERE k.expiry_time > $1
""",
int(datetime.utcnow().timestamp() * 1000),
)
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
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
""",
int(datetime.utcnow().timestamp() * 1000),
)
elif send_to == "untrial":
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections WHERE trial = 0")
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
JOIN servers s ON k.server_id = s.cluster_name
WHERE s.cluster_name = $1
""",
cluster_name,
)
else:
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
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:
if photo or photo_url:
await message.bot.send_photo(
chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
)
else:
await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
success_count += 1
except Exception as e:
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
text = (
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f"✅ <b>Доставлено:</b> {success_count}\n"
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
)
await message.answer(text=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
+21
View File
@@ -0,0 +1,21 @@
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) -> InlineKeyboardMarkup:
from ..clusters.keyboard import AdminClusterCallback
builder = InlineKeyboardBuilder()
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()
+187
View File
@@ -0,0 +1,187 @@
from typing import Any
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from database import delete_server, get_servers
from filters.admin import IsAdminFilter
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminServerCallback,
build_manage_server_kb,
)
router = Router()
@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()
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(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"),
)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .stats_handler import router
+26
View File
@@ -0,0 +1,26 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_stats_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack())
builder.button(
text="📥 Выгрузить пользователей в CSV",
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
)
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
+239
View File
@@ -0,0 +1,239 @@
from datetime import datetime, timezone
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from 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"):
action: str
tg_id: int
data: str | int | None = None
edit: bool = False
class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
action: str
tg_id: int
data: str
month: int | None = None
edit: bool = False
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)
days = (expiry - current_time).days
builder.button(
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=str(email)).pack(),
)
builder.button(
text="✉️ Сообщение", callback_data=AdminUserEditorCallback(action="users_send_message", tg_id=tg_id).pack()
)
builder.button(
text="💸 Изменить баланс",
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
)
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(),
)
builder.button(
text="❌ Удалить клиента", callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack()
)
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
)
return builder.as_markup()
def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month, amount in RENEWAL_PRICES.items():
builder.button(
text=f"+ {amount}Р ({month} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=amount).pack(),
)
builder.button(
text=f"- {amount}Р ({month} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=-amount).pack(),
)
builder.button(
text="💵 Добавить", callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack()
)
builder.button(
text="💵 Вычесть", callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack()
)
builder.button(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
)
return builder.as_markup()
def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month in RENEWAL_PRICES.keys():
month = int(month)
builder.button(
text=f"+ {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=month).pack(),
)
builder.button(
text=f"- {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=-month).pack(),
)
builder.button(
text="⏳ Добавить дни", callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Вычесть дни", callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
)
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
def build_user_delete_kb(tg_id: int):
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Да, удалить!",
callback_data=AdminUserEditorCallback(action="users_delete_user_confirm", tg_id=tg_id).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(1)
return builder.as_markup()
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
)
builder.adjust(1)
return builder.as_markup()
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="⏳ Время истечения",
callback_data=AdminUserEditorCallback(
action="users_expiry_edit", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="🔄 Перевыпустить",
callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=key_details["tg_id"]).pack(),
)
builder.button(
text="❌ Удалить",
callback_data=AdminUserEditorCallback(action="users_delete_key", data=email, tg_id=key_details["tg_id"]).pack(),
)
builder.button(
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()
def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=AdminUserEditorCallback(action="users_delete_key_confirm", data=email, tg_id=tg_id).pack(),
)
)
builder.row(build_editor_back_btn(tg_id))
builder.adjust(1)
return builder.as_markup()
def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
return build_editor_singleton_kb(BACK, tg_id, edit)
def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(build_editor_btn(text, tg_id, edit))
return builder.as_markup()
def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton:
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()
+932
View File
@@ -0,0 +1,932 @@
import asyncio
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 PUBLIC_LINK, RENEWAL_PRICES, TOTAL_GB
from database import (
delete_key,
delete_user_data,
get_balance,
get_client_id_by_email,
get_servers,
store_key,
update_balance,
update_key_expiry,
update_trial,
)
from filters.admin import IsAdminFilter
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,
)
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
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()
@router.callback_query(
AdminPanelCallback.filter(F.action == "search_user"),
IsAdminFilter(),
)
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
text = (
"<b>🔍 Поиск пользователя</b>"
"\n\n📌 Введите ID, Username или перешлите сообщение пользователя."
"\n\n🆔 ID - числовой айди"
"\n📝 Username - юзернейм пользователя"
"\n\n<i>✉️ Для поиска, вы можете просто переслать сообщение от пользователя.</i>"
)
await state.set_state(UserEditorState.waiting_for_user_data)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "search_key"),
IsAdminFilter(),
)
async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(UserEditorState.waiting_for_key_name)
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()
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
key_name = sanitize_key_name(message.text)
key_details = await get_key_details(key_name, session)
if not key_details:
await message.answer(text="🚫 Пользователь с указанным именем ключа не найден.", reply_markup=kb)
return
await process_user_search(message, state, session, key_details["tg_id"])
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_send_message"),
IsAdminFilter(),
)
async def handle_send_message(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext
):
tg_id = callback_data.tg_id
await callback_query.message.edit_text(
text="✉️ Введите текст сообщения, которое вы хотите отправить пользователю:", reply_markup=build_editor_kb(tg_id)
)
await state.update_data(tg_id=tg_id)
await state.set_state(UserEditorState.waiting_for_message_text)
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
async def handle_message_text_input(message: Message, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
try:
await message.bot.send_message(chat_id=tg_id, text=message.text)
await message.answer(text="✅ Сообщение успешно отправлено.", reply_markup=build_editor_kb(tg_id))
except Exception as e:
await message.answer(text=f"❌ Не удалось отправить сообщение: {e}", reply_markup=build_editor_kb(tg_id))
await state.clear()
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_trial_restore"),
IsAdminFilter(),
)
async def handle_trial_restore(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
tg_id = callback_data.tg_id
await update_trial(tg_id, 0, session)
await callback_query.message.edit_text(text="✅ Триал успешно восстановлен!", reply_markup=build_editor_kb(tg_id))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_edit"), IsAdminFilter())
async def handle_balance_change(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
tg_id = callback_data.tg_id
records = await session.fetch(
"""
SELECT amount, payment_system, status, created_at
FROM payments
WHERE tg_id = $1
ORDER BY created_at DESC
LIMIT 5
""",
tg_id,
)
balance = await get_balance(tg_id)
balance = int(balance)
text = (
f"<b>💵 Изменение баланса</b>"
f"\n\n🆔 ID: <b>{tg_id}</b>"
f"\n💰 Баланс: <b>{balance}Р</b>"
f"\n📊 Последние операции (5):"
)
if records:
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")
text += (
f"\n<blockquote>💸 Сумма: {amount} | {payment_system}"
f"\n📌 Статус: {status}"
f"\n⏳ Дата: {date}</blockquote>"
)
else:
text += "\n <i>🚫 Отсутствуют</i>"
await callback_query.message.edit_text(text=text, reply_markup=build_users_balance_kb(tg_id))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_add"), IsAdminFilter())
async def handle_balance_add(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
amount = callback_data.data
if amount:
await update_balance(tg_id, int(amount), session, is_admin=True)
await handle_balance_change(callback_query, callback_data, session)
return
await state.update_data(tg_id=tg_id, op_type="add")
await state.set_state(UserEditorState.waiting_for_balance)
await callback_query.message.edit_text(
text="✍️ Введите сумму, которую хотите добавить на баланс пользователя:",
reply_markup=build_users_balance_change_kb(tg_id),
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_take"), IsAdminFilter())
async def handle_balance_take(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id, op_type="take")
await state.set_state(UserEditorState.waiting_for_balance)
await callback_query.message.edit_text(
text="✍️ Введите сумму, которую хотите вычесть из баланса пользователя:",
reply_markup=build_users_balance_change_kb(tg_id),
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_set"), IsAdminFilter())
async def handle_balance_set(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id, op_type="set")
await state.set_state(UserEditorState.waiting_for_balance)
await callback_query.message.edit_text(
text="✍️ Введите баланс, который хотите установить пользователю:",
reply_markup=build_users_balance_change_kb(tg_id),
)
@router.message(UserEditorState.waiting_for_balance, IsAdminFilter())
async def handle_balance_input(message: Message, state: FSMContext, session: Any):
data = await state.get_data()
tg_id = data.get("tg_id")
op_type = data.get("op_type")
if not message.text.isdigit() or int(message.text) < 0:
await message.answer(
text="🚫 Пожалуйста, введите корректную сумму!", reply_markup=build_users_balance_change_kb(tg_id)
)
return
amount = int(message.text)
if op_type == "add":
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)
else:
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
await set_user_balance(tg_id, amount, session)
await message.answer(text=text, reply_markup=build_users_balance_change_kb(tg_id))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_key_edit"), IsAdminFilter())
async def handle_key_edit(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback | AdminUserKeyEditorCallback,
session: Any,
update: bool = False,
):
email = callback_data.data
key_details = await get_key_details(email, session)
if not key_details:
await callback_query.message.edit_text(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(callback_data.tg_id),
)
return
text = (
f"<b>🔑 Информация о ключе</b>"
f"\n\n<code>{key_details['key']}</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>"
)
if not update or not callback_data.edit:
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_details, email))
else:
await callback_query.message.edit_text(
text=text, reply_markup=build_users_key_expiry_kb(callback_data.tg_id, email)
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_expiry_edit"), IsAdminFilter())
async def handle_change_expiry(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback):
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_reply_markup(reply_markup=build_users_key_expiry_kb(tg_id, email))
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_expiry_add(
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
email = callback_data.data
month = callback_data.month
key_details = await get_key_details(email, session)
if not key_details:
await callback_query.message.edit_text(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(tg_id),
)
return
if month:
await change_expiry_time(key_details["expiry_time"] + month * 30 * 24 * 3600 * 1000, email, session)
await handle_key_edit(callback_query, callback_data, session, True)
return
await state.update_data(tg_id=tg_id, email=email, op_type="add")
await state.set_state(UserEditorState.waiting_for_expiry_time)
await callback_query.message.edit_text(
text="✍️ Введите количество дней, которое хотите добавить к времени действия ключа:",
reply_markup=build_users_key_show_kb(tg_id, email),
)
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "take"), IsAdminFilter())
async def handle_expiry_take(
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext
):
tg_id = callback_data.tg_id
email = callback_data.data
await state.update_data(tg_id=tg_id, email=email, op_type="take")
await state.set_state(UserEditorState.waiting_for_expiry_time)
await callback_query.message.edit_text(
text="✍️ Введите количество дней, которое хотите вычесть из времени действия ключа:",
reply_markup=build_users_key_show_kb(tg_id, email),
)
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "set"), IsAdminFilter())
async def handle_expiry_set(
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
email = callback_data.data
key_details = await get_key_details(email, session)
if not key_details:
await callback_query.message.edit_text(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(tg_id),
)
return
await state.update_data(tg_id=tg_id, email=email, op_type="set")
await state.set_state(UserEditorState.waiting_for_expiry_time)
text = (
"✍️ Введите новое время действия ключа:"
"\n\n📌 Формат: <b>год-месяц-день час:минута</b>"
f"\n\n📄 Текущая дата: {datetime.fromtimestamp(key_details['expiry_time'] / 1000).strftime('%Y-%m-%d %H:%M')}"
)
await callback_query.message.edit_text(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
async def handle_expiry_time_input(message: Message, state: FSMContext, session: Any):
data = await state.get_data()
tg_id = data.get("tg_id")
email = data.get("email")
op_type = data.get("op_type")
if op_type != "set" and (not message.text.isdigit() or int(message.text) < 0):
await message.answer(
text="🚫 Пожалуйста, введите корректное количество дней!",
reply_markup=build_users_key_show_kb(tg_id, email),
)
return
key_details = await get_key_details(email, session)
if not key_details:
await message.answer(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(tg_id),
)
return
try:
current_expiry_time = datetime.fromtimestamp(key_details["expiry_time"] / 1000, tz=MOSCOW_TZ)
if op_type == "add":
days = int(message.text)
new_expiry_time = current_expiry_time + timedelta(days=days)
text = f"✅ Ко времени действия ключа добавлено <b>{days} дн.</b>"
elif op_type == "take":
days = int(message.text)
new_expiry_time = current_expiry_time - timedelta(days=days)
text = f"✅ Из времени действия ключа вычтено <b>{days} дн.</b>"
else:
new_expiry_time = datetime.strptime(message.text, "%Y-%m-%d %H:%M")
new_expiry_time = MOSCOW_TZ.localize(new_expiry_time)
text = f"✅ Время действия ключа изменено на <b>{message.text} (МСК)</b>"
new_expiry_timestamp = int(new_expiry_time.timestamp() * 1000)
await change_expiry_time(new_expiry_timestamp, email, session)
except ValueError:
text = "🚫 Пожалуйста, используйте корректный формат даты (ГГГГ-ММ-ДД ЧЧ:ММ)!"
except Exception as e:
text = f"❗ Произошла ошибка во время изменения времени действия ключа: {e}"
await message.answer(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_update_key"), IsAdminFilter())
async def handle_update_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
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, 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())
async def handle_delete_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
email = callback_data.data
client_id = await session.fetchval("SELECT client_id FROM keys WHERE email = $1", email)
if client_id is None:
await callback_query.message.edit_text(
text="🚫 Ключ не найден!", reply_markup=build_editor_kb(callback_data.tg_id)
)
return
await callback_query.message.edit_text(
text="❓ Вы уверены, что хотите удалить ключ?", reply_markup=build_key_delete_kb(callback_data.tg_id, email)
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key_confirm"), IsAdminFilter())
async def handle_delete_key_confirm(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
email = callback_data.data
record = await session.fetchrow("SELECT client_id FROM keys WHERE email = $1", email)
kb = build_editor_kb(callback_data.tg_id)
if record:
client_id = record["client_id"]
clusters = await get_servers()
async def delete_key_from_servers():
tasks = []
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, return_exceptions=True)
await delete_key_from_servers()
await delete_key(client_id, session)
await callback_query.message.edit_text(text="✅ Ключ успешно удален.", reply_markup=kb)
else:
await callback_query.message.edit_text(text="🚫 Ключ не найден или уже удален.", reply_markup=kb)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_user"), IsAdminFilter())
async def handle_delete_user(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback):
tg_id = callback_data.tg_id
await callback_query.message.edit_text(
text=f"❗️ Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=build_user_delete_kb(tg_id)
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_user_confirm"), IsAdminFilter())
async def handle_delete_user_confirm(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
tg_id = callback_data.tg_id
key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
async def delete_keys_from_servers():
try:
tasks = []
for email, client_id in key_records:
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, return_exceptions=True)
except Exception as e:
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
try:
await delete_user_data(session, tg_id)
await callback_query.message.edit_text(
text=f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=build_editor_kb(callback_data.tg_id)
)
except Exception as e:
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_editor"), IsAdminFilter())
async def handle_editor(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
await process_user_search(callback_query.message, state, session, callback_data.tg_id, callback_data.edit)
async def process_user_search(
message: types.Message, state: FSMContext, session: Any, tg_id: int, edit: bool = False
) -> None:
await state.clear()
balance = await session.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
if balance is None:
await message.answer(
text="🚫 Пользователь с указанным ID не найден!",
reply_markup=build_admin_back_kb(),
)
return
balance = int(balance)
user_data = await session.fetchrow("SELECT username, created_at, updated_at FROM users WHERE tg_id = $1", tg_id)
username = await session.fetchval("SELECT username FROM users WHERE tg_id = $1", tg_id)
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
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")
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>"
)
kb = build_user_edit_kb(tg_id, key_records)
if edit:
try:
await message.edit_text(text=text, reply_markup=kb)
except TelegramBadRequest:
pass
else:
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)
if client_id is None:
return ValueError(f"User with email {email} was not found")
server_id = await session.fetchrow("SELECT server_id FROM keys WHERE client_id = $1", client_id)
if not server_id:
return ValueError(f"User with client_id {server_id} was not found")
clusters = await get_servers()
async def update_key_on_all_servers():
tasks = [
asyncio.create_task(
renew_key_in_cluster(
cluster_name,
email,
client_id,
expiry_time,
total_gb=TOTAL_GB,
)
)
for cluster_name in clusters
]
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
):
"""
Обработчик кнопки "📊 Трафик".
Получает трафик пользователя и отправляет администратору.
"""
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_text("⏳ Получаем данные о трафике, пожалуйста, подождите...")
traffic_data = await get_user_traffic(session, tg_id, email)
if traffic_data["status"] == "error":
await callback_query.message.edit_text(traffic_data["message"], reply_markup=build_editor_kb(tg_id, True))
return
total_traffic = 0
result_text = f"📊 <b>Трафик подписки {email}:</b>\n\n"
for server, traffic in traffic_data["traffic"].items():
if isinstance(traffic, str):
result_text += f"{server}: {traffic}\n"
else:
result_text += f"🌍 {server}: <b>{traffic} ГБ</b>\n"
total_traffic += 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 connections
SET trial = 0
WHERE tg_id IN (
SELECT DISTINCT c.tg_id
FROM connections c
LEFT JOIN keys k ON c.tg_id = k.tg_id
WHERE k.tg_id IS NULL AND c.trial != 0
)
"""
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_select_cluster(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id)
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_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"]
cluster_name = data["cluster_name"]
client_id = str(uuid.uuid4())
email = generate_random_email()
expiry = datetime.now(tz=timezone.utc) + timedelta(days=30 * months)
expiry_ms = int(expiry.timestamp() * 1000)
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
await store_key(
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_time=expiry_ms,
key=public_link,
server_id=cluster_name,
session=session,
)
await state.clear()
await callback_query.message.edit_text(
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.!",
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(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)
)
+88
View File
@@ -0,0 +1,88 @@
# Основные кнопки меню:
MAIN_MENU = "👤 Личный кабинет"
BACK = "⬅️ Назад"
SUPPORT = "💬 Поддержка"
CHANNEL = "📢 Канал"
ABOUT_VPN = "💬 О сервисе"
APPLY = "✅ Подтвердить"
CANCEL = "❌ Отмена"
# Профиль
ADD_SUB = " Подписка"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
INVITE = "👥 Пригласить друга"
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 = "🎁 Поделиться подарком"
APPLY = "✅ Подтвердить"
CANCEL = "❌ Отмена"
# Создание и подключение ключей
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 = "💰 Ввести свою сумму"
PAY = "Пополнить"
PAY_2 = "Оплатить"
STARS_BOT = "🤖 Бот для покупки звезд"
-5
View File
@@ -1,5 +0,0 @@
GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
PROFILE = "👤 Личный кабинет"
BACK = "🔙 Назад"
GIFTS_ABOUT = "<b>Дарите подарки и следите, чтобы они дошли до адресата! 🎄</b>"
-8
View File
@@ -1,8 +0,0 @@
ADD_SUB = " Устройство"
MY_SUBS = "📱 Мои устройства"
PAYMENT = "💳 Пополнить баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
MAIN_MENU = "⬅️ Главное меню"
-3
View File
@@ -1,3 +0,0 @@
PAY = "Пополнить"
BACK = "⬅️ Назад"
CUSTOM_SUM = "💰 Ввести свою сумму"
+78
View File
@@ -0,0 +1,78 @@
import random
import secrets
from typing import Any
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CAPTCHA_EMOJIS
from handlers.texts import CAPTCHA_PROMPT_MSG
from logger import logger
from .utils import edit_or_send_message
router = Router()
async def generate_captcha(message: Message, state: FSMContext):
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
all_emojis = [correct_emoji] + wrong_emojis
random.shuffle(all_emojis)
await state.update_data(
correct_emoji=correct_emoji,
message_id=message.message_id,
chat_id=message.chat.id,
original_text=message.text,
)
builder = InlineKeyboardBuilder()
for emoji in all_emojis:
builder.button(text=emoji, callback_data=f"captcha_{emoji}")
builder.adjust(2, 2)
return {
"text": CAPTCHA_PROMPT_MSG.format(correct_text=correct_text),
"markup": builder.as_markup(),
}
@router.callback_query(F.data.startswith("captcha_"))
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
from handlers.start import start_command
selected_emoji = callback.data.split("captcha_")[1]
state_data = await state.get_data()
correct_emoji = state_data.get("correct_emoji")
message_id = state_data.get("message_id")
chat_id = state_data.get("chat_id")
if not message_id or not chat_id:
target_message = callback.message
else:
try:
target_message = await callback.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=callback.message.text,
)
except Exception:
target_message = callback.message
if selected_emoji == correct_emoji:
logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу")
await start_command(target_message, state, session, admin, captcha=False)
else:
logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
)
+43 -55
View File
@@ -1,13 +1,27 @@
from datetime import datetime
from typing import Any
from aiogram import F, Router, types
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database import update_balance
from database import (
check_coupon_usage,
create_coupon_usage,
get_coupon_by_code,
update_balance,
update_coupon_usage_count,
)
from handlers.buttons import MAIN_MENU, COUPON_RESTART
from handlers.texts import (
COUPON_ACTIVATED_SUCCESS_MSG,
COUPON_ALREADY_USED_MSG,
COUPON_INPUT_PROMPT,
COUPON_NOT_FOUND_MSG,
)
from .utils import edit_or_send_message
class CouponActivationState(StatesGroup):
@@ -18,80 +32,54 @@ router = Router()
@router.callback_query(F.data == "activate_coupon")
async def handle_activate_coupon(
callback_query: types.CallbackQuery, state: FSMContext
):
@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"))
await callback_query.message.answer(
"<b>🎫 Введите код купона:</b>\n\n"
"📝 Пожалуйста, введите действующий код купона, который вы хотите активировать. 🔑",
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
await edit_or_send_message(
target_message=target_message,
text=COUPON_INPUT_PROMPT,
reply_markup=builder.as_markup(),
media_path=None,
)
await state.set_state(CouponActivationState.waiting_for_coupon_code)
@router.message(CouponActivationState.waiting_for_coupon_code)
async def process_coupon_code(message: types.Message, state: FSMContext, session: Any):
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"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(InlineKeyboardButton(text=COUPON_RESTART, callback_data="activate_coupon"))
await message.answer(activation_result, reply_markup=builder.as_markup())
await message.answer(
activation_result, reply_markup=builder.as_markup(), parse_mode="HTML"
)
await state.clear()
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
coupon_record = await session.fetchrow(
"""
SELECT id, usage_limit, usage_count, is_used, amount
FROM coupons
WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE
""",
coupon_code,
)
coupon_record = await get_coupon_by_code(coupon_code, session)
if not coupon_record:
return "<b>❌ Купон не найден</b> 🚫 или его использование ограничено. 🔒 Пожалуйста, проверьте код и попробуйте снова. 🔍"
return COUPON_NOT_FOUND_MSG
usage_exists = await session.fetchrow(
"""
SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2
""",
coupon_record["id"],
user_id,
)
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
if usage_exists:
return "<b>❌ Вы уже активировали этот купон.</b> 🚫 Купоны могут быть активированы только один раз. 🔒"
return COUPON_ALREADY_USED_MSG
coupon_amount = coupon_record["amount"]
async with session.transaction():
await session.execute(
"""
UPDATE coupons
SET usage_count = usage_count + 1,
is_used = CASE WHEN usage_count + 1 >= usage_limit AND usage_limit > 0 THEN TRUE ELSE FALSE END
WHERE id = $1
""",
coupon_record["id"],
)
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
await session.execute(
"""
INSERT INTO coupon_usages (coupon_id, user_id, used_at)
VALUES ($1, $2, $3)
""",
coupon_record["id"],
user_id,
datetime.utcnow(),
)
await update_balance(user_id, coupon_amount)
return f"<b>✅ Купон успешно активирован! 🎉</b>\n\nНа ваш баланс добавлено <b>{coupon_amount} рублей</b> 💰."
await update_balance(user_id, coupon_amount, session)
return COUPON_ACTIVATED_SUCCESS_MSG.format(coupon_amount=coupon_amount)
+30 -27
View File
@@ -1,12 +1,15 @@
from aiogram import F, Router, types
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, LabeledPrice, PreCheckoutQuery
from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
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
class DonateState(StatesGroup):
entering_donate_amount = State()
@@ -18,51 +21,53 @@ router = Router()
@router.callback_query(F.data == "donate")
async def process_donate(callback_query: types.CallbackQuery, state: FSMContext):
async def process_donate(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
)
)
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
builder.row(
InlineKeyboardButton(
text="💰 Ввести сумму доната",
callback_data="enter_custom_donate_amount",
)
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await callback_query.message.answer(
text="🌟 Поддержите наш проект! 💪\n\n"
text = (
"🌟 Поддержите наш проект! 💪\n\n"
"💖 Каждый донат помогает развивать и улучшать сервис. "
"🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡",
"🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡"
)
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "enter_custom_donate_amount")
async def process_enter_donate_amount(
callback_query: types.CallbackQuery, state: FSMContext
):
async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
await callback_query.message.answer(
"💸 Введите сумму доната в рублях:", reply_markup=builder.as_markup()
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
text = "💸 Введите сумму доната в рублях:"
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
)
await state.set_state(DonateState.entering_donate_amount)
@router.message(DonateState.entering_donate_amount)
async def process_donate_amount_input(message: types.Message, state: FSMContext):
async def process_donate_amount_input(message: Message, state: FSMContext):
if message.text.isdigit():
amount = int(message.text)
if amount // RUB_TO_XTR <= 0:
await message.answer(
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
)
await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:")
return
await state.update_data(amount=amount)
@@ -71,7 +76,7 @@ async def process_donate_amount_input(message: types.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} рублей",
@@ -95,13 +100,11 @@ async def on_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
@router.message(F.successful_payment, DonateState.waiting_for_donate_payment)
async def on_successful_donate(message: types.Message, state: FSMContext):
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(
+125 -103
View File
@@ -1,157 +1,179 @@
import os
from typing import Any
import asyncpg
from aiogram import F, Router, types
from aiogram.types import BufferedInputFile, InlineKeyboardButton
from aiogram import F, Router
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CONNECT_MACOS, CONNECT_WINDOWS, DATABASE_URL, SUPPORT_CHAT_URL
from handlers.texts import CONNECT_TV_TEXT, INSTRUCTION_PC, INSTRUCTIONS, KEY_MESSAGE, SUBSCRIPTION_DETAILS_TEXT
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,
INSTRUCTIONS,
INSTRUCTION_MACOS,
INSTRUCTION_PC,
KEY_MESSAGE,
SUBSCRIPTION_DETAILS_TEXT,
)
from handlers.utils import edit_or_send_message
router = Router()
@router.callback_query(F.data == "instructions")
@router.message(F.text == "/instructions")
async def send_instructions(callback_query_or_message: types.CallbackQuery | types.Message):
async def send_instructions(callback_query_or_message: CallbackQuery | Message):
instructions_message = INSTRUCTIONS
image_path = os.path.join("img", "instructions.jpg")
if not os.path.isfile(image_path):
if isinstance(callback_query_or_message, types.CallbackQuery):
await callback_query_or_message.message.answer("Файл изображения не найден.")
else:
await callback_query_or_message.answer("Файл изображения не найден.")
return
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, types.CallbackQuery):
send_photo = callback_query_or_message.message.answer_photo
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
send_photo = callback_query_or_message.answer_photo
with open(image_path, "rb") as image_from_buffer:
await send_photo(
BufferedInputFile(image_from_buffer.read(), filename="instructions.jpg"),
caption=instructions_message,
reply_markup=builder.as_markup(),
)
target_message = callback_query_or_message
await edit_or_send_message(
target_message=target_message,
text=instructions_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
@router.callback_query(F.data.startswith("connect_pc|"))
async def process_connect_pc(callback_query: types.CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await session.fetchrow(
"""
SELECT k.key
FROM keys k
WHERE k.tg_id = $1 AND k.email = $2
""",
tg_id,
key_name,
)
record = await get_key_details(key_name, session)
if not record:
await callback_query.message.answer(
"❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍",
reply_markup=builder.as_markup(),
media_path=None,
)
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 = KEY_MESSAGE.format(key)
instruction_message = f"{key_message}{INSTRUCTION_PC}"
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=f"{SUPPORT_CHAT_URL}"))
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 callback_query.message.answer(
instruction_message,
await edit_or_send_message(
target_message=callback_query.message,
text=instruction_message,
reply_markup=builder.as_markup(),
parse_mode="HTML",
disable_web_page_preview=True,
media_path=None,
)
@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: types.CallbackQuery):
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="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 callback_query.message.answer(
await edit_or_send_message(
target_message=callback_query.message,
text=CONNECT_TV_TEXT,
reply_markup=builder.as_markup(),
parse_mode="HTML",
disable_web_page_preview=True
media_path=None,
disable_web_page_preview=True,
)
@router.callback_query(F.data.startswith("continue_tv|"))
async def process_continue_tv(callback_query: types.CallbackQuery):
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
tg_id = callback_query.from_user.id
print(f"tg_id: {tg_id}, key_name: {key_name}") # Отладочный вывод
conn = await asyncpg.connect(DATABASE_URL)
try:
record = await conn.fetchrow(
"""
SELECT k.key
FROM keys k
WHERE k.tg_id = $1 AND k.email = $2
""",
tg_id,
key_name,
)
print(f"Query result: {record}") # Отладочный вывод результата
finally:
await conn.close()
subscription_link = record['key']
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="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 callback_query.message.answer(
await edit_or_send_message(
target_message=callback_query.message,
text=message_text,
reply_markup=builder.as_markup(),
parse_mode="HTML"
media_path=None,
)
+1
View File
@@ -5,6 +5,7 @@ from aiogram import Router
from .key_management import router as management_router
from .keys import router as keys_router
router = Router(name="keys_main_router")
router.include_routers(
+587 -114
View File
@@ -1,139 +1,290 @@
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.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, FSInputFile
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from bot import bot
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
NOTIFY_EXTRA_DAYS,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import get_balance, get_trial, store_key, update_balance
from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import DISCOUNTS, key_message_success
from handlers.utils import generate_random_email, get_least_loaded_cluster
from database import (
add_connection,
check_connection_exists,
create_temporary_data,
get_balance,
get_key_details,
get_trial,
store_key,
update_balance,
update_trial,
check_server_name_by_cluster,
)
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PAYMENT,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_COUNTRY_MSG,
SELECT_TARIFF_PLAN_MSG,
key_message_success,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
from panels.three_xui import delete_client
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(StatesGroup):
waiting_for_server_selection = State()
waiting_for_key_name = State()
viewing_profile = State()
waiting_for_message = State()
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
logger.info(f"User {tg_id} confirmed creation of a new key.")
logger.info(f"Balance for user {tg_id} is sufficient. Proceeding with key creation.")
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
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.utcnow()
trial_status = await get_trial(tg_id, session)
current_time = datetime.now(moscow_tz)
if trial_status == 0:
expiry_time = current_time + timedelta(days=TRIAL_TIME)
logger.info(f"Assigned 1-day trial to user {tg_id}.")
await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", tg_id)
await create_key(tg_id, expiry_time, state, session, message_or_query)
else:
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}"
)
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.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await message_or_query.message.answer(
"💳 Выберите тарифный план для создания нового ключа:", reply_markup=builder.as_markup()
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}",
)
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
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, state: FSMContext, session: Any):
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:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await callback_query.message.answer(
"💳 Недостаточно средств для создания подписки. Пополните баланс в личном кабинете.",
reply_markup=builder.as_markup(),
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,
},
)
await state.clear()
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 update_balance(tg_id, -plan_price)
expiry_time = datetime.utcnow() + timedelta(days=duration_days)
await create_key(tg_id, expiry_time, state, session, callback_query)
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
async def create_key(
tg_id: int, expiry_time: datetime, state: FSMContext, session: Any, message_or_query: Message | CallbackQuery
tg_id: int,
expiry_time: datetime,
state: FSMContext | None,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
"""Создаёт ключ с заданным сроком действия."""
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
if USE_COUNTRY_SELECTION:
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
least_loaded_cluster = await get_least_loaded_cluster()
logger.info(f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов")
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
if not servers:
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
logger.info(f"[Country Selection] Список доступных серверов: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if target_message:
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await bot.send_message(
chat_id=tg_id,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
return
while True:
key_name = generate_random_email()
logger.info(f"Generated random key name for user {tg_id}: {key_name}")
existing_key = await session.fetchrow(
"SELECT * FROM keys WHERE email = $1 AND tg_id = $2",
key_name,
tg_id,
)
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 name '{key_name}' already exists for user {tg_id}. Generating a new one.")
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
client_id = str(uuid.uuid4())
email = key_name.lower()
@@ -142,55 +293,377 @@ async def create_key(
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,
)
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
)
]
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
least_loaded_cluster,
session,
)
await asyncio.gather(*tasks)
logger.info(f"Key created on cluster {least_loaded_cluster} for user {tg_id}.")
await store_key(tg_id, client_id, email, expiry_timestamp, public_link, least_loaded_cluster, session)
data = {}
if state:
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id} on cluster: {e}")
if isinstance(message_or_query, Message):
await message_or_query.answer("❌ Произошла ошибка при создании ключа. Пожалуйста, попробуйте снова.")
elif isinstance(message_or_query, CallbackQuery):
await message_or_query.message.answer(
"❌ Произошла ошибка при создании ключа. Пожалуйста, попробуйте снова."
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))
builder.row(
InlineKeyboardButton(text="🍏 Скачать для iOS", url=DOWNLOAD_IOS),
InlineKeyboardButton(text="🤖 Скачать для Android", url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text="🍏 Подключить на iOS", url=f"{CONNECT_IOS}{public_link}"),
InlineKeyboardButton(text="🤖 Подключить на Android", url=f"{CONNECT_ANDROID}{public_link}"),
)
builder.row(InlineKeyboardButton(text="💻 Windows/Linux/MacOS", callback_data=f"connect_pc|{email}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.utcnow()
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 = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
if isinstance(message_or_query, Message):
await message_or_query.answer(key_message, reply_markup=builder.as_markup())
elif isinstance(message_or_query, CallbackQuery):
await message_or_query.message.answer(key_message, reply_markup=builder.as_markup())
default_media_path = "img/pic.jpg"
await state.clear()
if target_message:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
current_server,
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name – значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
"""Финализирует создание ключа с выбором стран."""
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
public_link = old_key_details["key"]
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
if old_key_name:
old_server_id = old_key_details.get("server_id")
if old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
xui = AsyncApi(
old_server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
deletion_success = await delete_client(
xui,
old_server_info["inbound_id"],
email,
client_id,
)
if not deletion_success:
logger.warning(f"Не удалось удалить клиента с сервера {old_server_id}, но продолжаем процесс.")
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
if old_key_name:
await session.execute(
"""
UPDATE keys
SET server_id = $1
WHERE tg_id = $2 AND email = $3
""",
selected_country,
tg_id,
old_key_name,
)
else:
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
selected_country,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
if state:
await state.clear()
async def check_server_availability(server_info: dict) -> bool:
"""
Проверяет доступность сервера через API.
Args:
server_info (dict): Информация о сервере (api_url, username, password).
Returns:
bool: True, если сервер доступен, False в противном случае.
"""
try:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"Сервер {server_info.get('server_name', 'unknown')} доступен.")
return True
except asyncio.TimeoutError:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} недоступен: {e}")
return False
+420 -90
View File
@@ -1,126 +1,208 @@
import asyncio
from typing import Any
import asyncpg
from py3xui import AsyncApi
from client import add_client, delete_client, extend_client_key
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, LIMIT_IP, TOTAL_GB
from database import get_servers_from_db
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB
from database import get_servers, store_key, delete_notification
from handlers.utils import get_least_loaded_cluster
from logger import logger
from panels.three_xui import (
ClientConfig,
add_client,
delete_client,
extend_client_key,
get_client_traffic,
toggle_client,
)
async def create_key_on_cluster(cluster_id, tg_id, client_id, email, expiry_timestamp):
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 — это имя сервера).
"""
try:
tasks = []
servers = await get_servers_from_db()
servers = await get_servers()
cluster = servers.get(cluster_id)
if not cluster:
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
semaphore = asyncio.Semaphore(2)
inbound_id = server_info.get("inbound_id")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
if SUPERNODE:
for server_info in cluster:
await create_client_on_server(
server_info, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan
)
continue
conn = await asyncpg.connect(DATABASE_URL)
existing_key = await conn.fetchrow(
"SELECT 1 FROM keys WHERE email = $1", email
else:
await asyncio.gather(
*(
create_client_on_server(server, tg_id, client_id, email, expiry_timestamp, semaphore, plan=plan)
for server in cluster
),
return_exceptions=True,
)
if existing_key:
raise ValueError(f"Email {email} уже существует в базе данных.")
tasks.append(
add_client(
xui,
client_id,
email,
tg_id,
limit_ip=LIMIT_IP,
total_gb=TOTAL_GB,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
)
)
await conn.close()
await asyncio.gather(*tasks)
except Exception as e:
logger.error(f"Ошибка при создании ключа: {e}")
raise e
async def create_client_on_server(
server_info: dict,
tg_id: int,
client_id: str,
email: str,
expiry_timestamp: int,
semaphore: asyncio.Semaphore,
plan: int = None,
):
"""
Создает клиента на указанном сервере.
"""
async with semaphore:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
return
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
sub_id = email
else:
unique_email = email
sub_id = unique_email
total_gb_value = int(TOTAL_GB) if plan is None else int(plan) * int(TOTAL_GB)
await add_client(
xui,
ClientConfig(
client_id=client_id,
email=unique_email,
tg_id=tg_id,
limit_ip=LIMIT_IP,
total_gb=total_gb_value,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(inbound_id),
sub_id=sub_id,
),
)
if SUPERNODE:
await asyncio.sleep(0.7)
async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb):
try:
servers = await get_servers_from_db()
servers = await get_servers()
cluster = servers.get(cluster_id)
if not cluster:
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
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} очищены при продлении.")
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
)
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),
email,
new_expiry_time,
client_id,
total_gb,
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
)
)
await asyncio.gather(*tasks)
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(
f"Не удалось продлить ключ {client_id} в кластере {cluster_id}: {e}"
)
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise e
async def delete_key_from_db(client_id, session):
try:
await session.execute("DELETE FROM keys WHERE client_id = $1", client_id)
except Exception as e:
logger.error(f"Ошибка при удалении ключа {client_id} из базы данных: {e}")
async def delete_key_from_cluster(cluster_id, email, client_id):
"""Удаление ключа с серверов в кластере"""
"""Удаление ключа с серверов в кластере или с конкретного сервера"""
try:
servers = await get_servers_from_db()
servers = await get_servers()
cluster = servers.get(cluster_id)
if not cluster:
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
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 = []
for server_info in cluster:
@@ -128,6 +210,7 @@ async def delete_key_from_cluster(cluster_id, email, client_id):
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
@@ -146,22 +229,31 @@ async def 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} в кластере {cluster_id}: {e}"
)
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
raise e
async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id):
"""
Обновляет ключ на всех серверах указанного кластера (или сервера, если передано имя).
"""
try:
servers = await get_servers_from_db()
servers = await get_servers()
cluster = servers.get(cluster_id)
if not cluster:
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
found_servers = []
for _key, server_list in servers.items():
for server_info in server_list:
if server_info.get("server_name", "").lower() == cluster_id.lower():
found_servers.append(server_info)
if found_servers:
cluster = found_servers
else:
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
tasks = []
for server_info in cluster:
@@ -169,6 +261,7 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
@@ -181,26 +274,263 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
tasks.append(
add_client(
xui,
client_id,
email,
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),
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,
),
)
)
await asyncio.gather(*tasks)
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(
f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}"
)
logger.info(f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}")
except Exception as e:
logger.error(
f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}"
)
logger.error(f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}")
raise e
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
FROM keys k
WHERE k.tg_id = $1 AND k.email = $2
""",
tg_id,
email,
)
if not record:
raise ValueError(f"The key {email} does not exist in database")
expiry_time = record["expiry_time"]
client_id = record["client_id"]
old_cluster_id = record["server_id"]
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",
tg_id,
email,
)
new_cluster_id = cluster_override or await get_least_loaded_cluster()
await asyncio.gather(
update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id),
return_exceptions=True,
)
await store_key(
tg_id,
client_id,
email,
expiry_time,
public_link,
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]: Структура с данными о трафике.
"""
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": "❌ У пользователя нет активных ключей."}
server_ids = {row["server_id"] for row in rows}
query_servers = """
SELECT server_name, api_url 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)}"}
servers_map = {row["server_name"]: row["api_url"] for row in server_rows}
user_traffic_data = {}
async def fetch_traffic(api_url: str, client_id: str, server: str) -> tuple[str, Any]:
"""
Получает трафик с сервера для заданного client_id.
Возвращает кортеж: (server, used_gb) или (server, ошибка).
"""
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
try:
traffic_info = await get_client_traffic(xui, client_id)
if traffic_info["status"] == "success" and traffic_info["traffic"]:
client_data = traffic_info["traffic"][0]
used_gb = (client_data.up + client_data.down) / 1073741824
return server, round(used_gb, 2)
else:
return server, "Ошибка получения трафика"
except Exception as e:
return server, f"Ошибка: {e}"
tasks = []
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, return_exceptions=True)
for server, result in results:
user_traffic_data[server] = result
return {"status": "success", "traffic": user_traffic_data}
async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str, enable: bool = True) -> dict[str, Any]:
"""
Включает или отключает клиента на всех серверах указанного кластера.
Args:
cluster_id (str): ID кластера или имя сервера
email (str): Email клиента
client_id (str): UUID клиента
enable (bool): True для включения, False для отключения
Returns:
dict[str, Any]: Результат операции с информацией по каждому серверу
"""
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} не найден.")
results = {}
tasks = []
for server_info in cluster:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
results[server_name] = False
continue
if SUPERNODE:
unique_email = f"{email}_{server_name.lower()}"
else:
unique_email = email
tasks.append(toggle_client(xui, int(inbound_id), unique_email, client_id, enable))
task_results = await asyncio.gather(*tasks, return_exceptions=True)
for server_info, result in zip(cluster, task_results, strict=False):
server_name = server_info.get("server_name", "unknown")
if isinstance(result, Exception):
logger.error(f"Ошибка на сервере {server_name}: {result}")
results[server_name] = False
else:
results[server_name] = result
status = "включен" if enable else "отключен"
logger.info(f"Клиент {email} {status} на серверах кластера {cluster_id}")
return {"status": "success" if any(results.values()) else "error", "results": results}
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:
"""
Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
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 = []
for server_info in cluster:
api_url = server_info["api_url"]
inbound_id = server_info.get("inbound_id")
server_name = server_info.get("server_name", "unknown")
if not inbound_id:
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
continue
xui = AsyncApi(api_url, username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger)
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"✅ Трафик клиента {email} успешно сброшен на всех серверах кластера {cluster_id}")
except Exception as e:
logger.error(f"❌ Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
raise
+859 -404
View File
File diff suppressed because it is too large Load Diff
+319 -154
View File
@@ -1,214 +1,379 @@
import asyncio
import base64
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, TRANSITION_DATE_STR
from database import get_servers_from_db
from config import (
DATABASE_URL,
PROJECT_NAME,
SUPERNODE,
SUPPORT_CHAT_URL,
TOTAL_GB,
TRANSITION_DATE_STR,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
from database import get_key_details, get_servers
from handlers.utils import convert_to_bytes
from logger import logger
async def fetch_url_content(url, tg_id):
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} для tg_id: {tg_id}")
async with aiohttp.ClientSession() as session:
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} для tg_id: {tg_id}")
logger.info(f"Успешно получен контент с {url} для идентификатора: {identifier}")
return base64.b64decode(content).decode("utf-8").split("\n")
else:
logger.error(
f"Не удалось получить {url} для tg_id: {tg_id}, статус: {response.status}"
f"Не удалось получить {url} для идентификатора: {identifier}, статус: {response.status}"
)
return []
except TimeoutError:
logger.error(f"Таймаут при получении {url} для идентификатора: {identifier}")
return []
except Exception as e:
logger.error(f"Ошибка при получении {url} для tg_id: {tg_id}: {e}")
logger.error(f"Ошибка при получении {url} для идентификатора: {identifier}: {e}")
return []
async def combine_unique_lines(urls, tg_id, query_string):
all_lines = []
logger.info(
f"Начинаем объединение подписок для tg_id: {tg_id}, запрос: {query_string}"
)
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]:
"""
Объединяет строки подписки из нескольких URL, удаляя дубликаты.
urls_with_query = [f"{url}?{query_string}" for url in urls]
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}")
for url in urls_with_query:
lines = await fetch_url_content(url, tg_id)
all_lines.extend(lines)
all_lines = list(set(filter(None, all_lines)))
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_lines = set()
for lines in results:
all_lines.update(filter(None, lines))
logger.info(
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для tg_id: {tg_id}"
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для идентификатора: {identifier}"
)
return all_lines
return list(all_lines)
transition_date = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S")
async def get_subscription_urls(server_id: str, email: str, conn) -> list[str]:
"""
Получает список URL-адресов для подписки в зависимости от режима выбора страны.
transition_timestamp_ms = int(transition_date.timestamp() * 1000)
Args:
server_id: Идентификатор сервера или кластера
email: Email пользователя
conn: Соединение с базой данных
transition_timestamp_ms_adjusted = transition_timestamp_ms - (3 * 60 * 60 * 1000)
Returns:
Список URL-адресов для подписки
"""
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
logger.info(
f"Время перехода (с поправкой на часовой пояс): {transition_timestamp_ms_adjusted}"
)
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
async def handle_old_subscription(request):
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)
if m_total:
value = float(m_total.group(1))
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit)
country_remaining[country] = remaining_bytes
num_countries = len(country_remaining)
issued_per_country = TOTAL_GB
total_traffic_bytes = issued_per_country * num_countries
consumed_traffic_bytes = total_traffic_bytes - sum(country_remaining.values())
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
else:
consumed_traffic_bytes = 1
total_traffic_bytes = 0
return f"upload=0; download={consumed_traffic_bytes}; total={total_traffic_bytes}; expire={expire_timestamp}"
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} | Поддержка↗️"
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"support-url": SUPPORT_CHAT_URL,
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"profile-web-page-url": f"https://t.me/{USERNAME_BOT}",
"subscription-userinfo": subscription_userinfo,
}
elif "Hiddify" in user_agent:
parts = subscription_info.split(" - ")[0].split(": ")
key_info = parts[1] if len(parts) > 1 else parts[0]
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
return {
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
else:
encoded_project_name = f"{project_name}\n{subscription_info}"
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
}
async def handle_subscription(request: web.Request, old_subscription: bool = False) -> web.Response:
"""
Обрабатывает запрос на подписку (старую или новую).
Args:
request: Объект запроса
old_subscription: Флаг, указывающий на тип подписки (старая или новая)
Returns:
Ответ с подпиской или сообщением об ошибке
"""
email = request.match_info.get("email")
tg_id = request.match_info.get("tg_id") if not old_subscription else None
if not email:
logger.warning("Получен запрос без email")
return web.Response(
text="❌ Неверные параметры запроса. Требуется email.",
status=400,
)
if not email or (not old_subscription and not tg_id):
logger.warning("Получен запрос с отсутствующими параметрами")
return web.Response(text="❌ Неверные параметры запроса.", status=400)
logger.info(f"Обработка запроса для старого клиента с email: {email}")
logger.info(
f"Обработка запроса для {'старого' if old_subscription else 'нового'} клиента: email={email}, tg_id={tg_id}"
)
conn = await asyncpg.connect(DATABASE_URL)
try:
key_info = await conn.fetchrow(
"SELECT created_at, server_id FROM keys WHERE email = $1", email
)
if not key_info:
client_data = await get_key_details(email, conn)
if not client_data:
logger.warning(f"Клиент с email {email} не найден в базе.")
return web.Response(
text="❌ Клиент с таким email не найден.",
status=404,
)
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
created_at_ms = key_info["created_at"]
cluster_name = key_info.get("server_id")
if not cluster_name:
logger.warning(f"У клиента с email {email} отсутствует cluster_name.")
return web.Response(
text="❌ Устаревшие данные. Обратитесь в поддержку.",
status=400,
)
stored_tg_id = client_data.get("tg_id")
server_id = client_data["server_id"]
logger.info(f"Значение created_at для клиента с email {email}: {created_at_ms}, кластер: {cluster_name}")
if not old_subscription and int(tg_id) != int(stored_tg_id):
logger.warning(f"Неверный tg_id для клиента с email {email}.")
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000)
logger.info(
f"Время создания клиента в формате datetime (UTC): {created_at_datetime}"
)
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}")
if created_at_ms >= transition_timestamp_ms_adjusted:
logger.info(f"Клиент с email {email} является новым.")
return web.Response(
text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.",
status=400,
)
transition_timestamp_ms = get_transition_timestamp()
logger.info(f"Время перехода (с учетом часового пояса Москвы): {transition_timestamp_ms}")
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
logger.info(f"Сервера в кластере: {cluster_servers}")
if created_at_ms >= transition_timestamp_ms:
logger.info(f"Клиент с email {email} является новым.")
return web.Response(text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.", status=400)
urls = []
for server in cluster_servers:
server_subscription_url = f"{server['subscription_url']}/{email}"
urls.append(server_subscription_url)
expiry_time_ms = client_data.get("expiry_time")
time_left = format_time_left(expiry_time_ms)
combined_subscriptions = await combine_unique_lines(urls, email, "")
urls = await get_subscription_urls(server_id, email, conn)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
base64_encoded = base64.b64encode(
"\n".join(combined_subscriptions).encode("utf-8")
).decode("utf-8")
query_string = request.query_string if not old_subscription else ""
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string)
random.shuffle(combined_subscriptions)
encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}"
headers = {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "7",
"profile-title": "base64:" + base64.b64encode(
encoded_project_name.encode("utf-8")
).decode("utf-8"),
}
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
base64_encoded = base64.b64encode("\n".join(cleaned_subscriptions).encode("utf-8")).decode("utf-8")
subscription_info = f"📄 Подписка: {email} - {time_left}"
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
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):
email = request.match_info.get("email")
tg_id = request.match_info.get("tg_id")
if not email or not tg_id:
logger.warning("Получен запрос с отсутствующими параметрами email или tg_id")
return web.Response(
text="❌ Неверные параметры запроса. Требуются email и tg_id.",
status=400,
)
logger.info(f"Обработка запроса для нового клиента: email={email}, tg_id={tg_id}")
conn = await asyncpg.connect(DATABASE_URL)
try:
client_data = await conn.fetchrow(
"SELECT tg_id, server_id FROM keys WHERE email = $1", email
)
if not client_data:
logger.warning(f"Клиент с email {email} не найден в базе.")
return web.Response(
text="❌ Клиент с таким email не найден.",
status=404,
)
stored_tg_id = client_data["tg_id"]
cluster_name = client_data["server_id"]
if str(tg_id) != str(stored_tg_id):
logger.warning(f"Неверный tg_id для клиента с email {email}.")
return web.Response(
text="❌ Неверные данные. Получите свой ключ в боте.",
status=403,
)
finally:
await conn.close()
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
urls = []
for server in cluster_servers:
server_subscription_url = f"{server['subscription_url']}/{email}"
urls.append(server_subscription_url)
query_string = request.query_string
logger.info(f"Извлечен query string: {query_string}")
combined_subscriptions = await combine_unique_lines(urls, tg_id, query_string)
base64_encoded = base64.b64encode(
"\n".join(combined_subscriptions).encode("utf-8")
).decode("utf-8")
encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}"
headers = {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "7",
"profile-title": "base64:" + base64.b64encode(
encoded_project_name.encode("utf-8")
).decode("utf-8"),
}
logger.info(f"Возвращаем объединенные подписки для email: {email}")
return web.Response(text=base64_encoded, headers=headers)
async def handle_new_subscription(request: web.Request) -> web.Response:
"""Обработка запроса для новых клиентов."""
return await handle_subscription(request, old_subscription=False)
-68
View File
@@ -1,68 +0,0 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any
from py3xui import AsyncApi
from client import add_client
from config import ADMIN_PASSWORD, ADMIN_USERNAME, PUBLIC_LINK, TOTAL_GB, TRIAL_TIME
from database import get_servers_from_db, store_key, use_trial
from handlers.texts import INSTRUCTIONS
from handlers.utils import generate_random_email, get_least_loaded_cluster
async def create_trial_key(tg_id: int, session: Any):
client_id = str(uuid.uuid4())
email = generate_random_email()
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
instructions = INSTRUCTIONS
result = {"key": public_link, "instructions": instructions, "email": email}
current_time = datetime.utcnow()
expiry_time = current_time + timedelta(days=TRIAL_TIME)
expiry_timestamp = int(expiry_time.timestamp() * 1000)
clusters = await get_servers_from_db()
least_loaded_cluster = await get_least_loaded_cluster()
if least_loaded_cluster not in clusters:
raise ValueError(f"Кластер {least_loaded_cluster} не найден в базе данных.")
servers_in_cluster = clusters[least_loaded_cluster]
tasks = []
for server_info in servers_in_cluster:
tasks.append(
add_client(
AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
),
client_id,
email,
tg_id,
limit_ip=1,
total_gb=TOTAL_GB,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(server_info["inbound_id"]),
)
)
await asyncio.gather(*tasks)
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
server_id=least_loaded_cluster,
session=session,
)
await use_trial(tg_id, session)
return result
-415
View File
@@ -1,415 +0,0 @@
import asyncio
from datetime import datetime, timedelta
import asyncpg
from aiogram import Bot, Router, types
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
DATABASE_URL,
DEV_MODE,
RENEWAL_PLANS,
TOTAL_GB,
TRIAL_TIME,
)
from database import (
add_notification,
check_notification_time,
delete_key,
get_balance,
get_servers_from_db,
update_balance,
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 logger import logger
router = Router()
async def notify_expiring_keys(bot: Bot):
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
logger.info("Подключение к базе данных успешно.")
current_time = int(datetime.utcnow().timestamp() * 1000)
threshold_time_10h = int(
(datetime.utcnow() + timedelta(hours=10)).timestamp() * 1000
)
threshold_time_24h = int(
(datetime.utcnow() + timedelta(days=1)).timestamp() * 1000
)
logger.info("Начало обработки уведомлений.")
await notify_inactive_trial_users(bot, conn)
await asyncio.sleep(1)
await check_online_users()
await asyncio.sleep(1)
await notify_10h_keys(bot, conn, current_time, threshold_time_10h)
await asyncio.sleep(1)
await notify_24h_keys(bot, conn, current_time, threshold_time_24h)
await asyncio.sleep(1)
await handle_expired_keys(bot, conn, current_time)
await asyncio.sleep(1)
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
finally:
if conn:
await conn.close()
logger.info("Соединение с базой данных закрыто.")
async def is_bot_blocked(bot: Bot, chat_id: int) -> bool:
if DEV_MODE:
return False
try:
member = await bot.get_chat_member(chat_id, bot.id)
blocked = member.status == "left"
logger.info(
f"Статус бота для пользователя {chat_id}: {'заблокирован' if blocked else 'активен'}"
)
return blocked
except Exception as e:
logger.warning(
f"Не удалось проверить статус бота для пользователя {chat_id}: {e}"
)
return False
async def notify_10h_keys(
bot: Bot,
conn: asyncpg.Connection,
current_time: float,
threshold_time_10h: float,
):
records = await conn.fetch(
"""
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified = FALSE
""",
threshold_time_10h,
current_time,
)
logger.info(f"Найдено {len(records)} ключей для уведомления за 10 часов.")
for record in records:
tg_id = record["tg_id"]
email = record["email"]
expiry_time = record["expiry_time"]
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 = "Ключ истек"
elif time_left.days > 0:
days_left_message = f"{time_left.days}"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"{hours_left}"
message = KEY_EXPIRY_10H.format(
email=email,
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
days_left_message=days_left_message,
price=RENEWAL_PLANS["1"]["price"],
)
if not await is_bot_blocked(bot, tg_id) and not DEV_MODE:
try:
keyboard = InlineKeyboardBuilder()
keyboard.button(
text="🔄 Продлить VPN", callback_data=f"renew_key|{email}"
)
keyboard.button(text="💳 Пополнить баланс", callback_data="pay")
keyboard.button(text="👤 Личный кабинет", callback_data="profile")
keyboard.adjust(1)
keyboard = keyboard.as_markup()
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"Уведомление отправлено пользователю {tg_id}.")
except Exception as e:
logger.error(
f"Ошибка при отправке уведомления пользователю {tg_id}: {e}"
)
continue
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE client_id = $1",
record["client_id"],
)
logger.info(f"Обновлено поле notified для клиента {record['client_id']}.")
await asyncio.sleep(1)
async def notify_24h_keys(
bot: Bot,
conn: asyncpg.Connection,
current_time: float,
threshold_time_24h: float,
):
logger.info("Проверка истекших ключей...")
records_24h = await conn.fetch(
"""
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified_24h = FALSE
""",
threshold_time_24h,
current_time,
)
logger.info(f"Найдено {len(records_24h)} ключей для уведомления за 24 часа.")
for record in records_24h:
tg_id = record["tg_id"]
email = record["email"]
expiry_time = record["expiry_time"]
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 = "Ключ истек"
elif time_left.days > 0:
days_left_message = f"{time_left.days}"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"{hours_left}"
message_24h = KEY_EXPIRY_24H.format(
email=email,
days_left_message=days_left_message,
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
)
if not await is_bot_blocked(bot, tg_id) and not DEV_MODE:
try:
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🔄 Продлить VPN",
callback_data=f"renew_key|{email}",
)
)
builder.row(
types.InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="pay",
)
)
builder.row(
types.InlineKeyboardButton(
text="👤 Личный кабинет",
callback_data="profile",
)
)
keyboard = builder.as_markup()
await bot.send_message(tg_id, message_24h, reply_markup=keyboard)
logger.info(f"Уведомление за 24 часа отправлено пользователю {tg_id}.")
except Exception as e:
logger.error(
f"Ошибка при отправке уведомления за 24 часа пользователю {tg_id}: {e}"
)
continue
await conn.execute(
"UPDATE keys SET notified_24h = TRUE WHERE client_id = $1",
record["client_id"],
)
logger.info(
f"Обновлено поле notified_24h для клиента {record['client_id']}."
)
await asyncio.sleep(1)
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
logger.info("Проверка пользователей, не активировавших пробный период...")
inactive_trial_users = await conn.fetch(
"""
SELECT tg_id, username FROM users
WHERE tg_id IN (
SELECT tg_id FROM connections
WHERE trial = 0
) AND tg_id NOT IN (
SELECT DISTINCT tg_id FROM keys
)
"""
)
logger.info(f"Найдено {len(inactive_trial_users)} неактивных пользователей.")
for user in inactive_trial_users:
tg_id = user["tg_id"]
username = user.get("username", "Пользователь")
try:
can_notify = await check_notification_time(
tg_id, "inactive_trial", hours=24, session=conn
)
if can_notify and not await is_bot_blocked(bot, tg_id):
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
)
)
builder.row(
types.InlineKeyboardButton(
text="👤 Личный кабинет", callback_data="profile"
)
)
keyboard = builder.as_markup()
message = (
f"👋 Привет, {username}!\n\n"
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
"🕒 Не упусти возможность попробовать наш VPN прямо сейчас.\n\n"
"💡 Нажми на кнопку ниже, чтобы активировать пробный доступ."
)
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 Exception as e:
logger.error(
f"Ошибка при отправке уведомления неактивному пользователю {tg_id}: {e}"
)
await asyncio.sleep(1)
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: float):
logger.info("Проверка истекших ключей...")
expiring_keys = await conn.fetch(
"""
SELECT tg_id, client_id, expiry_time, email FROM keys
WHERE expiry_time <= $1
""",
current_time,
)
logger.info(f"current_time {current_time}")
logger.info(f"Найдено {len(expiring_keys)} истекающих ключей.")
await asyncio.gather(*[process_key(record, bot, conn) for record in expiring_keys])
async def process_key(record, bot, conn):
tg_id = record["tg_id"]
client_id = record["client_id"]
email = record["email"]
balance = await get_balance(tg_id)
expiry_time = record["expiry_time"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
logger.info(
f"Время истечения ключа: {expiry_time} (дата: {expiry_date}), Текущее время: {current_date}, Оставшееся время: {time_left}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="👤 Личный кабинет", callback_data="profile"
)
]
]
)
try:
if balance >= RENEWAL_PLANS["1"]["price"]:
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"])
new_expiry_time = int(
(datetime.utcnow() + timedelta(days=30)).timestamp() * 1000
)
await update_key_expiry(client_id, new_expiry_time)
servers = await get_servers_from_db()
for cluster_id in servers:
await renew_key_in_cluster(
cluster_id, email, client_id, new_expiry_time, TOTAL_GB
)
logger.info(
f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}."
)
await conn.execute(
"""
UPDATE keys
SET notified = FALSE, notified_24h = FALSE
WHERE client_id = $1
""",
client_id,
)
logger.info(
f"Флаги notified и notified_24 сброшены для клиента с ID {client_id}."
)
try:
await bot.send_message(tg_id, text=KEY_RENEWED, reply_markup=keyboard)
logger.info(
f"Уведомление об успешном продлении отправлено клиенту {tg_id}."
)
except Exception as e:
logger.error(f"Ошибка при отправке уведомления клиенту {tg_id}: {e}")
else:
message_expired = "Ваша подписка истекла и была удалена. Получите новую через личный кабинет"
try:
await bot.send_message(
tg_id, text=message_expired, reply_markup=keyboard
)
logger.info(
f"Уведомление об истечении подписки и удалении ключа отправлено пользователю {tg_id}."
)
except Exception as e:
logger.error(
f"Ошибка при отправке уведомления об истечении подписки пользователю {tg_id}: {e}"
)
servers = await get_servers_from_db()
for cluster_id in servers:
await delete_key_from_cluster(cluster_id, email, client_id)
logger.info(f"Клиент {client_id} удален из кластера {cluster_id}.")
await delete_key(client_id)
logger.info(f"Ключ для клиента с ID {client_id} удален из базы данных.")
except Exception as e:
logger.error(f"Ошибка при обработке ключа для клиента {tg_id}: {e}")
async def check_online_users():
servers = await get_servers_from_db()
for cluster_id, cluster in servers.items():
for server_id, server in enumerate(cluster):
xui = AsyncApi(
server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD
)
await xui.login()
try:
online_users = len(await xui.client.online())
logger.info(
f"Сервер '{server['server_name']}' доступен, текущее количество активных пользователей: {online_users}."
)
except Exception as e:
logger.error(
f"Не удалось проверить пользователей на сервере {server_id}: {e}"
)
+11
View File
@@ -0,0 +1,11 @@
__all__ = ("router",)
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)
@@ -0,0 +1,419 @@
import asyncio
from datetime import datetime, timedelta
import asyncpg
import pytz
from aiogram import Bot, Router
from config import (
DATABASE_URL,
NOTIFICATION_TIME,
NOTIFY_DELETE_DELAY,
NOTIFY_DELETE_KEY,
NOTIFY_INACTIVE_TRAFFIC,
NOTIFY_MAXPRICE,
NOTIFY_RENEW,
NOTIFY_RENEW_EXPIRED,
RENEWAL_PRICES,
TOTAL_GB,
TRIAL_TIME_DISABLE,
)
from database import (
add_notification,
check_notification_time,
delete_key,
get_all_keys,
get_balance,
get_last_notification_time,
update_balance,
update_key_expiry,
delete_notification
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
from handlers.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 logger import logger
from .notify_utils import send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
notification_lock = asyncio.Lock()
async def periodic_notifications(bot: Bot):
"""
Периодическая проверка и отправка уведомлений.
Защищена от одновременного запуска с помощью asyncio.Lock.
"""
while True:
if notification_lock.locked():
logger.warning("⛔ Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
await asyncio.sleep(NOTIFICATION_TIME)
continue
async with notification_lock:
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
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)
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):
logger.info("Начало проверки подписок, истекающих через 24 часа.")
expiring_keys = [
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_24h
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
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
if not can_notify:
continue
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
days_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
notification_text = KEY_EXPIRY_24H.format(
email=email,
days_left_message=days_left_message,
formatted_expiry_date=formatted_expiry_date,
)
if NOTIFY_RENEW:
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
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}")
logger.info("✅ Обработка всех уведомлений за 24 часа завершена.")
await asyncio.sleep(1)
async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_10h: int, keys: list):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 10 часов.
"""
logger.info("Начало проверки подписок, истекающих через 10 часов.")
expiring_keys = [
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_10h
]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
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
if not can_notify:
continue
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
notification_text = KEY_EXPIRY_10H.format(
email=email,
hours_left_message=hours_left_message,
formatted_expiry_date=formatted_expiry_date,
)
if NOTIFY_RENEW:
try:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_10h.jpg", notification_text
)
except Exception as e:
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}")
logger.info("✅ Обработка всех уведомлений за 10 часов завершена.")
await asyncio.sleep(1)
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
"""
Обрабатывает истекшие ключи, проверяя продление или удаление.
"""
logger.info("Начало обработки истекших ключей.")
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
for key in expired_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
client_id = key.get("client_id")
server_id = key.get("server_id")
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
if NOTIFY_RENEW_EXPIRED:
try:
balance = await get_balance(tg_id)
except Exception as e:
logger.error(f"Ошибка получения баланса для пользователя {tg_id}: {e}")
continue
renewal_period_months = 1
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
if balance >= renewal_cost:
try:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
)
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
continue
if NOTIFY_DELETE_KEY:
delete_immediately = NOTIFY_DELETE_DELAY == 0
delete_after_delay = False
if last_notification_time is not None:
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= NOTIFY_DELETE_DELAY
logger.info(
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
f"NOTIFY_DELETE_DELAY={NOTIFY_DELETE_DELAY}"
)
if delete_immediately or delete_after_delay:
try:
await delete_key_from_cluster(server_id, email, client_id)
await delete_key(client_id, conn)
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
keyboard = build_notification_expired_kb()
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
KEY_DELETED_MSG.format(email=email),
keyboard,
)
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
except Exception as e:
logger.error(f"Не удалось отправить уведомление об удалении пользователю {tg_id}: {e}")
except Exception as e:
logger.error(f"❌ Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
continue
if last_notification_time is None:
keyboard = build_notification_kb(email)
if NOTIFY_DELETE_DELAY > 0:
hours = NOTIFY_DELETE_DELAY // 60
minutes = NOTIFY_DELETE_DELAY % 60
if hours > 0:
if minutes > 0:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix} и {minutes} минут"
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(email=email, time_str=time_str)
else:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix}"
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(email=email, time_str=time_str)
else:
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(email=email, minutes=NOTIFY_DELETE_DELAY)
else:
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
delay_message,
keyboard,
)
await add_notification(tg_id, notification_id, session=conn)
logger.info(
f"📢 Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}."
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление о продлении подписки пользователю {tg_id}: {e}")
logger.info("✅ Обработка истекших ключей завершена.")
await asyncio.sleep(1)
async def process_auto_renew_or_notify(
bot,
conn,
key: dict,
notification_id: str,
renewal_period_months: int,
standard_photo: str,
standard_caption: str
):
"""
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
иначе отправляет стандартное уведомление.
"""
tg_id = key.get("tg_id")
email = key.get("email", "")
renew_notification_id = f"{email}_renew"
try:
can_renew = await check_notification_time(tg_id, renew_notification_id, hours=24, session=conn)
if not can_renew:
logger.info(
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
)
return
balance = await get_balance(tg_id)
except Exception as e:
logger.error(f"Ошибка получения данных для пользователя {tg_id}: {e}")
return
if NOTIFY_MAXPRICE:
renewal_period_months = max(
(int(months) for months, price in RENEWAL_PRICES.items() if balance >= price), default=None
)
else:
renewal_period_months = 1 if balance >= RENEWAL_PRICES["1"] else None
if renewal_period_months:
renewal_period_months = int(renewal_period_months)
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
client_id = key.get("client_id")
server_id = key.get("server_id")
current_expiry = key.get("expiry_time")
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
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 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}."
)
renewed_message = KEY_RENEWED.format(
email=email, months=renewal_period_months, expiry_date=formatted_expiry_date
)
keyboard = build_notification_expired_kb()
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
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)
+29
View File
@@ -0,0 +1,29 @@
from aiogram.types import InlineKeyboardMarkup
from handlers.buttons import MAIN_MENU, RENEW_KEY
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
"""
Формирует inline-клавиатуру для уведомлений.
Кнопки: "🔄 Продлить VPN" (callback_data содержит email) и "👤 Личный кабинет".
"""
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
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()
def build_notification_expired_kb() -> InlineKeyboardMarkup:
"""
Формирует inline-клавиатуру для уведомлений после удаления или продления.
Кнопка: "👤 Личный кабинет"
"""
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.button(text=MAIN_MENU, callback_data="profile")
return builder.as_markup()
+103
View File
@@ -0,0 +1,103 @@
import asyncio
import os
import aiofiles
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from logger import logger
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,
caption: str,
keyboard: InlineKeyboardMarkup | None = None,
) -> bool:
"""
Отправляет уведомление пользователю.
Args:
bot: Экземпляр бота для отправки сообщений
tg_id: Telegram ID пользователя
image_filename: Имя файла изображения в директории img
caption: Текст сообщения
keyboard: Клавиатура для сообщения (опционально)
Returns:
bool: True если сообщение успешно отправлено, False в случае ошибки
"""
photo_path = os.path.join("img", image_filename)
if os.path.isfile(photo_path):
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
else:
logger.warning(f"Файл с изображением не найден: {photo_path}")
return await _send_text_notification(bot, tg_id, caption, keyboard)
@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
@@ -0,0 +1,197 @@
import asyncio
from datetime import datetime, timedelta
import asyncpg
import pytz
from aiogram import Bot, Router, types
from aiogram.exceptions import TelegramForbiddenError
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
from database import (
add_notification,
check_notification_time,
create_blocked_user,
)
from 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 logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
"""
Проверяет пользователей, не активировавших пробный период, и отправляет им напоминания.
Первое уведомление — стандартное.
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
"""
logger.info("Проверка пользователей, не активировавших пробный период...")
inactive_trial_users = await conn.fetch(
"""
SELECT tg_id, username, first_name, last_name FROM users
WHERE tg_id IN (
SELECT tg_id FROM connections
WHERE trial IN (0, -1)
)
AND tg_id NOT IN (
SELECT tg_id FROM blocked_users
)
AND tg_id NOT IN (
SELECT DISTINCT tg_id FROM keys
)
"""
)
logger.info(f"👥 Найдено {len(inactive_trial_users)} неактивных пользователей.")
for user in inactive_trial_users:
tg_id = user["tg_id"]
username = user["username"]
first_name = user["first_name"]
last_name = user["last_name"]
display_name = username or first_name or last_name or "Пользователь"
try:
can_notify = await check_notification_time(tg_id, "inactive_trial", hours=NOTIFY_INACTIVE, session=conn)
if can_notify:
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
)
)
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
trial_extended = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM notifications
WHERE tg_id = $1 AND notification_type = 'inactive_trial'
)
""",
tg_id,
)
if trial_extended:
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
message = TRIAL_INACTIVE_BONUS_MSG.format(
display_name=display_name, NOTIFY_EXTRA_DAYS=NOTIFY_EXTRA_DAYS, total_days=total_days
)
await conn.execute("UPDATE connections SET trial = -1 WHERE tg_id = $1", tg_id)
else:
message = TRIAL_INACTIVE_FIRST_MSG.format(display_name=display_name, TRIAL_TIME=TRIAL_TIME)
try:
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
await add_notification(tg_id, "inactive_trial", session=conn)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
await create_blocked_user(tg_id, conn)
except Exception as e:
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
except Exception as e:
logger.error(f"⚠ Ошибка при обработке пользователя {tg_id}: {e}")
await asyncio.sleep(1)
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
"""
Проверяет трафик пользователей, у которых ещё не отправлялось уведомление о нулевом трафике.
Если трафик 0 ГБ и прошло более 2 часов с момента создания ключа, отправляет уведомление,
но исключает пользователей, у которых подписка недавно продлилась.
"""
logger.info("Проверка пользователей с нулевым трафиком...")
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
for key in keys:
tg_id = key.get("tg_id")
email = key.get("email")
created_at = key.get("created_at")
client_id = key.get("client_id")
expiry_time = key.get("expiry_time")
notified = key.get("notified")
if created_at is None:
logger.warning(f"Для {email} нет значения created_at. Пропускаем.")
continue
if notified is True:
continue
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
created_at_plus_2 = created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
if expiry_time:
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
renewal_threshold = expiry_dt - timedelta(days=30)
renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
if renewal_recent:
continue
if current_dt < created_at_plus_2:
continue
try:
traffic_data = await get_user_traffic(conn, tg_id, email)
except Exception as e:
logger.error(f"Ошибка получения трафика для {email}: {e}")
continue
if traffic_data.get("status") != "success":
logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
continue
total_traffic = sum(
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
)
logger.info(f"Ключ для {email}: общий трафик: {total_traffic} ГБ")
if total_traffic == 0:
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup()
message = ZERO_TRAFFIC_MSG.format(email=email)
try:
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await create_blocked_user(tg_id, conn)
except Exception as e:
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
else:
try:
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
)
logger.info(f"Ключ для {email} имеет трафик. Обновлено notified = TRUE.")
except Exception as e:
logger.error(f"Ошибка обновления notified для пользователя {tg_id}: {e}")
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
+16 -22
View File
@@ -5,12 +5,16 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
CRYPTO_BOT_ENABLE,
DONATIONS_ENABLE,
FREEKASSA_ENABLE,
ROBOKASSA_ENABLE,
STARS_ENABLE,
YOOKASSA_ENABLE,
YOOMONEY_ENABLE,
)
from handlers.buttons import CRYPTOBOT, MAIN_MENU, ROBOKASSA, STARS, YOOKASSA, YOOMONEY
from handlers.texts import PAYMENT_METHODS_MSG
from .utils import edit_or_send_message
router = Router()
@@ -22,56 +26,46 @@ 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 FREEKASSA_ENABLE:
builder.row(
InlineKeyboardButton(
text="🌐 FreeKassa: множество способов",
callback_data="pay_freekassa",
)
)
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=MAIN_MENU, callback_data="profile"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await callback_query.message.answer(
"💸 <b>Выберите удобный способ пополнения баланса:</b>\n\n"
"• Быстро и безопасно\n"
"• Поддержка разных платежных систем\n"
"• Моментальное зачисление средств 🚀",
await edit_or_send_message(
target_message=callback_query.message,
text=PAYMENT_METHODS_MSG,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
-2
View File
@@ -1,10 +1,8 @@
__all__ = ("router",)
from aiogram import Router
from config import (
CRYPTO_BOT_ENABLE,
FREEKASSA_ENABLE,
ROBOKASSA_ENABLE,
STARS_ENABLE,
YOOKASSA_ENABLE,
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.
+106 -64
View File
@@ -1,32 +1,38 @@
import hashlib
from typing import Any
import asyncpg
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiohttp import web
from robokassa import HashAlgorithm, Robokassa
from config import (
DATABASE_URL,
ROBOKASSA_ENABLE,
ROBOKASSA_LOGIN,
ROBOKASSA_PASSWORD1,
ROBOKASSA_PASSWORD2,
ROBOKASSA_TEST_MODE,
)
from robokassa import HashAlgorithm, Robokassa
from database import (
add_connection,
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()
@@ -49,9 +55,7 @@ if ROBOKASSA_ENABLE:
def generate_payment_link(amount, inv_id, description, tg_id):
"""Генерация ссылки на оплату."""
logger.debug(
f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}"
)
logger.debug(f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}")
payment_link = robokassa._payment.link.generate_by_script(
out_sum=amount,
inv_id=inv_id,
@@ -63,9 +67,7 @@ def generate_payment_link(amount, inv_id, description, tg_id):
@router.callback_query(F.data == "pay_robokassa")
async def process_callback_pay_robokassa(
callback_query: types.CallbackQuery, state: FSMContext, session: Any
):
async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} initiated Robokassa payment.")
@@ -89,13 +91,7 @@ async def process_callback_pay_robokassa(
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
)
)
builder.row(
InlineKeyboardButton(
text="💰 Ввести свою сумму",
callback_data="enter_custom_amount_robokassa",
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay"))
key_count = await get_key_count(tg_id)
@@ -105,24 +101,30 @@ async def process_callback_pay_robokassa(
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.")
await callback_query.message.answer(
await callback_query.message.delete()
new_message = await callback_query.message.answer(
text="Выберите сумму пополнения:",
reply_markup=builder.as_markup(),
)
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
await state.set_state(ReplenishBalanceState.choosing_amount_robokassa)
logger.info(f"Displayed amount selection for user {tg_id}.")
@router.callback_query(F.data.startswith("robokassa_amount|"))
async def process_amount_selection(
callback_query: types.CallbackQuery, state: FSMContext
):
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
logger.info(f"Получены данные callback_data: {callback_query.data}")
data = callback_query.data.split("|")
if len(data) != 3 or data[1] != "amount":
logger.error("Ошибка: callback_data не соответствует формату.")
await callback_query.message.answer("Ошибка: данные повреждены.")
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: данные повреждены.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
amount_str = data[2]
@@ -132,7 +134,12 @@ async def process_amount_selection(
raise ValueError("Сумма должна быть положительным числом.")
except ValueError as e:
logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}")
await callback_query.message.answer("Некорректная сумма.")
await edit_or_send_message(
target_message=callback_query.message,
text="Некорректная сумма.",
reply_markup=types.InlineKeyboardMarkup(),
force_text=True,
)
return
await state.update_data(amount=amount)
@@ -146,14 +153,16 @@ async def process_amount_selection(
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 callback_query.message.answer(
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
await edit_or_send_message(
target_message=callback_query.message,
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
reply_markup=confirm_keyboard,
force_text=True,
)
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
@@ -170,9 +179,7 @@ async def robokassa_webhook(request):
shp_id = params.get("shp_id")
signature_value = params.get("SignatureValue")
logger.info(
f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}"
)
logger.info(f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}")
if not check_payment_signature(params):
logger.error("Неверная подпись или данные запроса.")
@@ -211,9 +218,7 @@ def check_payment_signature(params):
logger.info(f"Signature string before hashing: {signature_string}")
expected_signature = (
hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
)
expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
logger.info(f"Expected signature: {expected_signature}")
logger.info(f"Received signature: {signature_value}")
@@ -222,56 +227,93 @@ def check_payment_signature(params):
@router.callback_query(F.data == "enter_custom_amount_robokassa")
async def process_custom_amount_selection(
callback_query: types.CallbackQuery, state: FSMContext
):
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
tg_id = callback_query.message.chat.id
logger.info(f"User {tg_id} chose to enter a custom amount.")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="pay_robokassa"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
await callback_query.message.answer(
"Пожалуйста, введите сумму пополнения.",
await edit_or_send_message(
target_message=callback_query.message,
text=ENTER_SUM,
reply_markup=builder.as_markup(),
force_text=True,
)
await state.set_state(
ReplenishBalanceState.waiting_for_payment_confirmation_robokassa
)
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
async def handle_custom_amount_input(message: types.Message, state: FSMContext):
tg_id = message.chat.id
logger.info(f"User {tg_id} entered custom amount: {message.text}")
async def handle_custom_amount_input(
message: types.Message | types.CallbackQuery, state: FSMContext = None, session: Any = None
):
if isinstance(message, types.CallbackQuery):
tg_id = message.message.chat.id
target_message = message.message
else:
tg_id = message.chat.id
target_message = message
logger.info(f"User {tg_id} initiated payment through ROBOKASSA")
inv_id = 0
try:
amount = int(message.text)
if amount <= 0:
raise ValueError("Сумма должна быть положительным числом.")
conn = await asyncpg.connect(DATABASE_URL)
user_data = await get_temporary_data(conn, tg_id)
await conn.close()
await state.update_data(amount=amount)
if not user_data:
await edit_or_send_message(
target_message=target_message,
text="Данные для оплаты не найдены. Попробуйте снова.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
state_type = user_data["state"]
amount = user_data["data"].get("required_amount", 0)
if amount <= 0:
await edit_or_send_message(
target_message=target_message,
text="Недостаточная сумма для пополнения.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
payment_url = generate_payment_link(amount, inv_id, "Пополнение баланса", tg_id)
logger.info(f"Generated payment link for user {tg_id}: {payment_url}")
confirm_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="Оплатить", url=payment_url)],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")],
]
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
await message.answer(
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
reply_markup=confirm_keyboard,
)
await state.clear()
except ValueError as e:
logger.error(f"Некорректная сумма от пользователя {tg_id}: {e}")
await message.answer(
text="Введите корректную сумму в рублях (целое положительное число)."
if state_type == "waiting_for_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
)
elif state_type == "waiting_for_renewal_payment":
message_text = (
f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
)
else:
await edit_or_send_message(
target_message=target_message,
text="Некорректное состояние данных. Попробуйте снова.",
reply_markup=types.InlineKeyboardMarkup(),
)
return
await edit_or_send_message(target_message=target_message, text=message_text, reply_markup=builder.as_markup())
if isinstance(state, FSMContext):
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
await edit_or_send_message(
target_message=target_message,
text="Произошла ошибка при создании платежа. Попробуйте позже.",
reply_markup=types.InlineKeyboardMarkup(),
)
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.
+352 -109
View File
@@ -1,14 +1,65 @@
import html
from io import BytesIO
import os
from aiogram import F, Router, types
import asyncpg
import qrcode
from typing import Any
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.types import BufferedInputFile, InlineKeyboardButton
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import NEWS_MESSAGE, RENEWAL_PLANS
from database import get_balance, get_key_count, get_referral_stats
from handlers.buttons.profile import ADD_SUB, GIFTS, INSTRUCTIONS, INVITE, MAIN_MENU, MY_SUBS, PAYMENT, TARRIFS
from handlers.texts import get_referral_link, invite_message_send, profile_message_send
from config import (
ADMIN_ID,
DATABASE_URL,
GIFT_BUTTON,
INLINE_MODE,
INSTRUCTIONS_BUTTON,
NEWS_MESSAGE,
REFERRAL_BUTTON,
REFERRAL_OFFERS,
RENEWAL_PLANS,
SHOW_START_MENU_ONCE,
TOP_REFERRAL_BUTTON,
TRIAL_TIME,
USERNAME_BOT,
)
from database import get_balance, get_key_count, get_last_payments, get_referral_stats, get_trial
from handlers.buttons import (
ABOUT_VPN,
ADD_SUB,
BACK,
BALANCE,
BALANCE_HISTORY,
COUPON,
GIFTS,
INSTRUCTIONS,
INVITE,
MAIN_MENU,
MY_SUBS,
PAYMENT,
QR,
TOP_FIVE,
TRIAL_SUB
)
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, INVITE_TEXT_NON_INLINE, TOP_REFERRALS_TEXT
from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .texts import get_referral_link, invite_message_send, profile_message_send
from .utils import edit_or_send_message
router = Router()
@@ -16,136 +67,328 @@ router = Router()
@router.callback_query(F.data == "profile")
@router.message(F.text == "/profile")
async def process_callback_view_profile(
callback_query_or_message: types.Message | types.CallbackQuery, state: FSMContext, admin: bool
callback_query_or_message: Message | CallbackQuery,
state: FSMContext,
admin: bool,
):
if isinstance(callback_query_or_message, types.CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
username = callback_query_or_message.from_user.full_name
is_callback = True
elif isinstance(callback_query_or_message, types.Message):
chat_id = callback_query_or_message.chat.id
username = callback_query_or_message.from_user.full_name
is_callback = False
if isinstance(callback_query_or_message, CallbackQuery):
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 = 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}")
image_path = os.path.join("img", "pic.jpg")
key_count = await get_key_count(chat_id)
balance = await get_balance(chat_id)
if balance is None:
balance = 0
balance = await get_balance(chat_id) or 0
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
conn = await asyncpg.connect(DATABASE_URL)
try:
trial_status = await get_trial(chat_id, conn)
if key_count == 0:
profile_message += "\n<pre>🔧 <i>Нажмите кнопку ➕ Устройство, чтобы настроить VPN-подключение</i></pre>"
else:
profile_message += f"\n<pre> <i>{NEWS_MESSAGE}</i></pre>"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"),
InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"),
)
builder.row(
InlineKeyboardButton(
text=PAYMENT,
callback_data="pay",
)
)
builder.row()
builder.row(
InlineKeyboardButton(text=INVITE, callback_data="invite"),
InlineKeyboardButton(text=GIFTS, callback_data="gifts"),
)
builder.row(
InlineKeyboardButton(text=TARRIFS, callback_data="view_tariffs"),
InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"),
)
if admin:
builder.row(
InlineKeyboardButton(text="🔧 Администратор", callback_data="admin")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="start"))
if os.path.isfile(image_path):
with open(image_path, "rb") as image_file:
if is_callback:
await callback_query_or_message.message.answer_photo(
photo=BufferedInputFile(image_file.read(), filename="pic.jpg"),
caption=profile_message,
reply_markup=builder.as_markup(),
)
else:
await callback_query_or_message.answer_photo(
photo=BufferedInputFile(image_file.read(), filename="pic.jpg"),
caption=profile_message,
reply_markup=builder.as_markup(),
)
else:
if is_callback:
await callback_query_or_message.message.answer(
text=profile_message,
reply_markup=builder.as_markup(),
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
if key_count == 0:
profile_message += (
"\n<blockquote>🔧 <i>Нажмите кнопку ➕ Подписка, чтобы настроить VPN-подключение</i></blockquote>"
)
else:
await callback_query_or_message.answer(
text=profile_message,
reply_markup=builder.as_markup(),
profile_message += f"\n<blockquote> <i>{NEWS_MESSAGE}</i></blockquote>"
builder = InlineKeyboardBuilder()
if key_count > 0:
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
elif trial_status == 0:
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
else:
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
row_buttons = []
if REFERRAL_BUTTON:
row_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite"))
if GIFT_BUTTON:
row_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts"))
if row_buttons:
builder.row(*row_buttons)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"))
if admin:
builder.row(
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
if SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
else:
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
await edit_or_send_message(
target_message=target_message,
text=profile_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
force_text=True,
)
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)
@router.callback_query(F.data == "view_tariffs")
async def view_tariffs_handler(callback_query: types.CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
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"))
image_path = os.path.join("img", "tariffs.jpg")
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
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=text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
if os.path.isfile(image_path):
with open(image_path, "rb") as image_file:
await callback_query.message.answer_photo(
photo=BufferedInputFile(image_file.read(), filename="tariffs.jpg"),
caption=tariffs_message,
reply_markup=builder.as_markup(),
@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:
await callback_query.message.answer(
text=tariffs_message,
reply_markup=builder.as_markup(),
)
history_text = "❌ У вас пока нет операций с балансом."
await edit_or_send_message(
target_message=callback_query.message,
text=history_text,
reply_markup=builder.as_markup(),
media_path=None,
disable_web_page_preview=False,
)
@router.message(F.text == "/tariffs")
@router.callback_query(F.data == "view_tariffs")
async def view_tariffs_handler(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
image_path = os.path.join("img", "tariffs.jpg")
tariffs_message = "<b>🚀 Доступные тарифы VPN:</b>\n\n" + "\n".join([
f"{months} {'месяц' if months == '1' else 'месяца' if int(months) in [2, 3, 4] else 'месяцев'}: "
f"{RENEWAL_PLANS[months]['price']} "
f"{'💳' if months == '1' else '🌟' if months == '3' else '🔥' if months == '6' else '🚀'} рублей"
for months in sorted(RENEWAL_PLANS.keys(), key=int)
])
await edit_or_send_message(
target_message=callback_query.message,
text=tariffs_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.callback_query(F.data == "invite")
async def invite_handler(callback_query: types.CallbackQuery):
chat_id = callback_query.message.chat.id
@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()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
if os.path.isfile(image_path):
with open(image_path, "rb") as image_file:
await callback_query.message.answer_photo(
photo=BufferedInputFile(image_file.read(), filename="pic_invite.jpg"),
caption=invite_message,
if INLINE_MODE:
builder.button(text=INVITE, switch_inline_query="invite")
else:
invite_text = INVITE_TEXT_NON_INLINE.format(referral_link=referral_link)
builder.button(text=INVITE, switch_inline_query=invite_text)
builder.button(text=QR, callback_data=f"show_referral_qr|{chat_id}")
if TOP_REFERRAL_BUTTON:
builder.button(text=TOP_FIVE, callback_data="top_referrals")
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1)
await edit_or_send_message(
target_message=target_message,
text=invite_message,
reply_markup=builder.as_markup(),
media_path=image_path,
disable_web_page_preview=False,
)
@router.inline_query(F.query.in_(["referral", "ref", "invite"]))
async def inline_referral_handler(inline_query: InlineQuery):
referral_link = f"https://t.me/{USERNAME_BOT}?start=referral_{inline_query.from_user.id}"
results: list[InlineQueryResultArticle] = []
for index, offer in enumerate(REFERRAL_OFFERS):
description = offer["description"][:64]
message_text = offer["message"].format(trial_time=TRIAL_TIME)[:4096]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=offer["title"], url=referral_link))
results.append(
InlineQueryResultArticle(
id=str(index),
title=offer["title"],
description=description,
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
else:
await callback_query.message.answer(
text=invite_message,
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()
+238 -166
View File
@@ -1,252 +1,324 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import (
BufferedInputFile,
CallbackQuery,
InlineKeyboardButton,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import (
CAPTCHA_ENABLE,
CHANNEL_EXISTS,
CHANNEL_ID,
CHANNEL_REQUIRED,
CHANNEL_URL,
CONNECT_ANDROID,
CONNECT_IOS,
DONATIONS_ENABLE,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
SHOW_START_MENU_ONCE,
SUPPORT_CHAT_URL,
)
from database import add_connection, add_referral, check_connection_exists, get_trial, use_trial
from database import (
add_connection,
add_referral,
check_connection_exists,
get_referral_by_referred_id,
get_trial,
update_balance,
)
from handlers.admin.coupons.coupons_handler import handle_coupon_activation
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT
from handlers.captcha import generate_captcha
from handlers.keys.key_management import create_key
from handlers.keys.trial_key import create_trial_key
from handlers.texts import INSTRUCTIONS_TRIAL, WELCOME_TEXT, get_about_vpn
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPON_SUCCESS_MSG,
GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG,
NEW_REFERRAL_NOTIFICATION,
NOT_SUBSCRIBED_YET_MSG,
REFERRAL_SUCCESS_MSG,
SUBSCRIPTION_CHECK_ERROR_MSG,
SUBSCRIPTION_CONFIRMED_MSG,
SUBSCRIPTION_REQUIRED_MSG,
WELCOME_TEXT,
get_about_vpn,
)
from logger import logger
from .admin.panel.keyboard import AdminPanelCallback
from .utils import edit_or_send_message
router = Router()
@router.callback_query(F.data == "start")
async def handle_start_callback_query(
callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool
callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool, captcha: bool = False
):
await start_command(callback_query.message, state, session, admin)
await start_command(callback_query.message, state, session, admin, captcha)
@router.message(Command("start"))
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool):
"""Обрабатывает команду /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}")
await state.clear()
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
if message.text:
state_data = await state.get_data()
text_to_process = state_data.get("original_text", message.text)
if CHANNEL_EXISTS and CHANNEL_REQUIRED:
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 "gift_" in message.text:
logger.info(f"Обнаружена ссылка на подарок: {message.text}")
parts = message.text.split("gift_")[1].split("_")
gift_id = parts[0]
recipient_tg_id = message.chat.id
gift_info = await session.fetchrow(
"SELECT * FROM gifts WHERE gift_id = $1 AND is_used = FALSE", gift_id
member = await bot.get_chat_member(CHANNEL_ID, message.chat.id)
if member.status not in ["member", "administrator", "creator"]:
await state.update_data(original_text=text_to_process)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
else:
logger.info(
f"Пользователь {message.chat.id} подписан на канал (статус: {member.status}). Продолжаем работу."
)
except Exception as e:
logger.error(f"Ошибка проверки подписки пользователя {message.chat.id}: {e}")
await state.update_data(start_text=text_to_process)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
await process_start_logic(message, state, session, admin, text_to_process)
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['sender_tg_id'] == recipient_tg_id:
logger.warning(
f"Пользователь {recipient_tg_id} попытался активировать подарок, который был отправлен им самим."
async def process_start_logic(
message: Message, state: FSMContext, session: Any, admin: bool, text_to_process: str = None
):
text = text_to_process if text_to_process is not None else message.text
if text:
try:
if "coupons_" in text:
logger.info(f"Обнаружена ссылка на купон: {text}")
user_id = message.chat.id
await handle_coupon_activation(message, state, session, admin, text=text, user_id=user_id)
return
if "gift_" in text:
parts = text.split("gift_")[1].split("_")
if len(parts) < 2:
await message.answer("❌ Неверный формат ссылки на подарок.")
return await process_callback_view_profile(message, state, admin)
gift_id = parts[0]
async with session.transaction():
gift_info = await session.fetchrow(
"""
SELECT sender_tg_id, selected_months, expiry_time, is_used, recipient_tg_id
FROM gifts
WHERE gift_id = $1
FOR UPDATE
""",
gift_id,
)
if not gift_info:
await message.answer(GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG)
return await process_callback_view_profile(message, state, admin)
if gift_info["is_used"]:
await message.answer("Этот подарок уже был использован.")
return await process_callback_view_profile(message, state, admin)
if gift_info["sender_tg_id"] == message.chat.id:
await message.answer("❌ Вы не можете получить подарок от самого себя.")
return await show_start_menu(message, admin, session)
return await process_callback_view_profile(message, state, admin)
selected_months = gift_info['selected_months']
expiry_time = gift_info['expiry_time']
expiry_time_naive = expiry_time.replace(tzinfo=None)
logger.info(f"Подарок с ID {gift_id} успешно найден для пользователя {recipient_tg_id}.")
if gift_info["recipient_tg_id"]:
await message.answer("❌ Этот подарок уже был активирован другим пользователем.")
return await process_callback_view_profile(message, state, admin)
await create_key(recipient_tg_id, expiry_time_naive, state, session, message)
logger.info(f"Ключ создан для пользователя {recipient_tg_id} на срок {selected_months} месяцев.")
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
if not existing_referral:
await add_referral(message.chat.id, gift_info["sender_tg_id"], session)
connection_exists = await check_connection_exists(message.chat.id)
if not connection_exists:
await add_connection(tg_id=message.chat.id, session=session)
await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", message.chat.id)
await create_key(
message.chat.id,
gift_info["expiry_time"].replace(tzinfo=None),
state,
session,
message,
)
await session.execute(
"UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1 WHERE gift_id = $2",
recipient_tg_id,
message.chat.id,
gift_id,
)
await message.answer(
f"🎉 Ваш подарок на {selected_months} {'месяц' if selected_months == 1 else 'месяца' if selected_months in [2, 3, 4] else 'месяцев'} активирован!"
f"🎉 Ваш подарок на {gift_info['selected_months']} "
f"{'месяц' if gift_info['selected_months'] == 1 else 'месяца' if gift_info['selected_months'] in [2, 3, 4] else 'месяцев'} активирован!"
)
logger.info(f"Подарок на {selected_months} месяцев активирован для пользователя {recipient_tg_id}.")
return
elif "referral_" in message.text:
if "referral_" in text:
try:
referrer_tg_id = int(message.text.split("referral_")[1])
if connection_exists:
logger.info(
f"Пользователь {message.chat.id} уже зарегистрирован и не может стать рефералом."
)
referrer_tg_id = int(text.split("referral_")[1])
connection_exists_now = await check_connection_exists(message.chat.id)
if connection_exists_now:
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
return await show_start_menu(message, admin, session)
return await process_callback_view_profile(message, state, admin)
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 session.fetchrow(
"SELECT * FROM referrals WHERE referred_tg_id = $1", message.chat.id
)
return await process_callback_view_profile(message, state, admin)
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)
return await process_callback_view_profile(message, state, admin)
await add_referral(message.chat.id, referrer_tg_id, session)
logger.info(f"Реферал {message.chat.id} использовал ссылку от пользователя {referrer_tg_id}")
return await show_start_menu(message, admin, session)
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
try:
await bot.send_message(
referrer_tg_id,
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id),
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
return await process_callback_view_profile(message, state, admin)
except (ValueError, IndexError):
pass
except (ValueError, IndexError) as e:
logger.error(f"Ошибка при обработке реферальной ссылки: {e}")
return
logger.info("Пользователь зашел без реферальной ссылки, подарка или купона.")
else:
logger.info(f"Пользователь {message.chat.id} зашел без реферальной ссылки или подарка.")
except Exception as e:
logger.error(f"Ошибка при обработке текста {message.text} {e}", exc_info=True)
await message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
await show_start_menu(message, admin, session)
except (ValueError, IndexError) as e:
logger.error(f"Ошибка при обработке сообщения пользователя {message.chat.id}: {e}")
await message.answer("❌ Произошла ошибка. Пожалуйста, попробуйте снова.")
final_exists = await check_connection_exists(message.chat.id)
if final_exists:
if SHOW_START_MENU_ONCE:
return await process_callback_view_profile(message, state, admin)
else:
return await show_start_menu(message, admin, session)
else:
await show_start_menu(message, admin, session)
await add_connection(tg_id=message.chat.id, session=session)
return await show_start_menu(message, admin, session)
@router.callback_query(F.data == "check_subscription")
async def check_subscription_callback(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool):
user_id = callback_query.from_user.id
logger.info(f"[CALLBACK] Получен callback 'check_subscription' от пользователя {user_id}")
try:
member = await bot.get_chat_member(CHANNEL_ID, user_id)
logger.info(f"[CALLBACK] Статус подписки пользователя {user_id}: {member.status}")
if member.status not in ["member", "administrator", "creator"]:
await callback_query.answer(NOT_SUBSCRIBED_YET_MSG, show_alert=True)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await callback_query.message.edit_text(
SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
else:
await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG)
data = await state.get_data()
original_text = data.get("original_text")
if not original_text:
original_text = callback_query.message.text
await process_start_logic(callback_query.message, state, session, admin, text_to_process=original_text)
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
except Exception as e:
logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True)
await callback_query.answer(SUBSCRIPTION_CHECK_ERROR_MSG, show_alert=True)
async def show_start_menu(message: Message, admin: bool, session: Any):
"""Функция для отображения стандартного меню"""
"""Функция для отображения стандартного меню через редактирование сообщения.
Если редактирование не удалось, отправляем новое сообщение."""
logger.info(f"Показываю главное меню для пользователя {message.chat.id}")
trial_status = await get_trial(message.chat.id, session)
image_path = os.path.join("img", "pic.jpg")
image_path = os.path.join("img", "pic.jpg")
builder = InlineKeyboardBuilder()
if trial_status == 0:
builder.row(InlineKeyboardButton(text="🔗 Подключить VPN", callback_data="connect_vpn"))
if session is not None:
trial_status = await get_trial(message.chat.id, session)
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
if trial_status == 0:
builder.row(InlineKeyboardButton(text="🎁 Пробная подписка", callback_data="create_key"))
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"))
builder.row(
InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL),
InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL),
)
if CHANNEL_EXISTS:
builder.row(
InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL),
InlineKeyboardButton(text=CHANNEL, url=CHANNEL_URL),
)
else:
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if admin:
builder.row(InlineKeyboardButton(text="🔧 Администратор", callback_data="admin"))
builder.row(InlineKeyboardButton(text="🌐 О нашем VPN", callback_data="about_vpn"))
if os.path.isfile(image_path):
with open(image_path, "rb") as image_from_buffer:
await message.answer_photo(
photo=BufferedInputFile(image_from_buffer.read(), filename="pic.jpg"),
caption=WELCOME_TEXT,
reply_markup=builder.as_markup(),
)
else:
await message.answer(
text=WELCOME_TEXT,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "connect_vpn")
async def handle_connect_vpn(callback_query: CallbackQuery, session: Any):
user_id = callback_query.message.chat.id
trial_key_info = await create_trial_key(user_id, session)
if "error" in trial_key_info:
await callback_query.message.answer(trial_key_info["error"])
else:
await use_trial(user_id, session)
key_message = (
f"🔑 <b>Ваш персональный ключ доступа:</b>\n"
f"<code>{trial_key_info['key']}</code>\n\n"
f"📋 <b>Быстрая инструкция по подключению:</b>\n{INSTRUCTIONS_TRIAL}"
)
email = trial_key_info["email"]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
builder.row(
InlineKeyboardButton(text="🍏 Скачать для iOS", url=DOWNLOAD_IOS),
InlineKeyboardButton(text="🤖 Скачать для Android", url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(
text="🍏 Подключить на iOS",
url=f'{CONNECT_IOS}{trial_key_info["key"]}',
),
InlineKeyboardButton(
text="🤖 Подключить на Android",
url=f'{CONNECT_ANDROID}{trial_key_info["key"]}',
),
)
builder.row(
InlineKeyboardButton(
text="💻 Компьютеры", callback_data=f"connect_pc|{email}"
),
InlineKeyboardButton(
text="📺 Андроид TV", callback_data=f"connect_tv|{email}"
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
InlineKeyboardButton(text="🔧 Администратор", callback_data=AdminPanelCallback(action="admin").pack())
)
await callback_query.message.answer(
key_message, reply_markup=builder.as_markup()
)
builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn"))
await edit_or_send_message(
target_message=message,
text=WELCOME_TEXT,
reply_markup=builder.as_markup(),
media_path=image_path,
)
@router.callback_query(F.data == "about_vpn")
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)
await callback_query.message.answer(
get_about_vpn("3.2.3-minor"), reply_markup=builder.as_markup()
builder.row(InlineKeyboardButton(text=BACK, callback_data="start"))
text = get_about_vpn("3.2.3-minor")
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
media_path=None,
force_text=False,
)
+180 -23
View File
@@ -1,14 +1,36 @@
import random
import json
import os
import re
import secrets
import string
import aiofiles
import aiohttp
import asyncpg
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
from bot import bot
from config import DATABASE_URL
from database import get_servers_from_db
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:
"""
Очищает название ключа, оставляя только допустимые символы.
@@ -32,7 +54,7 @@ def generate_random_email(length: int = 6) -> str:
Returns:
str: Сгенерированная случайная строка.
"""
return "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=length))
return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else ""
async def get_least_loaded_cluster() -> str:
@@ -42,34 +64,36 @@ async def get_least_loaded_cluster() -> str:
Returns:
str: Идентификатор наименее загруженного кластера.
"""
servers = await get_servers_from_db()
cluster_loads: dict[str, int] = {cluster_id: 0 for cluster_id in servers.keys()}
servers = await get_servers()
server_to_cluster = {}
cluster_loads = dict.fromkeys(servers.keys(), 0)
for cluster_name, cluster_servers in servers.items():
for server in cluster_servers:
server_to_cluster[server["server_name"]] = cluster_name
logger.info(f"Сопоставление серверов и кластеров: {server_to_cluster}")
async with asyncpg.create_pool(DATABASE_URL) as pool:
async with pool.acquire() as conn:
keys = await conn.fetch("SELECT server_id FROM keys")
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}")
else:
logger.warning(f"⚠️ Сервер {server_id} не найден в известных кластерах!")
logger.info(f"Загруженность кластеров после запроса к БД: {cluster_loads}")
if not cluster_loads:
logger.warning("No clusters found in database or configuration.")
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}")
logger.info(f"✅ Выбран наименее загруженный кластер: {least_loaded_cluster}")
return least_loaded_cluster
async def handle_error(
tg_id: int, callback_query: object | None = None, message: str = ""
) -> None:
async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None:
"""
Обрабатывает ошибку, отправляя сообщение пользователю.
@@ -81,13 +105,146 @@ async def handle_error(
try:
if callback_query and hasattr(callback_query, "message"):
try:
await bot.delete_message(
chat_id=tg_id, message_id=callback_query.message.message_id
)
await bot.delete_message(chat_id=tg_id, message_id=callback_query.message.message_id)
except Exception as delete_error:
logger.warning(f"Не удалось удалить сообщение: {delete_error}")
await bot.send_message(tg_id, message, parse_mode="HTML")
await bot.send_message(tg_id, message)
except Exception as e:
logger.error(f"Ошибка при обработке ошибки: {e}")
def format_time_until_deletion(seconds: int) -> str:
if seconds <= 0:
return "0 минут"
days = seconds // (3600 * 24)
hours = (seconds % (3600 * 24)) // 3600
minutes = (seconds % 3600 + 59) // 60
parts = []
if days > 0:
if days == 1:
parts.append(f"{days} день")
elif 2 <= days <= 4:
parts.append(f"{days} дня")
else:
parts.append(f"{days} дней")
if hours > 0:
if hours == 1:
parts.append(f"{hours} час")
elif 2 <= hours <= 4:
parts.append(f"{hours} часа")
else:
parts.append(f"{hours} часов")
if minutes > 0 and days == 0:
if minutes == 1:
parts.append("1 минута")
elif 2 <= minutes <= 4:
parts.append(f"{minutes} минуты")
else:
parts.append(f"{minutes} минут")
return " и ".join(parts) if parts else "менее минуты"
def get_plural_form(num: int, form1: str, form2: str, form3: str) -> str:
n = abs(num) % 100
if 10 < n < 20:
return form3
return {1: form1, 2: form2, 3: form2, 4: form2}.get(n % 10, form3)
def format_days(days: int) -> str:
"""
Форматирует количество дней с правильным склонением.
Args:
days (int): Количество дней.
Returns:
str: Строка с числом и склонённым словом "день/дня/дней".
"""
if days <= 0:
return "0 дней"
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
async def edit_or_send_message(
target_message: Message,
text: str,
reply_markup: InlineKeyboardMarkup,
media_path: str = None,
disable_web_page_preview: bool = False,
force_text: bool = False,
):
"""
Универсальная функция для редактирования исходного сообщения 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:
image_data = await f.read()
media = InputMediaPhoto(
media=BufferedInputFile(image_data, filename=os.path.basename(media_path)),
caption=text,
)
try:
await target_message.edit_media(media=media, reply_markup=reply_markup)
return
except Exception:
await target_message.answer_photo(
photo=BufferedInputFile(image_data, filename=os.path.basename(media_path)),
caption=text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
return
else:
if not force_text and target_message.caption is not None:
try:
await target_message.edit_caption(caption=text, reply_markup=reply_markup)
return
except Exception as e:
logger.error(f"Ошибка редактирования подписи: {e}")
try:
await target_message.edit_text(
text=text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
return
except Exception:
await target_message.answer(
text=text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
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
GB = MB * 1024
TB = GB * 1024
units = {"KB": KB, "MB": MB, "GB": GB, "TB": TB}
return int(value * units.get(unit.upper(), 1))
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+2
View File
@@ -1,10 +1,12 @@
import logging
import os
import sys
from datetime import timedelta
from loguru import logger
log_folder = "logs"
if not os.path.exists(log_folder):
+1 -1
View File
File diff suppressed because one or more lines are too long
+57
View File
@@ -0,0 +1,57 @@
from collections.abc import Iterable
from aiogram import Dispatcher
from aiogram.dispatcher.middlewares.base import BaseMiddleware
from .admin import AdminMiddleware
from .loggings import LoggingMiddleware
from .session import SessionMiddleware
from .throttling import ThrottlingMiddleware
from .user import UserMiddleware
def register_middleware(
dispatcher: Dispatcher,
middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None,
exclude: Iterable[str] | None = None,
) -> None:
"""Регистрирует middleware в диспетчере.
Args:
dispatcher: Экземпляр диспетчера Aiogram
middlewares: Опциональный список middleware для регистрации.
Если не указан, регистрируются все стандартные middleware.
exclude: Опциональный список имен middleware, которые нужно исключить из регистрации.
Применяется только если middlewares не указан.
"""
# Если middleware не указаны, используем стандартный набор
if middlewares is None:
# Словарь всех доступных middleware
available_middlewares = {
"admin": AdminMiddleware(),
"session": SessionMiddleware(),
"logging": LoggingMiddleware(),
"throttling": ThrottlingMiddleware(),
"user": UserMiddleware(),
}
# Фильтруем middleware по списку исключений
exclude_set = set(exclude or [])
middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set]
# Регистрируем middleware для всех типов обработчиков
handlers = [
dispatcher.message,
dispatcher.callback_query,
dispatcher.inline_query,
# Можно добавить другие типы обработчиков при необходимости
]
# Регистрируем каждый middleware для каждого типа обработчика
for middleware in middlewares:
# Если передан класс, а не экземпляр, создаем экземпляр
if isinstance(middleware, type):
middleware = middleware()
for handler in handlers:
handler.outer_middleware(middleware)
+34 -5
View File
@@ -2,26 +2,55 @@ from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from aiogram.types import CallbackQuery, Message, TelegramObject
from config import ADMIN_ID
class AdminMiddleware(BaseMiddleware):
"""Middleware для проверки прав администратора.
Добавляет в data['admin'] = True/False в зависимости от того,
является ли пользователь администратором.
"""
_admin_ids: set[int] = set(ADMIN_ID) if isinstance(ADMIN_ID, list | tuple) else {ADMIN_ID}
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
"""Обрабатывает событие и добавляет флаг администратора в data.
Args:
handler: Обработчик события
event: Событие Telegram
data: Словарь с данными события
Returns:
Результат выполнения обработчика
"""
data["admin"] = self._check_admin_access(event)
return await handler(event, data)
def _check_admin_access(self, event: TelegramObject) -> bool:
"""Проверяет, имеет ли пользователь права администратора.
Args:
event: Событие Telegram
Returns:
True, если пользователь администратор, иначе False
"""
try:
admin_ids: int | list[int] = ADMIN_ID
if isinstance(admin_ids, list):
return event.from_user.id in admin_ids
return event.from_user.id == admin_ids
if isinstance(event, Message):
return event.from_user and event.from_user.id in self._admin_ids
elif isinstance(event, CallbackQuery):
return event.from_user and event.from_user.id in self._admin_ids
user_id = getattr(getattr(event, "from_user", None), "id", None)
return user_id in self._admin_ids if user_id else False
except Exception:
return False

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