Compare commits

...

2503 Commits

Author SHA1 Message Date
Egor c24c668299 Update Dockerfile 2025-10-07 06:47:33 +03:00
Egor 41df40eed3 Update docker-registry.yml 2025-10-07 06:47:26 +03:00
Egor b1975cc3ff Update docker-hub.yml 2025-10-07 06:47:14 +03:00
Egor 369e2c3c36 Merge pull request #853 from Fr1ngg/dev2
Дополнительные методы API для работы со страницами из ИНФО + вынесение хостов в конфигурации для платежек
2025-10-07 06:35:19 +03:00
Egor e2ec4b05db Merge pull request #852 from Fr1ngg/r6u84x-bedolaga/extract-host-to-bot-configuration
Allow configuring webhook hosts
2025-10-07 06:32:21 +03:00
Egor 7493b13348 Allow configuring YooKassa webhook host 2025-10-07 06:32:03 +03:00
Egor 0a65aa16cd Merge pull request #851 from Fr1ngg/461rmg-bedolaga/add-api-endpoints-for-offer-faq-terms
Allow FAQ creation API to pass display order and status
2025-10-07 06:29:22 +03:00
Egor 64a4ece0fe Allow FAQ creation API to pass display order and status 2025-10-07 06:28:26 +03:00
Egor 71664cca3e Merge pull request #850 from Fr1ngg/revert-847-avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Revert "Add informational pages endpoints to admin web API"
2025-10-07 06:28:09 +03:00
Egor b21177f685 Revert "Add informational pages endpoints to admin web API" 2025-10-07 06:28:00 +03:00
Egor cde6aa4c26 Merge pull request #849 from Fr1ngg/revert-848-5sy6f4-bedolaga/extract-host-to-bot-configuration
Revert "Allow configuring Tribute webhook host"
2025-10-07 06:26:51 +03:00
Egor 5264fc0d90 Revert "Allow configuring Tribute webhook host" 2025-10-07 06:26:37 +03:00
Egor 7aa2004f27 Merge pull request #848 from Fr1ngg/5sy6f4-bedolaga/extract-host-to-bot-configuration
Allow configuring Tribute webhook host
2025-10-07 06:23:26 +03:00
Egor 40a34d1e55 Allow configuring Tribute webhook host 2025-10-07 06:22:41 +03:00
Egor 4204b459e2 Merge pull request #847 from Fr1ngg/avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Add informational pages endpoints to admin web API
2025-10-07 06:21:03 +03:00
Egor cbe80e9447 Add pages management endpoints to web API 2025-10-07 06:20:44 +03:00
Egor 0cfd8d3c7b Merge pull request #846 from Fr1ngg/dev2
FAQ, Политика Конф, Оферта
2025-10-07 06:08:48 +03:00
Egor ddfa61c56f Merge pull request #845 from Fr1ngg/revert-842-revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Revert "Add FAQ management and user menu support""
2025-10-07 06:03:01 +03:00
Egor 7e340bc13a Revert "Revert "Add FAQ management and user menu support"" 2025-10-07 06:02:50 +03:00
Egor f7dd3be27c Merge pull request #844 from Fr1ngg/rcd0rc-bedolaga/add-offer-button-to-bot-info-section
Fix admin offer preview HTML truncation
2025-10-07 06:02:38 +03:00
Egor 3ff06ed9d1 Use paginator for admin offer preview 2025-10-07 06:02:19 +03:00
Egor 08d6a26b23 Merge pull request #843 from Fr1ngg/revert-841-ptoawe-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 06:01:38 +03:00
Egor 37e472d78a Revert "Add configurable public offer info section" 2025-10-07 06:01:25 +03:00
Egor 8cc04e01e9 Merge pull request #842 from Fr1ngg/revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Add FAQ management and user menu support"
2025-10-07 06:01:18 +03:00
Egor c199d04b91 Revert "Add FAQ management and user menu support" 2025-10-07 06:01:09 +03:00
Egor 102d5d0a9c Merge pull request #839 from Fr1ngg/g9mnci-bedolaga/add-faq-button-to-bot-info-section
Add FAQ management and user menu support
2025-10-07 05:52:53 +03:00
Egor 8e7b44811e Merge pull request #841 from Fr1ngg/ptoawe-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:52:37 +03:00
Egor ab1be9943b Fix HTML paginator preserving entities 2025-10-07 05:52:19 +03:00
Egor 61b702272b Merge pull request #840 from Fr1ngg/revert-838-5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:51:59 +03:00
Egor 84bce0043d Revert "Add configurable public offer info section" 2025-10-07 05:51:51 +03:00
Egor f54528f2d5 Add FAQ management and user menu support 2025-10-07 05:47:41 +03:00
Egor b66911f4f7 Merge pull request #838 from Fr1ngg/5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:45:04 +03:00
Egor 352c14331d Handle HTML tags when paginating public offer text 2025-10-07 05:44:16 +03:00
Egor 7ea91f344c Merge pull request #837 from Fr1ngg/revert-836-t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:40:05 +03:00
Egor d2d5b6c823 Revert "Add configurable public offer info section" 2025-10-07 05:39:54 +03:00
Egor 4405c2287a Merge pull request #836 from Fr1ngg/t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:27:38 +03:00
Egor 288a72f739 Fix per-language public offer disabling 2025-10-07 05:27:21 +03:00
Egor 60391a8b89 Merge pull request #835 from Fr1ngg/revert-834-plexvn-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:27:06 +03:00
Egor f2d4da9514 Revert "Add configurable public offer info section" 2025-10-07 05:26:57 +03:00
Egor 648ad24e6d Merge pull request #834 from Fr1ngg/plexvn-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:17:12 +03:00
Egor 5d28cd9930 Add configurable public offer info section 2025-10-07 05:16:53 +03:00
Egor 43974d1685 Merge pull request #833 from Fr1ngg/kq9kol-bedolaga/add-config-policy-button-to-bot-info
Add privacy policy info menu and admin controls
2025-10-07 04:57:06 +03:00
Egor 39ccc7fb4a Add privacy policy section and admin management 2025-10-07 04:56:45 +03:00
Egor d391a46b0e Merge pull request #831 from Fr1ngg/dev2
Расширения функционала отправки промо предложений / отправка конкретному юзеру
2025-10-07 02:39:35 +03:00
Egor 59dd638dd7 Merge pull request #830 from Fr1ngg/bedolaga-5bnc62
Fix promo offer durations and active discount display
2025-10-07 02:35:47 +03:00
Egor 3062061270 Fix promo offer durations and active discount display 2025-10-07 02:34:47 +03:00
Egor 5b0a64964e Merge pull request #829 from Fr1ngg/revert-828-bedolaga-48se3m
Revert "Add detailed promo offer status for user view"
2025-10-07 02:33:52 +03:00
Egor 70e3517d3a Revert "Add detailed promo offer status for user view" 2025-10-07 02:33:44 +03:00
Egor 08b64abb03 Merge pull request #828 from Fr1ngg/bedolaga-48se3m
Add detailed promo offer status for user view
2025-10-07 02:21:14 +03:00
Egor 84a3179974 Add detailed promo offer status for user view 2025-10-07 02:21:02 +03:00
Egor fa56ad8791 Merge pull request #827 from Fr1ngg/revert-826-bedolaga-ztqa24
Revert "Improve promo offer user view with status details"
2025-10-07 02:20:45 +03:00
Egor f584cb78f1 Revert "Improve promo offer user view with status details" 2025-10-07 02:20:36 +03:00
Egor ff3ac2b9f3 Merge pull request #826 from Fr1ngg/bedolaga-ztqa24
Improve promo offer user view with status details
2025-10-07 02:19:06 +03:00
Egor deabcfba28 Enhance promo offer user detail stats 2025-10-07 02:18:45 +03:00
Egor 85775604a8 Merge pull request #825 from Fr1ngg/revert-824-bedolaga-pzccm9
Revert "Fix promo offer localization placeholder mismatch"
2025-10-07 02:16:00 +03:00
Egor bf9b99818d Revert "Fix promo offer localization placeholder mismatch" 2025-10-07 02:15:52 +03:00
Egor 93a8c182bc Merge pull request #824 from Fr1ngg/bedolaga-pzccm9
Fix promo offer localization placeholder mismatch
2025-10-07 02:04:54 +03:00
Egor 7febaf9db2 Fix promo offer profile summary placeholders 2025-10-07 02:04:38 +03:00
Egor 21583279c6 Merge pull request #823 from Fr1ngg/revert-822-bedolaga-b9dizq
Revert "Cleanup promo offer user search prompt and add localization"
2025-10-07 02:04:11 +03:00
Egor df11e09d3a Revert "Cleanup promo offer user search prompt and add localization" 2025-10-07 02:04:02 +03:00
Egor 0b2ebbcdd8 Merge pull request #822 from Fr1ngg/bedolaga-b9dizq
Cleanup promo offer user search prompt and add localization
2025-10-07 01:57:12 +03:00
Egor 28be0954f9 Fix promo offer user search UX and translations 2025-10-07 01:56:59 +03:00
Egor 44164b2948 Merge pull request #821 from Fr1ngg/revert-820-bedolaga-72teet
Revert "Add user-targeted promo offer sending workflow"
2025-10-07 01:56:25 +03:00
Egor d9d4f171f6 Revert "Add user-targeted promo offer sending workflow" 2025-10-07 01:56:17 +03:00
Egor 4c73d1d0b6 Merge pull request #820 from Fr1ngg/bedolaga-72teet
Add user-targeted promo offer sending workflow
2025-10-07 01:41:34 +03:00
Egor 0bf71971f9 feat: add user-targeted promo offer sending 2025-10-07 01:41:15 +03:00
Egor ff19fb8950 Merge pull request #819 from Fr1ngg/revert-818-bedolaga-pm5a67
Revert "Add promo template editing workflow and restrictions"
2025-10-07 01:21:32 +03:00
Egor 68a37fcf72 Revert "Add promo template editing workflow and restrictions" 2025-10-07 01:21:20 +03:00
Egor 2caf16ccc6 Merge pull request #818 from Fr1ngg/bedolaga-pm5a67
Add promo template editing workflow and restrictions
2025-10-07 01:19:23 +03:00
Egor b38ee36698 Add promo template editing workflow and restrictions 2025-10-07 01:19:05 +03:00
Egor 3ee9c3de7b Merge pull request #817 from Fr1ngg/revert-816-bedolaga-m8ijxh
Revert "Improve admin promo template restrictions and editing"
2025-10-07 01:18:50 +03:00
Egor 0fce289926 Revert "Improve admin promo template restrictions and editing" 2025-10-07 01:18:42 +03:00
Egor b1e7f83d11 Merge pull request #816 from Fr1ngg/bedolaga-m8ijxh
Improve admin promo template restrictions and editing
2025-10-07 01:17:09 +03:00
Egor ff28f82240 Refine admin promo template workflow 2025-10-07 01:16:44 +03:00
Egor d5082be405 Merge pull request #815 from Fr1ngg/revert-814-bedolaga
Revert "Add per-user promo template sending in admin panel"
2025-10-07 01:16:13 +03:00
Egor 3e830a32ea Revert "Add per-user promo template sending in admin panel" 2025-10-07 01:16:05 +03:00
Egor c8a99ceed7 Merge pull request #814 from Fr1ngg/bedolaga
Add per-user promo template sending in admin panel
2025-10-07 00:19:18 +03:00
Egor 602ddd4013 feat(admin): manage user promo offers 2025-10-07 00:18:59 +03:00
Egor 33443955c7 Merge pull request #811 from Fr1ngg/dev2
Dev2
2025-10-06 04:48:28 +03:00
Egor 8f8b6ac1fe Merge pull request #810 from Fr1ngg/bedolaga/add-info-section-to-main-page
Add info section to main menu
2025-10-06 04:46:16 +03:00
Egor 8bb0795b1e Add info section to main menu 2025-10-06 04:45:54 +03:00
Egor 0055560d3e Merge pull request #809 from Fr1ngg/dev2
api promo offers
2025-10-06 04:37:05 +03:00
Egor dcc209deea Merge pull request #808 from Fr1ngg/bedolaga/add-api-endpoints-for-promo-offers
Add promo offer management endpoints
2025-10-06 04:27:40 +03:00
Egor a2cb7527f8 feat(api): add promo offers endpoints and docs 2025-10-06 04:27:21 +03:00
Egor 523c6ab69c Merge pull request #807 from Fr1ngg/dev2
Фикс промо предложений
2025-10-06 02:58:38 +03:00
Egor 91220b0b5f Merge pull request #806 from Fr1ngg/main
w
2025-10-06 02:57:53 +03:00
Egor f87dac8efa Merge pull request #805 from Fr1ngg/bedolaga/fix-discount-claim-error
Handle custom placeholders in discount claim message
2025-10-06 02:53:03 +03:00
Egor 44b75be97a Handle missing placeholders in discount claim message 2025-10-06 02:52:44 +03:00
Egor 500ed4cdf9 Update Dockerfile 2025-10-06 01:59:41 +03:00
Egor 498bd77076 Update docker-registry.yml 2025-10-06 01:59:29 +03:00
Egor 29afb6e67f Update docker-hub.yml 2025-10-06 01:59:16 +03:00
Egor 01d10b58dd Merge pull request #804 from Fr1ngg/dev2
Рассылка промопредложений + выдача сквадов(серверов) на время
2025-10-06 01:50:20 +03:00
Egor cf7aed9abf Merge pull request #803 from Fr1ngg/main
w
2025-10-06 01:46:02 +03:00
Egor 1b57531b28 Merge pull request #802 from Fr1ngg/bedolaga/fix-telegram-button-privacy-restriction-error-ptdrym
Handle Telegram privacy restriction fallback
2025-10-06 01:37:31 +03:00
Egor 375c096e82 Handle Telegram privacy restriction fallback 2025-10-06 01:37:12 +03:00
Egor 89b0daae1d Merge pull request #800 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-lplld3
Escape braces in promo hint server names
2025-10-06 01:25:44 +03:00
Egor 754c21a32f Escape braces in promo hint server names 2025-10-06 01:25:26 +03:00
Egor 4928cdde43 Merge pull request #799 from Fr1ngg/revert-798-bedolaga/fix-bot-main-message-for-test-server-36fdi2
Revert "Escape test server names in promo hint"
2025-10-06 01:25:14 +03:00
Egor 057e5f6ab0 Revert "Escape test server names in promo hint" 2025-10-06 01:25:03 +03:00
Egor 6d66d6f627 Merge pull request #798 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-36fdi2
Escape test server names in promo hint
2025-10-06 01:15:58 +03:00
Egor be28e9bec4 Escape server names in promo hint 2025-10-06 01:15:42 +03:00
Egor b923c4bcaa Merge pull request #797 from Fr1ngg/revert-796-bedolaga/fix-bot-main-message-for-test-server
Revert "Display test server names in promo hint"
2025-10-06 01:15:26 +03:00
Egor 3a749c1864 Revert "Display test server names in promo hint" 2025-10-06 01:14:58 +03:00
Egor d16d5b7918 Merge pull request #796 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server
Display test server names in promo hint
2025-10-06 01:09:23 +03:00
Egor 8484515722 Show test server names in promo hint 2025-10-06 01:08:53 +03:00
Egor b9efff6e97 Update ru.json 2025-10-06 01:01:41 +03:00
Egor 061557c438 Update ru.json 2025-10-06 00:58:53 +03:00
Egor df09ccfbd3 Merge pull request #795 from Fr1ngg/bedolaga-4ou2e3
Display promo offer details on main menu
2025-10-06 00:49:26 +03:00
Egor 16c7e140d0 Guard promo offer extra_data lookups 2025-10-06 00:49:09 +03:00
Egor 967924a1ff Merge pull request #794 from Fr1ngg/revert-793-revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Revert "Display promo offer details on main menu"""
2025-10-06 00:48:55 +03:00
Egor e028a25df2 Revert "Revert "Revert "Display promo offer details on main menu""" 2025-10-06 00:48:46 +03:00
Egor d3fcd11201 Merge pull request #793 from Fr1ngg/revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Display promo offer details on main menu""
2025-10-06 00:42:56 +03:00
Egor 97b9056b0b Revert "Revert "Display promo offer details on main menu"" 2025-10-06 00:42:46 +03:00
Egor 00ba52672b Merge pull request #792 from Fr1ngg/revert-791-bedolaga-g6lr1v
Revert "Add active promo info to main menu"
2025-10-06 00:42:01 +03:00
Egor efe16b4269 Revert "Add active promo info to main menu" 2025-10-06 00:41:42 +03:00
Egor 5e94ad2189 Merge pull request #791 from Fr1ngg/bedolaga-g6lr1v
Add active promo info to main menu
2025-10-06 00:37:37 +03:00
Egor 53bb582c81 Add active promo info to main menu 2025-10-06 00:37:19 +03:00
Egor 4c20ec19ab Merge pull request #790 from Fr1ngg/revert-789-bedolaga-ly8q4p
Revert "Display promo offer details on main menu"
2025-10-06 00:36:36 +03:00
Egor a7042ea012 Revert "Display promo offer details on main menu" 2025-10-06 00:36:26 +03:00
Egor f700285415 Merge pull request #789 from Fr1ngg/bedolaga-ly8q4p
Display promo offer details on main menu
2025-10-06 00:32:44 +03:00
Egor ed1fc22f6b Show promo offer details on main menu 2025-10-06 00:32:27 +03:00
Egor c3c0c764fd Merge pull request #788 from Fr1ngg/bedolaga/update-promo-offer-message-and-add-status-bar
Add promo offer timer to subscription flows
2025-10-06 00:10:22 +03:00
Egor 746e810c58 Add promo offer timer and updated discount messaging 2025-10-06 00:10:06 +03:00
Egor 2df5d21a9e Merge pull request #787 from Fr1ngg/bedolaga/fix-subscription-button-text-for-trial-users
Fix promo purchase discount CTA for trials
2025-10-05 23:35:36 +03:00
Egor d664b99e1c Adjust promo discount CTA for purchase offers 2025-10-05 23:35:09 +03:00
Egor 74fe3b09a2 Merge pull request #786 from Fr1ngg/revert-785-bedolaga-8svrvz
Revert "Adjust discount claim CTA for purchase offers"
2025-10-05 17:28:03 +03:00
Egor efea0e8414 Revert "Adjust discount claim CTA for purchase offers" 2025-10-05 17:27:54 +03:00
Egor 1dc4d0e04d Merge pull request #785 from Fr1ngg/bedolaga-8svrvz
Adjust discount claim CTA for purchase offers
2025-10-05 17:24:41 +03:00
Egor 84f06a96b3 Adjust discount claim CTA for purchase offers 2025-10-05 17:24:25 +03:00
Egor 4a7cc3306a Merge pull request #783 from Fr1ngg/bedolaga/add-duration-for-subscription-promo-discounts
Add expiration control for promo offer discounts
2025-10-05 17:01:38 +03:00
Egor 5d576bc727 Add expiration control for promo offer discounts 2025-10-05 17:01:11 +03:00
Egor 19a7a8e6b1 Merge pull request #782 from Fr1ngg/bedolaga/update-promo-message-buttons
Adjust promo offer keyboards to add close button
2025-10-05 06:11:11 +03:00
Egor 064f64afd0 Restrict promo offer keyboards to activation and close 2025-10-05 06:10:49 +03:00
Egor bfa5e5aab9 Merge pull request #781 from Fr1ngg/bedolaga-zcm3jb
Fix discount activation CTA to extend subscription
2025-10-05 05:47:29 +03:00
Egor f59bda57cf Adjust discount activation CTA 2025-10-05 05:46:55 +03:00
Egor 6887c265ad Merge pull request #780 from Fr1ngg/bedolaga/fix-chat-message-retention-during-promo-editing
Удаление сообщений администратора при редактировании промо-предложений
2025-10-05 05:31:06 +03:00
Egor 7db31ff5b5 Удаление пользовательских сообщений при редактировании промо 2025-10-05 05:30:45 +03:00
Egor 6bee1bb85d Merge pull request #779 from Fr1ngg/bedolaga/add-active-trials-category-to-promotions
Add active trial segment to purchase discount offer
2025-10-05 05:11:01 +03:00
Egor c604c5f6ff Add active trial segment to purchase promo 2025-10-05 05:10:45 +03:00
Egor ed5e6aa550 Merge pull request #777 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-qglaer
fix: rollback session after promo log failures
2025-10-05 04:06:21 +03:00
Egor 6b78d0f63e Merge pull request #778 from Fr1ngg/revert-776-bedolaga/add-paginated-log-for-promo-offers-nmhsmh
Revert "fix: restore promo test access claim flow"
2025-10-05 04:05:56 +03:00
Egor fea1819ef8 Revert "fix: restore promo test access claim flow" 2025-10-05 04:05:47 +03:00
Egor 45586277ab fix: rollback session after promo log failures 2025-10-05 04:05:43 +03:00
Egor 8ff3383246 Merge pull request #776 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-nmhsmh
fix: restore promo test access claim flow
2025-10-05 03:54:57 +03:00
Egor 7879dc5e43 fix: define newly added test access squads 2025-10-05 03:54:39 +03:00
Egor 392084e436 Merge pull request #775 from Fr1ngg/revert-774-revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "Revert "feat: add promo offer operation logs"""
2025-10-05 03:53:28 +03:00
Egor 256e1cb778 Revert "Revert "Revert "feat: add promo offer operation logs""" 2025-10-05 03:53:16 +03:00
Egor e131f77de3 Merge pull request #774 from Fr1ngg/revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "feat: add promo offer operation logs""
2025-10-05 03:46:36 +03:00
Egor a60082e5e5 Revert "Revert "feat: add promo offer operation logs"" 2025-10-05 03:46:23 +03:00
Egor 81bb8f8f4a Merge pull request #773 from Fr1ngg/revert-772-bedolaga/add-paginated-log-for-promo-offers-3spy1o
Revert "Add promo offer activity log in admin panel"
2025-10-05 03:46:05 +03:00
Egor a9af75d9df Revert "Add promo offer activity log in admin panel" 2025-10-05 03:45:55 +03:00
Egor 30758848fa Merge pull request #772 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-3spy1o
Add promo offer activity log in admin panel
2025-10-05 03:44:46 +03:00
Egor 00c024bd2c Add promo offer activity log in admin panel 2025-10-05 03:44:28 +03:00
Egor a69d28fb13 Merge pull request #771 from Fr1ngg/revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "feat: add promo offer operation logs"
2025-10-05 03:44:03 +03:00
Egor ac818ccf34 Revert "feat: add promo offer operation logs" 2025-10-05 03:43:53 +03:00
Egor 533335bad9 Merge pull request #770 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers
feat: add promo offer operation logs
2025-10-05 03:32:40 +03:00
Egor 37c99ae1fe feat: add promo offer operation logs 2025-10-05 03:32:24 +03:00
Egor d9b232b87b Merge pull request #769 from Fr1ngg/bedolaga/add-buttons-for-subscription-promotions
Enhance promo offer messaging and safeguards
2025-10-04 17:06:41 +03:00
Egor b8df611d0d Enhance promo offer flows 2025-10-04 17:06:24 +03:00
Egor e0b5beed02 Merge pull request #768 from Fr1ngg/bedolaga/add-purchase-button-to-discount-message
Fix promo discount message button and checkout crash
2025-10-04 16:43:04 +03:00
Egor f92cf97f63 Fix promo discount claim actions 2025-10-04 16:42:44 +03:00
Egor 9ae11bc525 Merge pull request #767 from Fr1ngg/bedolaga/update-promo-offer-functionality-in-admin-panel
Adjust promo test offers with server targeting
2025-10-04 16:29:38 +03:00
Egor ef1426a938 Adjust promo test offers with server targeting 2025-10-04 16:29:23 +03:00
Egor 456b02e2d9 Merge pull request #766 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-onp0w2
Ensure subscription service reloads RemnaWave configuration
2025-10-04 16:12:25 +03:00
Egor abb156a7b1 Fix RemnaWave config refresh in subscription service 2025-10-04 16:12:03 +03:00
Egor ca8917a43c Merge pull request #765 from Fr1ngg/revert-764-bedolaga/enhance-promo-offer-functionality-mxpx1o
Revert "Fix test access sync and add menu navigation"
2025-10-04 16:08:54 +03:00
Egor 8ce75007ff Revert "Fix test access sync and add menu navigation" 2025-10-04 16:08:45 +03:00
Egor 76ced9aeeb Merge pull request #764 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-mxpx1o
Fix test access sync and add menu navigation
2025-10-04 13:10:40 +03:00
Egor 42eeb67039 Fix test access sync and add back to menu button 2025-10-04 13:10:23 +03:00
Egor c11be659b3 Merge pull request #763 from Fr1ngg/revert-762-bedolaga/enhance-promo-offer-functionality-wktrm4
Revert "Improve test access promo syncing and messaging"
2025-10-04 13:09:58 +03:00
Egor 2a44d98015 Revert "Improve test access promo syncing and messaging" 2025-10-04 13:09:48 +03:00
Egor 885fa2fb1a Merge pull request #762 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-wktrm4
Improve test access promo syncing and messaging
2025-10-04 13:07:00 +03:00
Egor 8bcc2135ca Handle Remnawave sync failures for test access offers 2025-10-04 13:06:43 +03:00
Egor a26d2fb50b Merge pull request #761 from Fr1ngg/revert-760-bedolaga/enhance-promo-offer-functionality
Revert "Add admin squad picker for test access promo offers"
2025-10-04 13:06:18 +03:00
Egor ad3a8b3af3 Revert "Add admin squad picker for test access promo offers" 2025-10-04 13:06:09 +03:00
Egor 918a450e63 Merge pull request #760 from Fr1ngg/bedolaga/enhance-promo-offer-functionality
Add admin squad picker for test access promo offers
2025-10-04 12:52:52 +03:00
Egor 596053c720 feat: add squad picker for test promo offers 2025-10-04 12:52:27 +03:00
Egor abb83b43bc Merge pull request #759 from Fr1ngg/bedolaga/fix-promo-discount-handling-and-messages
Fix promo discount consumption race and refresh promo offer texts
2025-10-04 12:25:22 +03:00
Egor 6f67a36a58 Fix promo discount consumption and admin copy 2025-10-04 12:25:00 +03:00
Egor 9cbc9bb3d5 Merge pull request #758 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-5mdcu6
Honor promo offer discounts in autopay renewals
2025-10-04 12:10:34 +03:00
Egor 1cbf3c5643 Recalculate promo offer discount during subscription confirmation 2025-10-04 12:10:04 +03:00
Egor 8f35d56e6a Merge pull request #757 from Fr1ngg/revert-756-bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Revert "Honor promo offer discounts in autopay renewals"
2025-10-04 12:09:13 +03:00
Egor b5773ba668 Revert "Honor promo offer discounts in autopay renewals" 2025-10-04 12:09:04 +03:00
Egor fa5847ee64 Merge pull request #756 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Honor promo offer discounts in autopay renewals
2025-10-04 11:53:09 +03:00
Egor b561fbc9e5 Apply promo offer discounts to autopay renewals 2025-10-04 11:52:38 +03:00
Egor 305a0b6b95 Merge pull request #755 from Fr1ngg/revert-754-bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Revert "Fix promo offer validation to use pre-discount totals"
2025-10-04 11:52:02 +03:00
Egor 12a16097cf Revert "Fix promo offer validation to use pre-discount totals" 2025-10-04 11:51:52 +03:00
Egor c8986bea40 Merge pull request #754 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Fix promo offer validation to use pre-discount totals
2025-10-04 11:43:00 +03:00
Egor bcf01ecf6a Fix promo offer validation to use pre-discount totals 2025-10-04 11:42:43 +03:00
Egor a334fbffb7 Merge pull request #753 from Fr1ngg/revert-752-bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Revert "Refactor promo offers to apply stackable discounts"
2025-10-04 11:42:16 +03:00
Egor fc8f55195f Revert "Refactor promo offers to apply stackable discounts" 2025-10-04 11:42:06 +03:00
Egor dbd79cbc65 Merge pull request #752 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Refactor promo offers to apply stackable discounts
2025-10-04 11:35:06 +03:00
Egor 436686dd1b Ensure promo offers use stackable discounts 2025-10-04 11:34:46 +03:00
Egor 227e754f4a Merge pull request #751 from Fr1ngg/revert-750-bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Revert "Implement percent-based promo discounts"
2025-10-04 11:34:31 +03:00
Egor 3d43358b86 Revert "Implement percent-based promo discounts" 2025-10-04 11:34:22 +03:00
Egor f05f32b92e Merge pull request #750 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Implement percent-based promo discounts
2025-10-04 11:32:43 +03:00
Egor 7628b9aaa7 Implement percent-based promo discounts 2025-10-04 11:31:55 +03:00
Egor 9ab8f1dee9 Merge pull request #748 from Fr1ngg/revert-747-bedolaga/update-promo-offers-to-apply-percentage-discount
Revert "Implement stackable percent-based promo offers"
2025-10-04 11:31:05 +03:00
Egor 092ea034ec Revert "Implement stackable percent-based promo offers" 2025-10-04 11:30:57 +03:00
Egor c4e5261b31 Merge pull request #747 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount
Implement stackable percent-based promo offers
2025-10-04 11:29:04 +03:00
Egor 2baf2b9abb Adjust promo offer templates for percent discounts 2025-10-04 11:28:47 +03:00
Egor 87a6b05e54 Merge pull request #746 from Fr1ngg/revert-745-bedolaga/update-promo-offers-for-discounts-rwpidj
Revert "Implement percent-based promo offers"
2025-10-04 11:05:56 +03:00
Egor 0925dfdf80 Revert "Implement percent-based promo offers" 2025-10-04 11:05:47 +03:00
Egor cf294fae0a Merge pull request #745 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-rwpidj
Implement percent-based promo offers
2025-10-04 10:58:35 +03:00
Egor 12e77cd6bf Implement percent-based promo offers 2025-10-04 10:57:00 +03:00
Egor 867ae1b807 Merge pull request #744 from Fr1ngg/revert-743-bedolaga/update-promo-offers-for-discounts-9jz4ar
Revert "Clear expired promo discounts when preparing checkout summaries"
2025-10-04 10:55:45 +03:00
Egor db9f91b336 Revert "Clear expired promo discounts when preparing checkout summaries" 2025-10-04 10:55:32 +03:00
Egor df0792d4dd Merge pull request #743 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-9jz4ar
Clear expired promo discounts when preparing checkout summaries
2025-10-04 10:54:35 +03:00
Egor 0e0569fe23 Clear expired promo discounts before checkout 2025-10-04 10:53:09 +03:00
Egor daa37370e3 Merge pull request #741 from Fr1ngg/bedolaga/fix-invalid-values-error-for-active-subscriptions
Fix parsing of promo offer send callbacks
2025-10-04 10:19:36 +03:00
Egor b35c731ba5 Fix promo offer segment parsing 2025-10-04 10:19:05 +03:00
Egor 06879089a1 Merge pull request #740 from Fr1ngg/bedolaga/fix-missing-join-in-promo-segment-query
Fix promo segment join for no subscription users
2025-10-04 10:14:01 +03:00
Egor b96d530808 Fix promo segment no subscription join 2025-10-04 10:13:45 +03:00
Egor 8f3c8f37c1 Merge pull request #739 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot-mgwac3
Add admin promo offer management and targeted promotions
2025-10-04 10:05:54 +03:00
Egor e4c7a9cf15 Add admin promo offers and targeted discount workflows 2025-10-04 10:05:31 +03:00
Egor 935e2356db Merge pull request #738 from Fr1ngg/revert-737-bedolaga/add-promotional-offers-feature-to-bot
Revert "Implement targeted promotional offer campaigns"
2025-10-04 10:04:59 +03:00
Egor 3b392a671d Revert "Implement targeted promotional offer campaigns" 2025-10-04 10:04:49 +03:00
Egor 4ceb09d609 Merge pull request #737 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot
Implement targeted promotional offer campaigns
2025-10-04 09:59:41 +03:00
Egor 3f8ea7f8fd Add promotional offer broadcasting support 2025-10-04 09:59:17 +03:00
Egor 95525f2132 Update README.md 2025-10-04 08:03:44 +03:00
Egor d15b46dede Merge pull request #736 from Legacyyy777/DEV
Мулен 3
2025-10-04 07:44:54 +03:00
Egor f2fc7581a3 Update Dockerfile 2025-10-04 07:37:47 +03:00
Egor e96010d24d Update docker-registry.yml 2025-10-04 07:37:32 +03:00
Egor 35fc39b9f2 Update docker-hub.yml 2025-10-04 07:37:13 +03:00
Legacyyy777 7129625ab5 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 09:23:31 +05:00
Legacyyy777 e480356447 Оптимизирована обработка подключения к БД в WebhookServer. Удален цикл async for, добавлена обработка исключений при получении соединения с БД. 2025-10-04 09:21:49 +05:00
Egor 46ea2de300 Merge pull request #735 from Fr1ngg/dev2
Перенос настроек цен и управления серверов + рефактор запуска бота
2025-10-04 06:58:37 +03:00
Egor 2152ded3c4 Merge pull request #734 from Fr1ngg/bedolaga/improve-bot-startup-log-format-ih803q
feat: add structured startup timeline logging
2025-10-04 06:51:36 +03:00
Egor da0e8a2e3e feat: add structured startup timeline logging 2025-10-04 06:51:19 +03:00
Egor da540e8680 Merge pull request #733 from Fr1ngg/revert-732-bedolaga/improve-bot-startup-log-format
Revert "Enhance startup log presentation"
2025-10-04 06:51:07 +03:00
Egor a12ad52c7e Revert "Enhance startup log presentation" 2025-10-04 06:50:59 +03:00
Egor 38b5fa297b Merge pull request #732 from Fr1ngg/bedolaga/improve-bot-startup-log-format
Enhance startup log presentation
2025-10-04 06:49:05 +03:00
Egor d50475974c Improve startup logging aesthetics 2025-10-04 06:48:49 +03:00
Egor ada891f2d9 Merge pull request #731 from Fr1ngg/bedolaga/resolve-missing-localization-keys
Add missing admin pricing locale strings
2025-10-04 06:40:13 +03:00
Egor e7467e5434 Add missing admin pricing translations 2025-10-04 06:38:45 +03:00
Egor fa4a692574 Merge pull request #730 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-y4nuoo
Mutate period price cache in place
2025-10-04 06:31:18 +03:00
Egor 9c60ef2c33 Mutate period price cache in place 2025-10-04 06:30:50 +03:00
Egor 634633d12e Merge pull request #729 from Fr1ngg/revert-728-bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Revert "Fix subscription period price cache update"
2025-10-04 06:30:25 +03:00
Egor b3fd0c50ca Revert "Fix subscription period price cache update" 2025-10-04 06:30:17 +03:00
Egor acb267668e Merge pull request #728 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Fix subscription period price cache update
2025-10-04 06:24:55 +03:00
Egor 344cffef60 Fix period price cache refresh 2025-10-04 06:24:38 +03:00
Egor ef652d4518 Merge pull request #727 from Fr1ngg/revert-726-bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Revert "Fix subscription period price updates in admin panel"
2025-10-04 06:23:25 +03:00
Egor a66f9bbf28 Revert "Fix subscription period price updates in admin panel" 2025-10-04 06:23:17 +03:00
Egor 3e5fbcf2bd Merge pull request #726 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Fix subscription period price updates in admin panel
2025-10-04 06:20:12 +03:00
Egor ba2de9e150 Fix period price updates in admin 2025-10-04 06:19:52 +03:00
Egor b638e04629 Merge pull request #725 from Fr1ngg/revert-724-bedolaga/fix-subscription-price-update-in-admin-panel
Revert "Fix subscription price refresh in admin panel"
2025-10-04 06:19:32 +03:00
Egor 45a5f012c2 Revert "Fix subscription price refresh in admin panel" 2025-10-04 06:19:24 +03:00
Egor 1a66094800 Merge pull request #724 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel
Fix subscription price refresh in admin panel
2025-10-04 06:17:21 +03:00
Egor f7afe481bb Fix subscription price refresh in admin panel 2025-10-04 06:17:04 +03:00
Egor f04bf43962 Merge pull request #723 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-emq0t0
Refine admin pricing menu UX
2025-10-04 06:07:50 +03:00
Egor 3964ed1f3c Improve pricing admin panel UX 2025-10-04 06:07:35 +03:00
Egor 98fa8eef5d Merge pull request #722 from Fr1ngg/revert-721-bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX"
2025-10-04 06:07:18 +03:00
Egor c7b47eef4d Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX" 2025-10-04 06:07:10 +03:00
Egor 9270bb3bf5 Merge pull request #721 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Add trial and tariff configuration controls to admin pricing panel and refine UX
2025-10-04 06:04:55 +03:00
Egor ad2f6f6f22 Improve pricing admin UX 2025-10-04 06:04:33 +03:00
Egor 62749f9ffe Merge pull request #720 from Fr1ngg/revert-719-bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Revert "Add trial and tariff settings to admin pricing"
2025-10-04 05:58:04 +03:00
Egor e623f9df3a Revert "Add trial and tariff settings to admin pricing" 2025-10-04 05:57:49 +03:00
Egor 2b79de7d82 Merge pull request #719 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Add trial and tariff settings to admin pricing
2025-10-04 05:56:26 +03:00
Egor cdcc3b4c4a Add trial and tariff settings to admin pricing 2025-10-04 05:56:07 +03:00
Egor e757569628 Merge pull request #718 from Fr1ngg/revert-717-bedolaga/add-trial-period-management-in-admin-panel
Revert "Add trial and tariff configuration controls to admin pricing panel"
2025-10-04 05:55:34 +03:00
Egor 4f88cdf7db Revert "Add trial and tariff configuration controls to admin pricing panel" 2025-10-04 05:55:26 +03:00
Egor 406c13aea2 Merge pull request #717 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel
Add trial and tariff configuration controls to admin pricing panel
2025-10-04 05:51:31 +03:00
Egor c95b1e4fad feat(admin): extend pricing panel settings 2025-10-04 05:51:12 +03:00
Egor 43c480fa33 Merge pull request #716 from Fr1ngg/revert-713-revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Revert "Refactor admin menu for servers and pricing management""
2025-10-04 05:37:11 +03:00
Egor cb25bbb2ed Revert "Revert "Refactor admin menu for servers and pricing management"" 2025-10-04 05:36:59 +03:00
Egor 2602a05fb2 Merge pull request #715 from Fr1ngg/revert-714-bedolaga/reorganize-admin-panel-menu-t36ykc
Revert "Reorganize admin menu and add pricing management"
2025-10-04 05:35:24 +03:00
Egor c31da6c2ae Revert "Reorganize admin menu and add pricing management" 2025-10-04 05:35:06 +03:00
Egor 17d51ab286 Merge pull request #714 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-t36ykc
Reorganize admin menu and add pricing management
2025-10-04 05:33:40 +03:00
Egor e576cb97ec feat: reorganize admin menu with pricing section 2025-10-04 05:33:25 +03:00
Egor 217fed9c9c Merge pull request #713 from Fr1ngg/revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Refactor admin menu for servers and pricing management"
2025-10-04 05:33:14 +03:00
Egor 030f4efbe1 Revert "Refactor admin menu for servers and pricing management" 2025-10-04 05:33:06 +03:00
Egor 33b3828abc Merge pull request #712 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-mzg1j9
Refactor admin menu for servers and pricing management
2025-10-04 05:30:27 +03:00
Egor f2876fd02c Refactor admin menu for servers and pricing management 2025-10-04 05:30:08 +03:00
Egor ee9ba71f3c Merge pull request #711 from Fr1ngg/revert-710-bedolaga/reorganize-admin-panel-menu
Revert "Reorganize admin panel pricing and server controls"
2025-10-04 05:29:48 +03:00
Egor 6a5d5d960f Revert "Reorganize admin panel pricing and server controls" 2025-10-04 05:29:41 +03:00
Egor 910409cdfb Merge pull request #710 from Fr1ngg/bedolaga/reorganize-admin-panel-menu
Reorganize admin panel pricing and server controls
2025-10-04 05:27:32 +03:00
Egor d78b36b60e Add dedicated pricing management section to admin panel 2025-10-04 05:27:10 +03:00
Egor ca2f83db88 Обновление меню конфигурации бота
Dev2
2025-10-04 04:58:05 +03:00
Egor bf591f55ea Merge pull request #708 from Fr1ngg/bedolaga-hw3yna
feat: enhance admin settings control panel
2025-10-04 04:49:29 +03:00
Egor 99aa09349e feat: enhance admin settings control panel 2025-10-04 04:49:15 +03:00
Egor a391b635ed Merge pull request #707 from Fr1ngg/revert-704-bedolaga-8nay3u
Revert "Complete bot configuration dashboard actions"
2025-10-04 04:48:54 +03:00
Egor 4f1ff34dce Revert "Complete bot configuration dashboard actions" 2025-10-04 04:47:38 +03:00
Egor 9a23a305b5 Merge pull request #706 from Fr1ngg/revert-705-bedolaga/add-alembic-migration-for-system_settings_history-table
Revert "Add migration for system settings history audit table"
2025-10-04 04:47:20 +03:00
Egor 539e7e9066 Revert "Add migration for system settings history audit table" 2025-10-04 04:47:12 +03:00
Egor 6b1173a1b5 Merge pull request #705 from Fr1ngg/bedolaga/add-alembic-migration-for-system_settings_history-table
Add migration for system settings history audit table
2025-10-04 04:39:02 +03:00
Egor 23f8e158ad Add migration for system settings history audit table 2025-10-04 04:38:44 +03:00
Egor 578061be8c Merge pull request #704 from Fr1ngg/bedolaga-8nay3u
Complete bot configuration dashboard actions
2025-10-04 04:24:00 +03:00
Egor 84816b84e7 Complete bot configuration dashboard actions 2025-10-04 04:23:41 +03:00
Egor 0ad2f5bb8a Merge pull request #703 from Fr1ngg/revert-702-bedolaga-1gwn05
Revert "Implement admin bot configuration experience"
2025-10-04 04:23:22 +03:00
Egor e415b4b2ee Revert "Implement admin bot configuration experience" 2025-10-04 04:23:14 +03:00
Egor 7d6ecaaa1b Merge pull request #702 from Fr1ngg/bedolaga-1gwn05
Implement admin bot configuration experience
2025-10-04 04:21:20 +03:00
Egor 588461c53a Implement admin bot configuration experience 2025-10-04 04:21:01 +03:00
Egor 25c23bd653 Merge pull request #701 from Fr1ngg/revert-700-bedolaga-i1q6pf
Revert "feat: enrich bot configuration admin panel"
2025-10-04 04:20:45 +03:00
Egor 1a033de259 Revert "feat: enrich bot configuration admin panel" 2025-10-04 04:20:36 +03:00
Egor 850f8d2b2e Merge pull request #700 from Fr1ngg/bedolaga-i1q6pf
feat: enrich bot configuration admin panel
2025-10-04 04:15:48 +03:00
Egor 39374095eb feat: enrich bot configuration admin panel 2025-10-04 04:15:31 +03:00
Egor f4fe8913fa Merge pull request #699 from Fr1ngg/revert-698-bedolaga/add-subscription-plan-menu-in-admin-panel
Revert "Add admin tariffs menu scaffolding"
2025-10-04 03:48:07 +03:00
Egor 9a5203770b Revert "Add admin tariffs menu scaffolding" 2025-10-04 03:47:57 +03:00
Egor 64b0f5161c Merge pull request #698 from Fr1ngg/bedolaga/add-subscription-plan-menu-in-admin-panel
Add admin tariffs menu scaffolding
2025-10-04 02:40:28 +03:00
Egor 8e12609746 Add admin tariffs menu scaffolding 2025-10-04 02:39:16 +03:00
Egor 703d09c556 Merge pull request #697 from Fr1ngg/dev2
Dev2
2025-10-04 00:41:22 +03:00
Egor 7b238a32b4 Merge pull request #696 from Fr1ngg/main
w
2025-10-04 00:40:34 +03:00
Egor c72fa990fa Merge pull request #694 from Legacyyy777/DEV
Мулен правки 2
2025-10-04 00:39:28 +03:00
Egor f8fc929cbf Merge pull request #695 from Fr1ngg/bedolaga/update-install_bot.sh-for-docker-compose
Add Docker Compose pull update option and display container status
2025-10-04 00:38:58 +03:00
Egor 744aaeb5d6 feat: add docker compose pull update 2025-10-04 00:38:34 +03:00
Legacyyy777 515088ff45 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 02:33:07 +05:00
Legacyyy777 96b64df989 Добавлен return после цикла async for db in get_db().
Добавлена обработка случая, когда не удается получить соединение с БД.
2025-10-04 02:32:04 +05:00
Egor f51c22c67e Merge pull request #693 from Fr1ngg/dev2
Dev2
2025-10-04 00:19:03 +03:00
Egor 010c412268 Merge pull request #682 from Fr1ngg/main
w
2025-10-04 00:14:53 +03:00
Egor 7d204684e1 Update install_bot.sh 2025-10-04 00:11:19 +03:00
Egor 08f47694ee Merge pull request #692 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup-mc8p92
Fix PostgreSQL sequence sync query
2025-10-03 23:55:59 +03:00
Egor e61977a368 Fix PostgreSQL sequence sync query 2025-10-03 23:55:43 +03:00
Egor 72ae83e6ef Merge pull request #691 from Fr1ngg/revert-690-bedolaga/fix-automigration-error-at-bot-startup
Revert "Fix PostgreSQL sequence sync for newer versions"
2025-10-03 23:55:19 +03:00
Egor 80aa0c2443 Revert "Fix PostgreSQL sequence sync for newer versions" 2025-10-03 23:55:09 +03:00
Egor 9e0eb3fd4a Merge pull request #690 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup
Fix PostgreSQL sequence sync for newer versions
2025-10-03 23:54:29 +03:00
Egor 6fe4bad240 Fix PostgreSQL sequence sync for newer versions 2025-10-03 23:54:11 +03:00
Egor 859089e91d Merge pull request #689 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration-hr68zi
Handle users sequence desync after backup restore
2025-10-03 23:36:04 +03:00
Egor c4fa25321e Handle users sequence desync after backup restore 2025-10-03 23:35:47 +03:00
Egor 96a6f26b1e Merge pull request #688 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url-w44me1
Handle missing RemnaWave configuration in subscription service
2025-10-03 22:12:59 +03:00
Egor 3f0d125207 Handle missing RemnaWave configuration in subscription service 2025-10-03 22:12:43 +03:00
Egor cb5821fffc Merge pull request #687 from Fr1ngg/revert-686-bedolaga/fix-attributeerror-for-base_url
Revert "Handle missing RemnaWave API configuration gracefully"
2025-10-03 22:12:13 +03:00
Egor f79420a9b0 Revert "Handle missing RemnaWave API configuration gracefully" 2025-10-03 22:11:51 +03:00
Egor a7b4dc985f Merge pull request #686 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url
Handle missing RemnaWave API configuration gracefully
2025-10-03 22:10:50 +03:00
Egor 38562744ab Handle missing RemnaWave API configuration gracefully 2025-10-03 22:09:29 +03:00
Egor 3d413ced59 Merge pull request #685 from Fr1ngg/revert-684-bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Revert "Fix admin top-up notifications failing after webhook"
2025-10-03 22:01:44 +03:00
Egor 0002ce0a9a Revert "Fix admin top-up notifications failing after webhook" 2025-10-03 22:01:35 +03:00
Egor 140d3580e9 Merge pull request #684 from Fr1ngg/bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Fix admin top-up notifications failing after webhook
2025-10-03 21:57:26 +03:00
Egor cf5cb406d0 Fix admin top-up notifications failing after webhook 2025-10-03 21:57:07 +03:00
Egor 8c3f3d56c8 Merge pull request #681 from Legacyyy777/DEV
Правим мулен
2025-10-03 21:43:47 +03:00
Egor d33831dfe4 Delete .github/workflows/deploy.yml 2025-10-03 21:43:30 +03:00
Legacyyy777 b68f1256ca Добавлено логирование заголовков вебхука Mulen Pay для отладки и временно отключена проверка подписи. Обновлены сообщения логирования для улучшения диагностики. 2025-10-03 10:28:02 +05:00
Legacyyy777 3d3b019e60 Merge branch 'Fr1ngg:main' into DEV 2025-10-03 10:12:31 +05:00
Egor f1a801d693 Merge pull request #680 from Fr1ngg/dev2
Dev2
2025-10-03 00:35:27 +03:00
Egor de33e15ae0 Merge pull request #679 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration
Sync PostgreSQL sequences after restore
2025-10-03 00:29:39 +03:00
Egor abd87c2a48 Sync PostgreSQL sequences after restore 2025-10-03 00:29:11 +03:00
Egor f691e91560 Merge pull request #678 from Fr1ngg/main
w
2025-10-03 00:23:44 +03:00
Egor 7dafb0c29f Update README.md 2025-10-02 23:56:23 +03:00
Egor 5037d92e36 Update index.html 2025-10-02 23:15:53 +03:00
Egor d765e08017 Merge pull request #676 from Fr1ngg/bedolaga/document-manual-reverse-proxy-setup-for-caddy-and-nginx
docs: mount miniapp assets in caddy
2025-10-02 19:57:17 +03:00
Egor 4958613ad9 Update README.md 2025-10-02 19:56:59 +03:00
Egor 489667c847 Update README.md 2025-10-02 19:52:55 +03:00
Egor ccedfad51f docs: mount miniapp assets in caddy 2025-10-02 19:50:46 +03:00
Egor 841ac5cdb3 Update README.md 2025-10-02 19:13:40 +03:00
Egor a8b6202fe9 Merge pull request #675 from Fr1ngg/bedolaga/update-readme-for-install_bot.sh
docs: document installer usage
2025-10-02 19:12:02 +03:00
Egor 4708c67dcc docs: document installer usage 2025-10-02 19:11:34 +03:00
Egor f705b508a1 Merge pull request #674 from Fr1ngg/dev2
Dev2
2025-10-02 17:35:18 +03:00
Egor 6030fb08ec Update install_bot.sh 2025-10-02 17:32:17 +03:00
Egor a5b152e73a Merge pull request #673 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy-dzg7sf
Refactor install script for Caddy-only proxy support
2025-10-02 03:49:05 +03:00
Egor 087a57fc11 Refactor installer to focus on Caddy proxy 2025-10-02 03:48:49 +03:00
Egor 95e44f99a2 Merge pull request #672 from Fr1ngg/revert-671-bedolaga/remove-nginx-support-and-connect-to-caddy
Revert "Refine installer Caddy integration"
2025-10-02 03:48:12 +03:00
Egor b8bb2acd64 Revert "Refine installer Caddy integration" 2025-10-02 03:48:03 +03:00
Egor eb3e806e0f Merge pull request #671 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy
Refine installer Caddy integration
2025-10-02 03:44:40 +03:00
Egor 33911aca93 Update installer to rely solely on Caddy 2025-10-02 03:44:22 +03:00
Egor 63c6e64cbe Merge pull request #655 from Fr1ngg/bedolaga/add-subscription-link-redirect-for-desktop-289875
Add desktop redirect fallback for Happ subscription links
2025-10-02 03:40:49 +03:00
Egor 358c8aed3b Merge branch 'main' into bedolaga/add-subscription-link-redirect-for-desktop-289875 2025-10-02 03:40:18 +03:00
Egor 4ac994454b Merge pull request #670 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-bnh3fm
Allow optional RemnaWave settings for initial startup
2025-10-02 03:14:14 +03:00
Egor 5d6f080663 Allow missing RemnaWave settings on first start 2025-10-02 03:13:31 +03:00
Egor 33f0e3b9ba Merge pull request #669 from Fr1ngg/revert-668-bedolaga/fix-bot-startup-validation-errors
Revert "Allow optional settings to initialize without env overrides"
2025-10-02 03:13:01 +03:00
Egor ef809de9a2 Revert "Allow optional settings to initialize without env overrides" 2025-10-02 03:12:50 +03:00
Egor 628c53513c Merge pull request #668 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Allow optional settings to initialize without env overrides
2025-10-02 02:10:50 +03:00
Egor 876362cd80 Make optional env settings optional 2025-10-02 02:10:22 +03:00
Egor 1a9efd65a1 Update install_bot.sh 2025-10-02 02:02:21 +03:00
Egor 121918c33a Merge pull request #667 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh-xyoa0f
Improve install bot state persistence and path resolution
2025-10-02 00:45:11 +03:00
Egor b46c3bd142 Improve install state handling in install_bot 2025-10-02 00:44:53 +03:00
Egor 566b563626 Merge pull request #666 from Fr1ngg/revert-665-bedolaga/remove-installation-check-in-install_bot.sh
Revert "Handle missing installation state in install_bot.sh"
2025-10-02 00:44:16 +03:00
Egor 397af1cc86 Revert "Handle missing installation state in install_bot.sh" 2025-10-02 00:44:07 +03:00
Egor bdfa2a3039 Merge pull request #665 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh
Handle missing installation state in install_bot.sh
2025-10-02 00:32:31 +03:00
Egor 38001410b8 Handle missing bot install state 2025-10-02 00:31:54 +03:00
Egor c2693fc46a Merge pull request #663 from Fr1ngg/bedolaga/fix-install_bot.sh-to-append-domains
Preserve existing Caddyfile entries when updating webhooks
2025-10-01 23:31:08 +03:00
Egor 9db84af7a9 Preserve existing Caddyfile entries when updating webhooks 2025-10-01 23:25:16 +03:00
Egor 0172138084 Merge pull request #662 from Fr1ngg/bedolaga/fix-script-not-removing-created-network
Add docker network cleanup option to proxy menu
2025-10-01 23:20:05 +03:00
Egor 0e913ec78b Add docker network cleanup option to proxy menu 2025-10-01 23:11:06 +03:00
Egor 6a5d0181b0 Update install_bot.sh 2025-10-01 23:07:05 +03:00
Egor 7684ed382e Update install_bot.sh 2025-10-01 21:55:46 +03:00
Egor 0d47acf044 Merge pull request #661 from Fr1ngg/bedolaga/create-auto-installer-for-bot
Add interactive installation script for the bot
2025-10-01 21:47:15 +03:00
Egor 2d09b10f36 Add interactive installation and monitoring script 2025-10-01 21:46:58 +03:00
Egor 77c99ad33e Merge pull request #660 from Fr1ngg/revert-659-bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Revert "Add automated installer and monitoring script"
2025-10-01 21:33:19 +03:00
Egor a41531944c Revert "Add automated installer and monitoring script" 2025-10-01 21:33:11 +03:00
Egor 9b2d9406d3 Merge pull request #659 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Add automated installer and monitoring script
2025-10-01 21:25:42 +03:00
Egor 1776a5b424 Add automated installer and monitoring script 2025-10-01 21:24:00 +03:00
Egor 419b921487 Merge pull request #658 from Fr1ngg/revert-657-bedolaga/create-auto-installer-for-bot-monitoring
Revert "Add automated installation and monitoring script"
2025-10-01 21:23:20 +03:00
Egor c1f7404912 Revert "Add automated installation and monitoring script" 2025-10-01 21:23:11 +03:00
Egor 1c07f40d43 Merge pull request #657 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring
Add automated installation and monitoring script
2025-10-01 21:16:43 +03:00
Egor d2e6a42eb8 Add automated installer and monitoring script 2025-10-01 21:16:26 +03:00
Egor 5a7960bb14 Merge pull request #656 from Fr1ngg/main
w
2025-10-01 21:03:53 +03:00
Egor 27d48f68b4 Update index.html 2025-10-01 20:27:54 +03:00
Egor 9465c6cba3 Create index.html 2025-10-01 20:25:50 +03:00
Egor 3088be1dde Add desktop redirect fallback for Happ subscription links 2025-10-01 19:46:15 +03:00
Egor 845a8175fa Merge pull request #652 from Fr1ngg/revert-651-bedolaga/d
Revert "Fix desktop handling of happ subscription links"
2025-10-01 19:34:46 +03:00
Egor af27fb570c Revert "Fix desktop handling of happ subscription links" 2025-10-01 19:34:38 +03:00
Egor 181bb137fb Merge pull request #651 from Fr1ngg/bedolaga/d
Fix desktop handling of happ subscription links
2025-10-01 19:29:54 +03:00
Egor 220646c7ac Fix desktop handling of happ subscription links 2025-10-01 19:29:30 +03:00
Egor 4513deb2a1 Add files via upload 2025-10-01 07:11:06 +03:00
Egor e1001156b4 Update Dockerfile 2025-10-01 07:05:17 +03:00
Egor ba2c534622 Update Dockerfile 2025-10-01 07:05:02 +03:00
Egor 72f06a784e Update docker-registry.yml 2025-10-01 07:04:44 +03:00
Egor 04e862b6bd Update docker-hub.yml 2025-10-01 07:04:31 +03:00
Egor 7f4af89d60 Update miniapp-setup.md 2025-10-01 06:43:37 +03:00
Egor f68bb699eb Update index.html 2025-10-01 06:32:56 +03:00
Egor c1d19e2ade Merge pull request #648 from Fr1ngg/dev2
Миниапка с подпиской
2025-10-01 06:14:01 +03:00
Egor e4c7e8b27f Merge pull request #647 from Fr1ngg/bedolaga/localize-subscription-button-to-english-w0zeno
Adjust purchase button localization and navigation
2025-10-01 06:10:51 +03:00
Egor c63c483b9b Adjust purchase button localization and navigation 2025-10-01 06:10:37 +03:00
Egor 4b04696feb Merge pull request #646 from Fr1ngg/revert-645-bedolaga/localize-subscription-button-to-english
Revert "Adjust miniapp purchase button behavior"
2025-10-01 06:10:09 +03:00
Egor 9ffbba7259 Revert "Adjust miniapp purchase button behavior" 2025-10-01 06:10:02 +03:00
Egor cc0b927388 Merge pull request #645 from Fr1ngg/bedolaga/localize-subscription-button-to-english
Adjust miniapp purchase button behavior
2025-10-01 06:08:34 +03:00
Egor cbcf2d55da Adjust miniapp purchase button behavior 2025-10-01 06:08:18 +03:00
Egor f7c70f60e1 Merge pull request #644 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp-izs6w9
Add configurable purchase link button to mini app error state
2025-10-01 06:01:30 +03:00
Egor 7c96b0f71c Add purchase link support to mini app error state 2025-10-01 06:01:12 +03:00
Egor 2002032be3 Merge pull request #643 from Fr1ngg/revert-642-bedolaga/add-subscription-purchase-button-in-miniapp
Revert "Add configurable purchase link to the mini app"
2025-10-01 06:00:40 +03:00
Egor ed6ff293ea Revert "Add configurable purchase link to the mini app" 2025-10-01 06:00:29 +03:00
Egor 65e66353b2 Merge pull request #642 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp
Add configurable purchase link to the mini app
2025-10-01 05:57:08 +03:00
Egor 4fa87804d9 Add miniapp purchase link support 2025-10-01 05:56:51 +03:00
Egor 17587a9b30 Update miniapp-setup.md 2025-10-01 05:53:53 +03:00
Egor 37c552c705 Merge pull request #641 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding-etzom5
Open Happ CryptoLink subscriptions externally
2025-10-01 05:42:46 +03:00
Egor 95c6e72f1b fix: open happ cryptolink externally 2025-10-01 05:42:32 +03:00
Egor 4fa49e6f2e Merge pull request #640 from Fr1ngg/revert-639-bedolaga/remove-happ_cryptolink_redirect_template-binding
Revert "Open HAPP cryptolink from miniapp externally"
2025-10-01 05:41:48 +03:00
Egor 3aaa3d0671 Revert "Open HAPP cryptolink from miniapp externally" 2025-10-01 05:41:40 +03:00
Egor 9c013f19c4 Merge pull request #639 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding
Open HAPP cryptolink from miniapp externally
2025-10-01 05:39:33 +03:00
Egor c7b6929dec Open HAPP cryptolink in external browser 2025-10-01 05:39:17 +03:00
Egor 4dca932ec2 Merge pull request #638 from Fr1ngg/revert-637-bedolaga/fix-hap_cryptolink-redirect-link
Revert "Fix miniapp connect button to open Happ CryptoLink directly"
2025-10-01 05:36:38 +03:00
Egor 997fcf8ec7 Revert "Fix miniapp connect button to open Happ CryptoLink directly" 2025-10-01 05:35:43 +03:00
Egor eb8c8ec4ce Merge pull request #637 from Fr1ngg/bedolaga/fix-hap_cryptolink-redirect-link
Fix miniapp connect button to open Happ CryptoLink directly
2025-10-01 05:34:04 +03:00
Egor f4ed61c4ca Adjust miniapp connect link handling 2025-10-01 05:33:30 +03:00
Egor 70d3434952 Merge pull request #636 from Fr1ngg/revert-635-revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"""
2025-10-01 05:27:04 +03:00
Egor 450d75227b Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""" 2025-10-01 05:26:48 +03:00
Egor 6e61980fa9 Merge pull request #635 from Fr1ngg/revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""
2025-10-01 05:25:02 +03:00
Egor 777e243bcd Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"" 2025-10-01 05:24:52 +03:00
Egor 46d9c25968 Merge pull request #634 from Fr1ngg/revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Add configurable purchase link and direct Happ flow in miniapp"
2025-10-01 05:24:39 +03:00
Egor e26223a7d8 Revert "Add configurable purchase link and direct Happ flow in miniapp" 2025-10-01 05:24:28 +03:00
Egor 2098165229 Delete miniapp/index2.html 2025-10-01 05:19:00 +03:00
Egor c7e9d84585 Merge pull request #633 from Fr1ngg/bedolaga/remove-redirect-to-happ_cryptolink
Add configurable purchase link and direct Happ flow in miniapp
2025-10-01 05:17:29 +03:00
Egor 8a92d5e5a9 Add purchase link support and adjust miniapp connect flow 2025-10-01 05:17:12 +03:00
Egor 8b8f187cb3 Update index.html 2025-10-01 04:42:40 +03:00
Egor 02e3faf194 Create index2.html 2025-10-01 04:25:14 +03:00
Egor 7e8e05a0fc Merge pull request #631 from Fr1ngg/bedolaga/add-service-name-configuration-option-d3ekaj
Add configurable miniapp branding
2025-10-01 04:03:32 +03:00
Egor c3ca12a8a5 Update config.py 2025-10-01 04:03:14 +03:00
Egor d82b24927b Update README.md 2025-10-01 04:02:53 +03:00
Egor 7d9d05b1e8 Update .env.example 2025-10-01 04:02:33 +03:00
Egor cc96919672 Add configurable miniapp branding 2025-10-01 04:01:47 +03:00
Egor 1f0f5a4317 Merge pull request #630 from Fr1ngg/revert-629-bedolaga/add-service-name-configuration-option
Revert "feat: allow configuring miniapp branding"
2025-10-01 04:01:31 +03:00
Egor cd9218ec09 Revert "feat: allow configuring miniapp branding" 2025-10-01 04:01:22 +03:00
Egor 7256a0d1ab Merge pull request #629 from Fr1ngg/bedolaga/add-service-name-configuration-option
feat: allow configuring miniapp branding
2025-10-01 04:01:08 +03:00
Egor e2f5191844 Update config.py 2025-10-01 04:00:48 +03:00
Egor d964a2d53c Update README.md 2025-10-01 04:00:18 +03:00
Egor 7ace06834b Update .env.example 2025-10-01 03:59:49 +03:00
Egor 0a789d3bd1 feat: allow configuring miniapp branding 2025-10-01 03:58:34 +03:00
Egor 9c44c3ebd0 Merge pull request #628 from Fr1ngg/bedolaga/update-miniapp-index.html-template-mdwbtr
Enhance miniapp subscription details
2025-10-01 03:46:09 +03:00
Egor 67650666fc Enhance miniapp subscription details 2025-10-01 03:45:51 +03:00
Egor b5e343cfb0 Merge pull request #627 from Fr1ngg/revert-626-bedolaga/update-miniapp-index.html-template
Revert "Improve miniapp subscription metadata display"
2025-10-01 03:45:36 +03:00
Egor 68f72a88a4 Revert "Improve miniapp subscription metadata display" 2025-10-01 03:45:28 +03:00
Egor a225540c48 Merge pull request #626 from Fr1ngg/bedolaga/update-miniapp-index.html-template
Improve miniapp subscription metadata display
2025-10-01 03:44:30 +03:00
Egor 3fdc1fdeda Enhance miniapp subscription details 2025-10-01 03:44:05 +03:00
Egor 0748e9eed3 Merge pull request #625 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-waqu1h
Add localization and account data to miniapp
2025-10-01 03:17:27 +03:00
Egor f420e07a24 Add localization and account data to miniapp 2025-10-01 03:17:09 +03:00
Egor 885a56ecf4 Merge pull request #624 from Fr1ngg/revert-623-bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Revert "Enhance miniapp page with localization and account data"
2025-10-01 03:16:47 +03:00
Egor 4e4e80f3c4 Revert "Enhance miniapp page with localization and account data" 2025-10-01 03:16:38 +03:00
Egor 4942ac4c5e Merge pull request #623 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Enhance miniapp page with localization and account data
2025-10-01 03:14:58 +03:00
Egor 20f83ac751 feat: enhance miniapp UI with localization and account data 2025-10-01 03:14:40 +03:00
Egor 6b2b20cca2 Merge pull request #622 from Fr1ngg/revert-621-bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Revert "Add localization, balance, and server info to miniapp"
2025-10-01 03:13:44 +03:00
Egor 9db392b410 Revert "Add localization, balance, and server info to miniapp" 2025-10-01 03:13:34 +03:00
Egor 444e87d70e Merge pull request #621 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Add localization, balance, and server info to miniapp
2025-10-01 03:11:27 +03:00
Egor d8a6b6b08c Enhance miniapp with localization and account data 2025-10-01 03:11:09 +03:00
Egor 2658c298f0 Merge pull request #620 from Fr1ngg/revert-619-bedolaga/add-language-switch-and-subscription-redirect
Revert "Add localization and account data to mini app"
2025-10-01 03:10:44 +03:00
Egor 0ca4f9630a Revert "Add localization and account data to mini app" 2025-10-01 03:10:35 +03:00
Egor 718c9aba92 Merge pull request #619 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect
Add localization and account data to mini app
2025-10-01 03:05:52 +03:00
Egor 078f1f8878 Add localization and account info to mini app 2025-10-01 03:05:34 +03:00
Egor c76eeea020 Merge pull request #617 from Fr1ngg/bedolaga/integrate-miniapp-page-with-bot-api
Document miniapp deployment and reverse proxy setup
2025-10-01 02:32:57 +03:00
Egor f919368d0b Document miniapp deployment and reverse proxy setup 2025-10-01 02:32:36 +03:00
Egor ccb442fe44 Create index.html 2025-10-01 02:10:06 +03:00
Egor d8c0dd862b Merge pull request #616 from Fr1ngg/dev2
W
2025-10-01 02:08:39 +03:00
Egor 1ed418b176 Delete miniapp/index.html 2025-10-01 02:08:17 +03:00
Egor 59efd837cd Merge pull request #615 from Fr1ngg/revert-614-bedolaga/add-third-purchase-mode-for-subscriptions
Revert "Add tariff-based subscription mode"
2025-10-01 02:06:32 +03:00
Egor 60d23bedde Revert "Add tariff-based subscription mode" 2025-10-01 02:06:18 +03:00
Egor d08f74179f Merge pull request #614 from Fr1ngg/bedolaga/add-third-purchase-mode-for-subscriptions
Add tariff-based subscription mode
2025-10-01 02:05:00 +03:00
Egor 70ad084bcd Add tariff-aware subscription API handling 2025-10-01 02:04:20 +03:00
Egor 818ef1ccb0 Create index.html 2025-10-01 02:03:30 +03:00
Egor f6369f7b44 Merge pull request #613 from Fr1ngg/bedolaga/add-english-locale-support-14ehhu
Add English localization for subscription management flows
2025-10-01 01:23:19 +03:00
Egor 5f8295a588 Add English localization for subscription settings flows 2025-10-01 01:22:49 +03:00
Egor 5c51788dc3 Merge pull request #612 from Fr1ngg/revert-611-bedolaga/add-english-locale-support
Revert "Add English localization for subscription management interfaces"
2025-10-01 01:22:36 +03:00
Egor 24e6d57273 Revert "Add English localization for subscription management interfaces" 2025-10-01 01:22:28 +03:00
Egor be95d34122 Merge pull request #611 from Fr1ngg/bedolaga/add-english-locale-support
Add English localization for subscription management interfaces
2025-10-01 01:18:14 +03:00
Egor 359cdd106d Add English localization for subscription management menus 2025-10-01 01:17:43 +03:00
Egor 0bc5b10a78 Merge pull request #610 from Fr1ngg/bedolaga/add-english-localization-for-user-management
Add English localization for admin user management view
2025-10-01 01:04:24 +03:00
Egor efd4eda62a Add English localization for admin user view 2025-10-01 01:04:05 +03:00
Egor d4c169e6bb Merge pull request #609 from Fr1ngg/bedolaga/add-localization-to-admin-panel-submenu
Add English localization for admin support menus
2025-10-01 00:08:24 +03:00
Egor c12f3d443c Add English localization for admin support menus 2025-10-01 00:08:03 +03:00
Egor db7ecea006 Merge pull request #608 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue-xgkdfd
Handle additional MulenPay webhook signature formats
2025-09-30 23:57:35 +03:00
Egor 2b4136faa7 Handle additional MulenPay webhook signature formats 2025-09-30 23:57:18 +03:00
Egor ca09ce8cf4 Merge pull request #607 from Fr1ngg/revert-606-bedolaga/fix-payment-callback-signature-issue
Revert "Improve MulenPay webhook signature handling"
2025-09-30 23:57:02 +03:00
Egor 533087b333 Revert "Improve MulenPay webhook signature handling" 2025-09-30 23:56:54 +03:00
Egor 80afcbb737 Merge pull request #606 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue
Improve MulenPay webhook signature handling
2025-09-30 23:51:44 +03:00
Egor 845faf037f Improve MulenPay webhook signature handling 2025-09-30 23:51:29 +03:00
Egor 1f160ced02 Merge pull request #605 from Fr1ngg/bedolaga/add-english-translations-for-admin-panel
Add missing admin panel locale translations
2025-09-30 23:47:25 +03:00
Egor 07e4c07d6e Add admin panel translations for ru/en locales 2025-09-30 23:47:06 +03:00
Egor 78ab84b021 Merge pull request #604 from Fr1ngg/bedolaga/add-card-payment-option-to-paypalych
Support PayPalych card payments with configurable button labels
2025-09-30 23:42:36 +03:00
Egor db5551df23 Add customizable PayPalych payment buttons 2025-09-30 23:42:19 +03:00
Egor ee75395c31 Merge pull request #603 from Fr1ngg/bedolaga/add-missing-translations-for-admin-panel
Add missing admin translations for Russian and English locales
2025-09-30 22:16:22 +03:00
Egor 453bb75b7f Add missing admin translations for Russian and English 2025-09-30 22:15:57 +03:00
Egor 88d2c8325d Merge pull request #602 from Fr1ngg/bedolaga/add-notifications-for-unsubscribed-users
Add monitoring notification for trial users leaving the channel
2025-09-30 21:45:13 +03:00
Egor 8fe9a3dbe2 feat: notify trial users when leaving channel 2025-09-30 21:44:49 +03:00
Egor cf6c282b25 Merge pull request #600 from PEDZEO/main
fix /start moderator
2025-09-30 21:12:03 +03:00
Egor 773e525a6a Merge pull request #599 from SantaSpeen/main
[+] Adding support stock app-config.json
2025-09-30 21:08:44 +03:00
Egor a7d35d51b8 Merge pull request #601 from Fr1ngg/revert-596-bedolaga/fix-all-merge-conflicts
Revert "Resolve duplicate handlers and cleanup imports"
2025-09-30 21:07:46 +03:00
Egor 46eb93dabe Revert "Resolve duplicate handlers and cleanup imports" 2025-09-30 21:07:37 +03:00
Egor 43e63f864e Merge pull request #594 from Gy9vin/main
Расширенный фильтр пользователей
2025-09-30 20:56:45 +03:00
PEDZEO 8774b53570 fix /start moderator 2025-09-30 18:45:44 +03:00
SantaSpeen 2aaec2ca58 Merge pull request #1 from SantaSpeen/slave
Merge into main
2025-09-30 17:20:56 +03:00
SantaSpeen 5efe36878d [~] Update upstream 2025-09-30 17:20:08 +03:00
SantaSpeen b9861249b7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/handlers/subscription.py
2025-09-30 17:11:32 +03:00
Egor 2c9b585f4b Merge pull request #597 from Fr1ngg/dev2
fix
2025-09-30 16:51:54 +03:00
Egor dd5b36ff4a Merge pull request #596 from Fr1ngg/bedolaga/fix-all-merge-conflicts
Resolve duplicate handlers and cleanup imports
2025-09-30 16:41:09 +03:00
Egor 144a942a97 Resolve duplicate handlers and cleanup imports 2025-09-30 16:40:08 +03:00
SantaSpeen ff661d84ee [+] Update app-config.json: new get_apps_for_device 2025-09-30 14:46:55 +03:00
SantaSpeen ab1b8619f8 [+] Update get_apps_for_device: Allow app-config.json just from panel 2025-09-30 14:26:22 +03:00
gy9vin 0f621bfe5a Revert "Оживление виджета "Детальная статистика пользователей""
This reverts commit 400cc5a32e.
2025-09-30 12:42:17 +03:00
gy9vin c344f418c5 Расширение фильтров 2025-09-30 12:39:15 +03:00
gy9vin 400cc5a32e Оживление виджета "Детальная статистика пользователей" 2025-09-30 11:55:04 +03:00
Egor b6a383b5d8 Merge pull request #593 from Fr1ngg/dev2
Логи в боте
2025-09-30 04:37:25 +03:00
Egor a8215ddec5 Update system_logs.py 2025-09-30 04:34:09 +03:00
Egor 954c96ed16 Merge pull request #592 from Fr1ngg/bedolaga/add-log-section-to-admin-panel
Add system logs view to admin panel
2025-09-30 04:30:47 +03:00
Egor ed9a3613f5 Add system logs view to admin panel 2025-09-30 04:30:30 +03:00
Egor 29743351de Delete app/webapi/vpn-admin-dashboard.html 2025-09-30 03:52:57 +03:00
Egor fd897464f5 Update Dockerfile 2025-09-30 03:52:36 +03:00
Egor d9b0420202 Update docker-registry.yml 2025-09-30 03:52:24 +03:00
Egor 527d907035 Update docker-hub.yml 2025-09-30 03:52:11 +03:00
Egor 6c3270e6d7 Merge pull request #590 from Fr1ngg/dev2
Dev to main
2025-09-30 03:36:11 +03:00
Egor b7382eb7c3 Merge pull request #589 from Fr1ngg/bedolaga/update-bot-configuration-menu
Update admin bot configuration menu grouping
2025-09-30 03:32:04 +03:00
Egor 0cd7d45338 Refine bot configuration menu structure 2025-09-30 03:31:37 +03:00
Egor 23dcb9699c Merge pull request #588 from Fr1ngg/bedolaga/fix-duplicate-cryptolink-display
Remove duplicate Happ CryptoLink link display
2025-09-30 03:17:49 +03:00
Egor 9603952b2a Adjust Happ CryptoLink message formatting 2025-09-30 03:17:17 +03:00
Egor 299eeee9d3 Merge pull request #587 from Fr1ngg/bedolaga-xjizv4
Convert Happ cryptolink message to happ scheme
2025-09-30 03:11:48 +03:00
Egor 63c7e9c08b Adjust Happ cryptolink message formatting 2025-09-30 03:11:28 +03:00
Egor 42131457bf Merge pull request #586 from Fr1ngg/revert-585-bedolaga-uswi1l
Revert "feat: update Happ cryptolink subscription link handling"
2025-09-30 03:11:17 +03:00
Egor a7370fbe52 Revert "feat: update Happ cryptolink subscription link handling" 2025-09-30 03:11:08 +03:00
Egor 41f0a15cea Merge pull request #585 from Fr1ngg/bedolaga-uswi1l
feat: update Happ cryptolink subscription link handling
2025-09-30 03:08:59 +03:00
Egor dd92f37fc1 feat: adjust Happ cryptolink presentation 2025-09-30 03:08:42 +03:00
Egor 46d4d699dd Merge pull request #584 from Fr1ngg/revert-583-bedolaga-bsbzht
Revert "Update Happ CryptoLink message formatting"
2025-09-30 03:07:37 +03:00
Egor 76b594363b Revert "Update Happ CryptoLink message formatting" 2025-09-30 03:07:28 +03:00
Egor 107b18d642 Merge pull request #583 from Fr1ngg/bedolaga-bsbzht
Update Happ CryptoLink message formatting
2025-09-30 03:05:01 +03:00
Egor 9973491503 Update Happ CryptoLink message formatting 2025-09-30 03:04:26 +03:00
Egor 418081048e Merge pull request #582 from Fr1ngg/bedolaga/add-language-selection-toggle-in-bot-config
feat: allow disabling language selection
2025-09-30 02:51:53 +03:00
Egor f22838b99f feat: make language selection optional 2025-09-30 02:51:31 +03:00
Egor 9bdacea568 Update .env.example 2025-09-30 02:50:26 +03:00
Egor 6bec0ed0c8 Merge pull request #581 from Fr1ngg/bedolaga/add-language-selection-at-start
Add language selection workflow and menu option
2025-09-30 02:38:29 +03:00
Egor 4363955443 Add language selection workflow and menu option 2025-09-30 02:37:57 +03:00
Egor 47a272c7e8 Merge pull request #580 from Fr1ngg/bedolaga/fix-subscription-renewal-error
Prevent async session errors when extending subscriptions
2025-09-30 02:17:24 +03:00
Egor 07ce852917 Fix subscription extension notifications and bot init 2025-09-30 02:17:06 +03:00
Egor a6ada5c9e3 Merge pull request #579 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups-zboyk5
feat: expand admin promo notifications
2025-09-30 02:06:47 +03:00
Egor 90c5b1d003 feat: expand admin notifications for promo events 2025-09-30 02:06:31 +03:00
Egor 326aca31d7 Merge pull request #578 from Fr1ngg/revert-577-bedolaga/add-admin-notifications-for-promo-groups
Revert "Enhance admin notifications with promo group context"
2025-09-30 02:06:20 +03:00
Egor 4fd5ba863b Revert "Enhance admin notifications with promo group context" 2025-09-30 02:06:11 +03:00
Egor 4bd0b221e7 Merge pull request #577 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups
Enhance admin notifications with promo group context
2025-09-30 01:57:14 +03:00
Egor f47511f26a Enhance admin notifications with promo group context 2025-09-30 01:56:52 +03:00
Egor 2d409eaa1f Merge pull request #576 from Fr1ngg/bedolaga/update-subscription-status-to-icons
Move subscription status icons into server user buttons
2025-09-30 01:41:16 +03:00
Egor 9d42c4fb7d Adjust server user list status icons 2025-09-30 01:41:03 +03:00
Egor d3ced8f7e2 Merge pull request #575 from Fr1ngg/bedolaga/add-pagination-to-users-list-page
Add pagination to admin server users list
2025-09-30 01:34:24 +03:00
Egor a5715b2ca8 Add pagination to server users admin view 2025-09-30 01:34:07 +03:00
Egor c18dd604d8 Merge pull request #574 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-g9vyec
Fix server user list to account for JSON-based connections
2025-09-30 01:28:59 +03:00
Egor c2656f4ee6 Fix server users list by including JSON connections 2025-09-30 01:28:43 +03:00
Egor 18641d46d3 Merge pull request #573 from Fr1ngg/revert-572-bedolaga/fix-user-count-display-on-server-list-e72yq9
Revert "Fix server user list counts"
2025-09-30 01:28:28 +03:00
Egor 7ef3a08215 Revert "Fix server user list counts" 2025-09-30 01:28:20 +03:00
Egor 55f1a3b220 Merge pull request #572 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-e72yq9
Fix server user list counts
2025-09-30 01:26:51 +03:00
Egor e2f313e6ea Fix server user list counts 2025-09-30 01:26:37 +03:00
Egor 5f99478433 Merge pull request #571 from Fr1ngg/revert-570-bedolaga/fix-user-count-display-on-server-list-gt974l
Revert "Fix server user list to include JSON connections"
2025-09-30 01:26:12 +03:00
Egor 89f0a3663d Revert "Fix server user list to include JSON connections" 2025-09-30 01:26:03 +03:00
Egor e53dab87e3 Merge pull request #570 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-gt974l
Fix server user list to include JSON connections
2025-09-30 01:24:22 +03:00
Egor 6d85c179bb Fix server user list to include JSON connections 2025-09-30 01:24:05 +03:00
Egor 30dafcef49 Merge pull request #569 from Fr1ngg/revert-568-bedolaga/fix-user-count-display-on-server-list
Revert "Fix admin server user list query"
2025-09-30 01:23:47 +03:00
Egor 32d7a7394e Revert "Fix admin server user list query" 2025-09-30 01:23:36 +03:00
Egor 3cb4f27433 Merge pull request #568 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list
Fix admin server user list query
2025-09-30 01:22:06 +03:00
Egor 50692566d5 Fix server user list to use connected squads 2025-09-30 01:21:49 +03:00
Egor fbb2afd9e1 Merge pull request #567 from Fr1ngg/bedolaga/update-backup-and-restore-mechanism
Expand backup coverage to include configuration snapshots
2025-09-30 01:03:15 +03:00
Egor 3de951b153 Enhance backup coverage and configuration snapshots 2025-09-30 01:02:57 +03:00
Egor 1b86d467b5 Merge pull request #566 from Fr1ngg/bedolaga/investigate-subscription-cancellation-bug
Convert extended trials to paid subscriptions
2025-09-30 00:51:15 +03:00
Egor e4743ced9b Convert extended trials to paid subscriptions 2025-09-30 00:50:57 +03:00
Egor 2351056d56 Merge pull request #565 from Fr1ngg/bedolaga/fix-subscription-promotion-allocation
Fix total spent calculation for promo groups
2025-09-30 00:43:06 +03:00
Egor 748f9a2393 Fix total spent calculation for promo groups 2025-09-30 00:42:46 +03:00
Egor ada15065ea Merge pull request #564 from Fr1ngg/bedolaga/fix-user-statistics-in-admin-panel
Fix admin user subscription stats
2025-09-29 17:37:52 +03:00
Egor 53f1831f59 Fix admin user subscription stats 2025-09-29 17:37:20 +03:00
Egor 860ebf18b6 Merge pull request #563 from Fr1ngg/bedolaga/fix-promotion-group-mechanics-and-discounts
Fix base period discount rounding and summary formatting
2025-09-29 16:00:27 +03:00
Egor ae05ca08ea Round discounted subscription prices up and clean summary 2025-09-29 15:43:50 +03:00
Egor 88e8a56526 Merge pull request #562 from Fr1ngg/bedolaga/fix-transaction-history-logging-for-admin-actions
Log admin-triggered balance deductions in transaction history
2025-09-29 15:11:05 +03:00
Egor fb4d714441 Log admin balance deductions as transactions 2025-09-29 15:10:47 +03:00
Egor 2690f63a82 Merge pull request #561 from Fr1ngg/bedolaga/update-subscription-pricing-calculation
Fix admin subscription purchase cost calculation
2025-09-29 14:59:02 +03:00
Egor ec9855a0a9 Fix admin subscription price calculation 2025-09-29 14:57:47 +03:00
Egor 984ad8e448 Merge pull request #560 from Fr1ngg/bedolaga/add-traffic-reset-on-subscription-payment
Add configurable traffic reset on subscription payments
2025-09-29 14:41:06 +03:00
Egor ca81b32502 Add configurable traffic reset on payments 2025-09-29 14:40:36 +03:00
Egor 70f190501b Merge pull request #559 from Fr1ngg/bedolaga/fix-automation-migration-errors
Fix promo group migration defaults for addon discounts
2025-09-29 14:21:46 +03:00
Egor 432efdbb7c Fix promo group seeding for new databases 2025-09-29 14:21:23 +03:00
Egor 83a821a591 Merge pull request #558 from Fr1ngg/bedolaga/add-trial-and-subscription-days-summation
Add setting to roll remaining trial time into paid subscriptions
2025-09-29 14:02:33 +03:00
Egor 14bdfd4a1e Add configurable trial rollover for paid subscriptions 2025-09-29 14:02:14 +03:00
Egor 66ff2a38d3 Merge pull request #557 from Fr1ngg/bedolaga/update-subscription-link-handling
Respect hidden subscription link setting
2025-09-29 13:40:46 +03:00
Egor 9f7c9cad38 feat: respect hidden subscription link setting 2025-09-29 13:40:27 +03:00
Egor 2132226b15 Merge pull request #556 from Fr1ngg/revert-555-bedolaga/hide-subscription-link-in-notifications
Revert "Hide subscription link when configured"
2025-09-29 13:16:49 +03:00
Egor 849465b2e1 Revert "Hide subscription link when configured" 2025-09-29 13:16:38 +03:00
Egor 402b31625b Merge pull request #555 from Fr1ngg/bedolaga/hide-subscription-link-in-notifications
Hide subscription link when configured
2025-09-29 13:12:42 +03:00
Egor 5fc24dbfae Hide subscription link in success messages when configured 2025-09-29 13:12:24 +03:00
Egor 8ba65cc2fe Merge pull request #554 from Fr1ngg/main
w
2025-09-29 13:02:20 +03:00
Egor ee2bffe08e Merge pull request #550 from PEDZEO/feature/ticket-user-info
Исправил поведение кнопок
2025-09-29 12:58:48 +03:00
PEDZEO c4b5db9002 fix(validation): sanitize имён + HTML-escape только в /start 2025-09-29 02:17:19 +03:00
PEDZEO 1a78bdc194 Исправил поведение кнопок 2025-09-29 01:31:49 +03:00
Egor a3bb749a6a Merge pull request #549 from Fr1ngg/main
w
2025-09-28 22:39:25 +03:00
Egor 626f78b230 Merge pull request #548 from PEDZEO/feature/ticket-user-info
Feature/ticket user info
2025-09-28 22:21:47 +03:00
PEDZEO 5d44285c0a feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:47 +03:00
PEDZEO 2d8091ddcb feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:29 +03:00
Egor fa6a4698ab Merge pull request #546 from FireWookie/dev2
Update API
2025-09-28 16:41:40 +03:00
firewookie f40454313f add ads compaigns remote route 2025-09-28 14:21:53 +05:00
firewookie 8a256b1bc2 add broadcast service 2025-09-28 14:05:35 +05:00
firewookie 71ac7ea464 backup logic and add documentation to stats route 2025-09-28 14:04:17 +05:00
firewookie bbc9f93429 update api 2025-09-28 13:36:53 +05:00
firewookie 3ba9598b5e update api 2025-09-28 13:35:13 +05:00
firewookie ffa51986a8 env to json 2025-09-28 13:34:43 +05:00
Egor 7e20140a00 Merge pull request #544 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue-j3pfif
Add fallback for bootstrap API token authentication
2025-09-28 06:28:06 +03:00
Egor 50d080f54e Improve web API token authentication fallback 2025-09-28 06:27:38 +03:00
Egor 29829b1c8c Merge pull request #543 from Fr1ngg/revert-542-bedolaga/fix-bot-token-authorization-issue
Revert "Ensure bootstrap web API token works without migration"
2025-09-28 06:26:47 +03:00
Egor e84e707d2f Revert "Ensure bootstrap web API token works without migration" 2025-09-28 06:26:37 +03:00
Egor c12566ae53 Merge pull request #542 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue
Ensure bootstrap web API token works without migration
2025-09-28 06:24:31 +03:00
Egor e6976b5262 Handle bootstrap API token fallback 2025-09-28 06:24:13 +03:00
Egor 81cfdcf7ac Merge pull request #541 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-jxb0f9
Synchronize bootstrap web API token with config overrides
2025-09-28 06:16:38 +03:00
Egor 9c1ca5b748 Ensure default web API token stays in sync with settings 2025-09-28 06:16:13 +03:00
Egor a0d14f97f4 Merge pull request #540 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-mvwipi
Handle missing RemnaWave dependencies in web API
2025-09-28 06:03:25 +03:00
Egor 8fdc70fdc3 Handle optional RemnaWave dependencies in web API 2025-09-28 06:03:11 +03:00
Egor d93139a458 Merge pull request #539 from Fr1ngg/revert-538-bedolaga/add-admin-panel-functionality-to-bot-api
Revert "Handle optional RemnaWave dependencies in web API"
2025-09-28 06:02:51 +03:00
Egor b78dc12db9 Revert "Handle optional RemnaWave dependencies in web API" 2025-09-28 06:02:42 +03:00
Egor 2a02f589bf Merge pull request #538 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api
Handle optional RemnaWave dependencies in web API
2025-09-28 06:00:37 +03:00
Egor bcfa4f8400 Handle optional RemnaWave dependencies in web API 2025-09-28 06:00:18 +03:00
Egor 042dc900ef Merge pull request #537 from Fr1ngg/bedolaga/add-endpoint-information-to-redoc
Fix OpenAPI export for RemnaWave endpoints
2025-09-28 05:45:49 +03:00
Egor 6004607bf6 Update __init__.py 2025-09-28 05:45:41 +03:00
Egor 40e4b1ce7f Ensure RemnaWave routes are imported for OpenAPI 2025-09-28 05:45:09 +03:00
Egor a4c5a2295f Merge pull request #536 from Fr1ngg/bedolaga/fix-missing-remnawave-endpoints-in-docs
Fix RemnaWave API docs registration
2025-09-28 05:29:01 +03:00
Egor 3d9a75ff51 Fix RemnaWave API docs registration 2025-09-28 05:28:41 +03:00
Egor 1216a4dd9d Merge pull request #535 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints-6ae7or
Add RemnaWave API documentation to Swagger
2025-09-28 05:09:45 +03:00
Egor 4bc1c402e9 feat: document remnawave endpoints in swagger 2025-09-28 05:09:24 +03:00
Egor 42a758bd68 Merge pull request #534 from Fr1ngg/revert-533-bedolaga/fix-documentation-for-remnawave-endpoints
Revert "Fix RemnaWave routes visibility in docs"
2025-09-28 05:08:18 +03:00
Egor 07a0d0ca43 Revert "Fix RemnaWave routes visibility in docs" 2025-09-28 05:08:08 +03:00
Egor f53b04444b Merge pull request #533 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints
Fix RemnaWave routes visibility in docs
2025-09-28 05:06:32 +03:00
Egor 9877c00c92 Fix RemnaWave routes visibility in docs 2025-09-28 05:06:16 +03:00
Egor 14aa175193 Merge pull request #532 from Fr1ngg/revert-529-revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Revert "Add RemnaWave management endpoints to web API""
2025-09-28 04:57:19 +03:00
Egor 562766a637 Revert "Revert "Add RemnaWave management endpoints to web API"" 2025-09-28 04:57:10 +03:00
Egor 4616911189 Merge pull request #531 from Fr1ngg/revert-530-bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Revert "Add Remnawave component management API"
2025-09-28 04:56:43 +03:00
Egor 1e14a0919f Revert "Add Remnawave component management API" 2025-09-28 04:56:33 +03:00
Egor e1f0bd152c Merge pull request #530 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Add Remnawave component management API
2025-09-28 04:53:36 +03:00
Egor c10e34ad43 Add Remnawave component management API 2025-09-28 04:53:01 +03:00
Egor 714f742560 Merge pull request #529 from Fr1ngg/revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Add RemnaWave management endpoints to web API"
2025-09-28 04:51:51 +03:00
Egor dc5e440fe2 Revert "Add RemnaWave management endpoints to web API" 2025-09-28 04:51:42 +03:00
Egor d53873dc7f Merge pull request #527 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components
Add RemnaWave management endpoints to web API
2025-09-28 04:46:12 +03:00
Egor 10ab557d8d feat(webapi): add remnawave integration endpoints 2025-09-28 04:45:57 +03:00
Egor 81147d42a9 Merge pull request #526 from Fr1ngg/revert-525-bedolaga/-scalar-redoc-api-osbdld
Revert "Replace ReDoc docs with Scalar UI"
2025-09-28 04:30:48 +03:00
Egor 8d5e2037d3 Revert "Replace ReDoc docs with Scalar UI" 2025-09-28 04:30:38 +03:00
Egor 7f03e61b5f Merge pull request #525 from Fr1ngg/bedolaga/-scalar-redoc-api-osbdld
Replace ReDoc docs with Scalar UI
2025-09-28 04:28:00 +03:00
Egor 870ee2fb66 Replace ReDoc docs with Scalar UI 2025-09-28 04:27:43 +03:00
Egor a525079282 Merge pull request #524 from Fr1ngg/revert-523-bedolaga/-scalar-redoc-api
Revert "Replace ReDoc with Scalar API reference"
2025-09-28 04:27:06 +03:00
Egor 39aff2bc1b Revert "Replace ReDoc with Scalar API reference" 2025-09-28 04:26:57 +03:00
Egor b4dc9f1970 Merge pull request #523 from Fr1ngg/bedolaga/-scalar-redoc-api
Replace ReDoc with Scalar API reference
2025-09-28 04:24:32 +03:00
Egor 37ac3e7a81 Replace ReDoc with Scalar API reference 2025-09-28 04:24:13 +03:00
Egor e3e6428d2a Merge pull request #522 from Fr1ngg/bedolaga/enable-swagger-for-api-in-bot
docs: explain enabling swagger ui
2025-09-28 04:20:43 +03:00
Egor 3e3385d1eb docs: explain enabling swagger ui 2025-09-28 04:20:28 +03:00
Egor 0375e74227 Merge pull request #521 from Fr1ngg/revert-505-revert-504-bedolaga/-docs
Revert "Revert "Enable API key auth in web API docs""
2025-09-28 04:02:41 +03:00
Egor b6bdeeefdd Revert "Revert "Enable API key auth in web API docs"" 2025-09-28 04:02:28 +03:00
Egor 24b3660f7b Merge pull request #520 from Fr1ngg/bedolaga/fix-bot-startup-issue
Fix database URL initialization
2025-09-28 03:56:01 +03:00
Egor da2c30cd8d Fix database URL initialization 2025-09-28 03:55:43 +03:00
Egor 1fad95c0bb Merge pull request #519 from Fr1ngg/revert-518-bedolaga/fix-bot-startup-validation-errors-f8krj6
Revert "Allow bot to boot without RemnaWave API settings"
2025-09-28 03:50:14 +03:00
Egor 6a1b1a9a91 Revert "Allow bot to boot without RemnaWave API settings" 2025-09-28 03:50:06 +03:00
Egor 9abf0b6ebe Merge pull request #518 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-f8krj6
Allow bot to boot without RemnaWave API settings
2025-09-28 03:46:33 +03:00
Egor 34e90b335b Handle missing RemnaWave configuration 2025-09-28 03:46:12 +03:00
Egor e84c82f29d Merge pull request #517 from Fr1ngg/revert-506-bedolaga/fix-bot-startup-error-due-to-missing-database_url
Revert "Make DATABASE_URL optional for default setup"
2025-09-28 03:38:31 +03:00
Egor 1c929a1a70 Revert "Make DATABASE_URL optional for default setup" 2025-09-28 03:38:23 +03:00
Egor 1c8878e651 Merge pull request #516 from Fr1ngg/revert-507-bedolaga/fix-missing-database_url-in-settings
Revert "Fix default handling for DATABASE_URL setting"
2025-09-28 03:37:53 +03:00
Egor 84645d84f7 Revert "Fix default handling for DATABASE_URL setting" 2025-09-28 03:37:44 +03:00
Egor 0cc59501bc Merge pull request #515 from Fr1ngg/revert-510-bedolaga/fix-missing-database_url-in-settings-wqxv3w
Revert "Allow optional Remnawave integration settings"
2025-09-28 03:36:54 +03:00
Egor ef6aaa58ea Revert "Allow optional Remnawave integration settings" 2025-09-28 03:36:42 +03:00
Egor 942ce48241 Merge pull request #514 from Fr1ngg/revert-513-bedolaga/fix-bot-startup-validation-errors-2waops
Revert "Handle missing optional RemnaWave settings"
2025-09-28 03:36:19 +03:00
Egor 625ec43c34 Revert "Handle missing optional RemnaWave settings" 2025-09-28 03:36:10 +03:00
Egor f184303415 Merge pull request #513 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-2waops
Handle missing optional RemnaWave settings
2025-09-28 03:35:02 +03:00
Egor 84a17864cd Handle missing optional RemnaWave settings 2025-09-28 03:34:44 +03:00
Egor 1476247519 Merge pull request #512 from Fr1ngg/revert-511-bedolaga/fix-bot-startup-validation-errors
Revert "Fix missing defaults for optional Remnawave settings"
2025-09-28 03:34:05 +03:00
Egor 0ea95005ee Revert "Fix missing defaults for optional Remnawave settings" 2025-09-28 03:33:56 +03:00
Egor ba808f66da Merge pull request #511 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Fix missing defaults for optional Remnawave settings
2025-09-28 03:32:42 +03:00
Egor e8472a502f Make optional Remnawave settings nullable by default 2025-09-28 03:32:23 +03:00
Egor 7d552ab715 Merge pull request #510 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-wqxv3w
Allow optional Remnawave integration settings
2025-09-28 03:27:55 +03:00
Egor 00de3a41df Allow optional Remnawave integration settings 2025-09-28 03:27:25 +03:00
Egor 36f38187d9 Merge pull request #509 from Fr1ngg/revert-508-bedolaga/fix-missing-database_url-in-settings-gvqm03
Revert "Make Remnawave settings optional by default"
2025-09-28 03:27:12 +03:00
Egor dd68b2b788 Revert "Make Remnawave settings optional by default" 2025-09-28 03:27:02 +03:00
Egor 7a9d37c884 Merge pull request #508 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-gvqm03
Make Remnawave settings optional by default
2025-09-28 03:25:20 +03:00
Egor 6a904d4be2 Make Remnawave settings optional by default 2025-09-28 03:25:04 +03:00
Egor b5b8408055 Merge pull request #507 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings
Fix default handling for DATABASE_URL setting
2025-09-28 03:20:07 +03:00
Egor 86a3228579 Fix default handling for DATABASE_URL setting 2025-09-28 03:19:52 +03:00
Egor 560993c0f3 Merge pull request #506 from Fr1ngg/bedolaga/fix-bot-startup-error-due-to-missing-database_url
Make DATABASE_URL optional for default setup
2025-09-28 03:11:15 +03:00
Egor a3fe935149 Make DATABASE_URL optional in settings 2025-09-28 03:10:57 +03:00
Egor a75549aebf Merge pull request #505 from Fr1ngg/revert-504-bedolaga/-docs
Revert "Enable API key auth in web API docs"
2025-09-28 03:02:31 +03:00
Egor be3fc7aa09 Revert "Enable API key auth in web API docs" 2025-09-28 03:02:22 +03:00
Egor 6cbfc6d74b Merge pull request #504 from Fr1ngg/bedolaga/-docs
Enable API key auth in web API docs
2025-09-28 02:59:25 +03:00
Egor 7ff7a051d9 Enable API key auth in web API docs 2025-09-28 02:59:07 +03:00
Egor 9a2f156158 Merge pull request #503 from Fr1ngg/bedolaga/fix-err_empty_response-on-api-endpoint
Expose admin API port for docker deployments
2025-09-28 02:48:58 +03:00
Egor 38b058636c Expose admin API port in docker compose 2025-09-28 02:48:42 +03:00
Egor 32523f5dea Merge pull request #502 from Fr1ngg/bedolaga/fix-web-api-startup-error
Handle uvicorn startup flag regression for admin web API
2025-09-28 02:32:09 +03:00
Egor ff70e38dbc Fix web API startup wait for uvicorn 0.32 2025-09-28 02:31:56 +03:00
Egor 8110f52bc3 Merge pull request #501 from Fr1ngg/bedolaga/fix-bot-api-launch-issue-3qh95y
Fix FastAPI 204 delete endpoints to return empty responses
2025-09-28 02:25:43 +03:00
Egor a5a0176433 Fix 204 responses to avoid response bodies 2025-09-28 02:25:26 +03:00
Egor 2d960e3e30 Merge pull request #500 from Fr1ngg/revert-499-bedolaga/fix-bot-api-launch-issue
Revert "Fix 204 responses for web API deletes"
2025-09-28 02:25:10 +03:00
Egor b5fa978023 Revert "Fix 204 responses for web API deletes" 2025-09-28 02:25:01 +03:00
Egor 9cc49e9f66 Merge pull request #499 from Fr1ngg/bedolaga/fix-bot-api-launch-issue
Fix 204 responses for web API deletes
2025-09-28 02:24:17 +03:00
Egor dbf63e518f Fix 204 responses for web API deletes 2025-09-28 02:23:58 +03:00
Egor 3e218b6e6d Merge pull request #498 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-vi0z1h
Handle RemnaWave server removal and add connected users view
2025-09-28 02:08:25 +03:00
Egor 2f52f6b8de Remove missing RemnaWave servers and list connected users 2025-09-28 02:08:05 +03:00
Egor 5d377cf885 Merge pull request #497 from Fr1ngg/revert-496-bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Revert "Handle removed RemnaWave squads and expose server users"
2025-09-28 02:07:50 +03:00
Egor 62ecacc3cc Revert "Handle removed RemnaWave squads and expose server users" 2025-09-28 02:07:43 +03:00
Egor 4ab6d45e50 Merge pull request #496 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Handle removed RemnaWave squads and expose server users
2025-09-28 02:02:55 +03:00
Egor d1bed5e41a Handle removed RemnaWave squads and expose server users 2025-09-28 02:02:37 +03:00
Egor b907f884ba Merge pull request #495 from Fr1ngg/revert-494-bedolaga/fix-server-deletion-issue-in-subscriptions
Revert "Handle RemnaWave server removal and expose server user list"
2025-09-28 02:02:08 +03:00
Egor 83a76c928d Revert "Handle RemnaWave server removal and expose server user list" 2025-09-28 02:01:57 +03:00
Egor 90d3f3aef2 Merge pull request #494 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions
Handle RemnaWave server removal and expose server user list
2025-09-28 01:56:59 +03:00
Egor 263df606d2 Remove missing RemnaWave servers during sync and add user list view 2025-09-28 01:56:40 +03:00
Egor 2a38969e3d Merge pull request #493 from Fr1ngg/main
w
2025-09-28 01:50:53 +03:00
Egor 300feec18e Merge pull request #492 from Fr1ngg/dev3
Dev3
2025-09-28 01:38:14 +03:00
Egor 7c56452eb4 Merge pull request #491 from Fr1ngg/bedolaga/fix-payment-button-and-status-issues
Fix PayPalych SBP payments
2025-09-28 01:34:02 +03:00
Egor 548ec540f2 Fix PayPalych SBP payments 2025-09-28 01:33:38 +03:00
Egor bceba34cc3 Merge pull request #490 from Fr1ngg/main
w
2025-09-28 01:14:29 +03:00
Egor b07ce2be5c Merge pull request #487 from yazhog/main
Пропуск выбора стран при оформлении подписки если доступен только 1 сквад, фикс уведомлений при пополнении баланса
2025-09-28 01:13:50 +03:00
Egor 9472cf5e8c Merge pull request #484 from PEDZEO/feature/ticket-report
Добавлен отчет по тикетам
2025-09-28 01:12:51 +03:00
yazhog 82f34e8c6e Merge pull request #44 from yazhog/codex/update-payment-options-layout
Reorder subscription return button in top-up selection
2025-09-27 16:48:18 +03:00
yazhog 02b718dac5 Adjust top-up keyboard button order 2025-09-27 16:44:52 +03:00
yazhog a65a23acb2 Merge pull request #43 from yazhog/codex/refactor-admin-notification-subscription-handling
Avoid lazy subscription load in admin notifications
2025-09-27 15:58:11 +03:00
yazhog 12523e7515 Avoid lazy subscription load in admin notifications 2025-09-27 15:54:38 +03:00
yazhog d3497f924a Merge pull request #42 from yazhog/codex/skip-country-selection-if-one-available
Skip country selection step when only one option is available
2025-09-27 13:29:20 +03:00
yazhog 75faa54b85 Skip country selection when only one option 2025-09-27 13:10:03 +03:00
Legacyyy777 02a9ac9be5 Merge branch 'Fr1ngg:main' into DEV 2025-09-26 20:39:23 +05:00
Pedzeo d22f9aaf62 Добавлен отчет по тикетам 2025-09-26 17:14:00 +03:00
Egor 68f5e2ab80 Add files via upload 2025-09-26 15:17:29 +03:00
Egor 4e2b71cb85 Update Dockerfile 2025-09-26 05:42:14 +03:00
Egor e229a7ee65 Merge pull request #480 from Fr1ngg/bedolaga/update-api-bot-for-web-admin-integration
Document and type admin web API integration
2025-09-26 05:37:28 +03:00
Egor abf1f53218 Add web admin API schemas and docs 2025-09-26 05:37:08 +03:00
Egor 2859219f80 Merge pull request #479 from Fr1ngg/revert-478-revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert 478 revert 477 bedolaga/add api for bot integration vlrm04
2025-09-26 05:18:55 +03:00
Egor e71b7f4533 Revert "Revert "Add administrative web API and database support"" 2025-09-26 05:18:41 +03:00
Egor 5ba652fab6 Merge pull request #478 from Fr1ngg/revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert "Add administrative web API and database support"
2025-09-26 05:18:32 +03:00
Egor 0a3913a2aa Revert "Add administrative web API and database support" 2025-09-26 05:18:21 +03:00
Egor 118ef8a29f Merge pull request #477 from Fr1ngg/bedolaga/add-api-for-bot-integration-vlrm04
Add administrative web API and database support
2025-09-26 05:17:33 +03:00
Egor b5f7e06605 Add administrative web API and database support 2025-09-26 05:16:58 +03:00
Egor d258d27406 Merge pull request #476 from Fr1ngg/revert-475-revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert "Revert 474 revert 473 bedolaga/add api for bot integration"
2025-09-26 05:16:43 +03:00
Egor 554fb28cdb Revert "Revert 474 revert 473 bedolaga/add api for bot integration" 2025-09-26 05:16:36 +03:00
Egor 808901c38b Merge pull request #475 from Fr1ngg/revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert 474 revert 473 bedolaga/add api for bot integration
2025-09-26 05:11:38 +03:00
Egor 68508d8a86 Revert "Revert "Add web admin API for bot management"" 2025-09-26 05:11:13 +03:00
Egor 0daf8d7691 Merge pull request #474 from Fr1ngg/revert-473-bedolaga/add-api-for-bot-integration
Revert "Add web admin API for bot management"
2025-09-26 05:10:12 +03:00
Egor 5aeb2038c5 Revert "Add web admin API for bot management" 2025-09-26 05:09:59 +03:00
Egor d3a1060117 Merge pull request #473 from Fr1ngg/bedolaga/add-api-for-bot-integration
Add web admin API for bot management
2025-09-26 05:09:54 +03:00
Egor 4d22e99076 feat: add web admin api server 2025-09-26 05:09:26 +03:00
Egor 879607982e Update Dockerfile 2025-09-26 00:23:25 +03:00
Egor 5482b31fcc Update docker-registry.yml 2025-09-26 00:23:14 +03:00
Egor f5be0e6807 Update docker-hub.yml 2025-09-26 00:22:56 +03:00
Egor c3a60871c9 Merge pull request #444 from Fr1ngg/dev2
Тест платежек в настройках конфигурации бота + перенос обязательных переменных ремны из env
2025-09-25 23:47:48 +03:00
Egor 2628444866 Merge pull request #443 from Fr1ngg/bedolaga/fix-invalid-payload-error-during-star-balance-test
Allow admin Stars test invoices
2025-09-25 23:44:30 +03:00
Egor c1a7360c66 Allow admin Stars test invoices 2025-09-25 23:44:14 +03:00
Egor b8c2bdc9e9 Merge pull request #442 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-331od6
Add payment testing actions to admin bot configuration
2025-09-25 23:39:58 +03:00
Egor 573735ffa9 Add payment testing actions to admin bot configuration 2025-09-25 23:39:41 +03:00
Egor 9b3a9883b0 Merge pull request #441 from Fr1ngg/revert-440-bedolaga/add-payment-testing-options-in-admin-settings-l7liam
Revert "Add payment testing actions to admin bot configuration"
2025-09-25 23:39:25 +03:00
Egor 8cc54d376a Revert "Add payment testing actions to admin bot configuration" 2025-09-25 23:39:18 +03:00
Egor 26910dbbcc Merge pull request #440 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-l7liam
Add payment testing actions to admin bot configuration
2025-09-25 23:38:21 +03:00
Egor b131fea7d1 Add payment testing actions to admin bot configuration 2025-09-25 23:38:05 +03:00
Egor 84bc36d855 Merge pull request #439 from Fr1ngg/revert-438-bedolaga/add-payment-testing-options-in-admin-settings-5pai8v
Revert "Add payment testing actions to admin bot configuration"
2025-09-25 23:37:51 +03:00
Egor ea5bd22c28 Revert "Add payment testing actions to admin bot configuration" 2025-09-25 23:37:43 +03:00
Egor e9f1064d73 Merge pull request #438 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-5pai8v
Add payment testing actions to admin bot configuration
2025-09-25 23:36:24 +03:00
Egor f3cba203dd Add payment testing actions to admin bot configuration 2025-09-25 23:36:08 +03:00
Egor 0ff15850b7 Merge pull request #437 from Fr1ngg/revert-436-bedolaga/add-payment-testing-options-in-admin-settings
Revert "Add payment testing tools to admin configuration"
2025-09-25 23:35:47 +03:00
Egor 383563c399 Revert "Add payment testing tools to admin configuration" 2025-09-25 23:35:38 +03:00
Egor 40bbb54f56 Merge pull request #436 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings
Add payment testing tools to admin configuration
2025-09-25 23:32:35 +03:00
Egor a09eafb9c1 Add admin payment testing tools 2025-09-25 23:32:16 +03:00
Egor 98b15d5c82 Merge pull request #435 from Fr1ngg/bedolaga/move-remnawave-settings-to-admin-config
Make RemnaWave settings configurable via admin panel
2025-09-25 23:16:49 +03:00
Egor b5cb547fe2 Make RemnaWave settings configurable via admin panel 2025-09-25 23:16:31 +03:00
Egor a7af39fb34 Merge pull request #434 from Fr1ngg/dev2
Динамическая конфигурация бота в бд)()()()(
2025-09-25 22:09:40 +03:00
Egor 02fa7de016 Merge pull request #433 from Fr1ngg/bedolaga/reorganize-bot-admin-sections-and-submenus
Reorganize bot configuration categories
2025-09-25 21:20:08 +03:00
Egor b14ee9a6a4 Reorganize bot configuration categories 2025-09-25 21:19:42 +03:00
Egor be2181c4cb Merge pull request #432 from Fr1ngg/bedolaga/fix-bot-settings-editing-issues-zs6d4j
Fix admin config callbacks and add selectable options
2025-09-25 21:08:20 +03:00
Egor 6cd65812ac Fix admin config callbacks and add selectable options 2025-09-25 21:08:01 +03:00
Egor b282fd1525 Merge pull request #431 from Fr1ngg/revert-430-bedolaga/fix-bot-settings-editing-issues
Revert "Fix bot config callbacks and add selectable options"
2025-09-25 21:07:30 +03:00
Egor 7c38e672e3 Revert "Fix bot config callbacks and add selectable options" 2025-09-25 21:07:21 +03:00
Egor c2685476b0 Merge pull request #430 from Fr1ngg/bedolaga/fix-bot-settings-editing-issues
Fix bot config callbacks and add selectable options
2025-09-25 20:52:24 +03:00
Egor a80e2176de Fix bot config callbacks and add selectable options 2025-09-25 20:52:09 +03:00
Egor bf4de82442 Merge pull request #429 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling-d8gjen
Allow admins to update bot config via plain text input
2025-09-25 18:16:18 +03:00
Egor 22c736a41c Allow direct admin bot config updates via text input 2025-09-25 18:16:01 +03:00
Egor a1c5f9d987 Merge pull request #428 from Fr1ngg/revert-427-bedolaga/fix-bot-configuration-command-handling-9kzryd
Revert "Enable direct bot configuration edits from setting view"
2025-09-25 18:15:48 +03:00
Egor 1ca1df1403 Revert "Enable direct bot configuration edits from setting view" 2025-09-25 18:15:36 +03:00
Egor be3b554cde Merge pull request #427 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling-9kzryd
Enable direct bot configuration edits from setting view
2025-09-25 18:14:28 +03:00
Egor efc8f04836 Enable direct bot configuration edits from setting view 2025-09-25 18:14:12 +03:00
Egor 1cf7138430 Merge pull request #426 from Fr1ngg/revert-425-bedolaga/fix-bot-configuration-command-handling
Revert "Allow direct editing of bot config settings"
2025-09-25 18:13:56 +03:00
Egor 64c8145f1a Revert "Allow direct editing of bot config settings" 2025-09-25 18:13:47 +03:00
Egor dc62ad927d Merge pull request #425 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling
Allow direct editing of bot config settings
2025-09-25 18:13:13 +03:00
Egor 61188d8a97 Allow direct editing of bot config settings 2025-09-25 18:12:57 +03:00
Egor f92d5e996e Merge pull request #424 from Fr1ngg/revert-419-revert-418-bedolaga/improve-bot-settings-layout
Revert "Revert "Improve bot configuration admin menu grouping""
2025-09-25 18:04:14 +03:00
Egor 817fb82476 Revert "Revert "Improve bot configuration admin menu grouping"" 2025-09-25 18:04:04 +03:00
Egor 9635f9464e Merge pull request #423 from Fr1ngg/revert-422-bedolaga/improve-bot-settings-layout-yxarpo
Revert "Improve admin bot configuration menu grouping and labels"
2025-09-25 18:03:56 +03:00
Egor 33e0d58e4d Revert "Improve admin bot configuration menu grouping and labels" 2025-09-25 18:03:48 +03:00
Egor 5fb951328d Merge pull request #422 from Fr1ngg/bedolaga/improve-bot-settings-layout-yxarpo
Improve admin bot configuration menu grouping and labels
2025-09-25 18:00:50 +03:00
Egor 5f5255d107 Improve admin bot configuration menu grouping and labels 2025-09-25 18:00:26 +03:00
Egor 4bf7f83f1e Merge pull request #421 from Fr1ngg/revert-420-bedolaga/improve-bot-settings-layout-z8kptq
Revert "Improve admin bot configuration navigation clarity"
2025-09-25 17:59:03 +03:00
Egor cce6d99a74 Revert "Improve admin bot configuration navigation clarity" 2025-09-25 17:58:53 +03:00
Egor 4060425ac4 Merge pull request #420 from Fr1ngg/bedolaga/improve-bot-settings-layout-z8kptq
Improve admin bot configuration navigation clarity
2025-09-25 17:50:21 +03:00
Egor 5023bec388 Fix imports for bot config grouping 2025-09-25 17:50:07 +03:00
Egor e0d90874bd Merge pull request #419 from Fr1ngg/revert-418-bedolaga/improve-bot-settings-layout
Revert "Improve bot configuration admin menu grouping"
2025-09-25 17:49:47 +03:00
Egor dd62ba1efd Revert "Improve bot configuration admin menu grouping" 2025-09-25 17:49:37 +03:00
Egor ceb8191b43 Merge pull request #418 from Fr1ngg/bedolaga/improve-bot-settings-layout
Improve bot configuration admin menu grouping
2025-09-25 17:46:25 +03:00
Egor da2e55bb25 Improve bot configuration admin menu grouping 2025-09-25 17:46:09 +03:00
Legacyyy777 23f53dc5df Merge branch 'Fr1ngg:main' into DEV 2025-09-25 19:15:06 +05:00
Egor 8a3a990e39 Merge pull request #417 from Fr1ngg/revert-412-revert-411-bedolaga/move-env-settings-to-admin-panel
Revert "Revert "Add admin bot configuration management UI""
2025-09-25 17:11:32 +03:00
Egor e3cd2b0b92 Revert "Revert "Add admin bot configuration management UI"" 2025-09-25 17:11:15 +03:00
Egor 1777128590 Merge pull request #416 from Fr1ngg/revert-415-bedolaga/move-env-settings-to-admin-panel-zgsjdr
Revert "Add admin-configurable runtime settings"
2025-09-25 17:10:53 +03:00
Egor 04a5fe9d77 Revert "Add admin-configurable runtime settings" 2025-09-25 17:10:41 +03:00
Egor 595a4eb3a3 Merge pull request #415 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel-zgsjdr
Add admin-configurable runtime settings
2025-09-25 17:04:51 +03:00
Egor ede9259347 Import json in app settings service 2025-09-25 17:04:34 +03:00
Egor d87556fd9f Merge pull request #414 from Fr1ngg/revert-413-bedolaga/move-env-settings-to-admin-panel-qubb3n
Revert "Add admin-managed bot configuration settings"
2025-09-25 16:58:03 +03:00
Egor 198ce23625 Revert "Add admin-managed bot configuration settings" 2025-09-25 16:57:55 +03:00
Egor 1be83b7b48 Merge pull request #413 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel-qubb3n
Add admin-managed bot configuration settings
2025-09-25 16:56:27 +03:00
Egor 2b2b94936f Add admin-managed bot configuration settings 2025-09-25 16:55:45 +03:00
Egor 84df8f727e Merge pull request #412 from Fr1ngg/revert-411-bedolaga/move-env-settings-to-admin-panel
Revert "Add admin bot configuration management UI"
2025-09-25 16:42:39 +03:00
Egor 84aaaf6254 Revert "Add admin bot configuration management UI" 2025-09-25 16:42:30 +03:00
Egor ce867efaea Merge pull request #411 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel
Add admin bot configuration management UI
2025-09-25 16:39:57 +03:00
Egor 492d9f5170 Add admin UI for runtime bot configuration 2025-09-25 16:39:34 +03:00
Egor dcbbf7ba44 Merge pull request #410 from Fr1ngg/dev2
Деактивация тральной подписки в случае отписки от канала
2025-09-25 16:09:00 +03:00
Egor 04e7f818c3 Merge pull request #409 from Fr1ngg/bedolaga/improve-subscription-verification-service
Handle trial subscription status on channel membership changes
2025-09-25 16:05:47 +03:00
Egor 2713bdf08e Handle trial subscription status on channel membership changes 2025-09-25 16:05:30 +03:00
Egor b2bfaf0e81 Merge pull request #408 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users-9k5u2q
Check trial channel subscription status
2025-09-25 15:54:29 +03:00
Egor 25a4be076b Check trial channel subscription status 2025-09-25 15:54:11 +03:00
Egor cbd721a10f Merge pull request #407 from Fr1ngg/revert-406-bedolaga/update-subscription-service-for-trial-users-hese0j
Revert "Add trial channel subscription monitoring"
2025-09-25 15:54:00 +03:00
Egor 3ff8cb5e61 Revert "Add trial channel subscription monitoring" 2025-09-25 15:53:51 +03:00
Egor 6f144f64b1 Merge pull request #406 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users-hese0j
Add trial channel subscription monitoring
2025-09-25 15:51:31 +03:00
Egor 93200d1450 Add trial channel subscription monitoring 2025-09-25 15:51:13 +03:00
Egor 3f60533c50 Merge pull request #405 from Fr1ngg/revert-404-bedolaga/update-subscription-service-for-trial-users
Revert "Add trial subscription channel membership monitoring"
2025-09-25 15:50:59 +03:00
Egor 41ab0abac8 Revert "Add trial subscription channel membership monitoring" 2025-09-25 15:50:49 +03:00
Egor a5e4fb24ca Merge pull request #404 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users
Add trial subscription channel membership monitoring
2025-09-25 15:48:56 +03:00
Egor 6d2ce895e4 Add trial subscription channel membership monitoring 2025-09-25 15:48:40 +03:00
Egor f41a4197b6 Merge pull request #403 from Fr1ngg/dev2
Скидка на докупку доп услуг в промогруппе
2025-09-25 15:34:17 +03:00
Egor 200c728342 Merge pull request #402 from Fr1ngg/bedolaga/refactor-activation-status-toggle-in-discounts-kvwc17
Improve promo group discount UI and toggle handling
2025-09-25 15:30:46 +03:00
Egor d8571c4f3e Improve promo group discount UI and toggle handling 2025-09-25 15:30:30 +03:00
Egor 233ceaf400 Merge pull request #401 from Fr1ngg/revert-400-bedolaga/refactor-activation-status-toggle-in-discounts
Revert "Improve promo group addon discount toggle UX"
2025-09-25 15:30:10 +03:00
Egor e8bca6926a Revert "Improve promo group addon discount toggle UX" 2025-09-25 15:30:00 +03:00
Egor 9e8aecae4d Merge pull request #400 from Fr1ngg/bedolaga/refactor-activation-status-toggle-in-discounts
Improve promo group addon discount toggle UX
2025-09-25 15:27:18 +03:00
Egor e75b44695e Fix promo group discount summary formatting 2025-09-25 15:26:55 +03:00
Egor 18784b3ef7 Merge pull request #399 from Fr1ngg/bedolaga/fix-repeat-user-assignment-to-promo-group
Prevent repeated automatic promo group reassignment
2025-09-25 15:06:13 +03:00
Egor 98a241a301 Prevent repeated auto promo group assignments 2025-09-25 15:05:53 +03:00
Egor 70ee783a7a Merge pull request #398 from Fr1ngg/revert-397-bedolaga/fix-multiple-user-promo-group-assignment-b2wz1u
Revert "Track last auto promo group to avoid duplicate assignments"
2025-09-25 14:56:19 +03:00
Egor 00cd0bb5b0 Revert "Track last auto promo group to avoid duplicate assignments" 2025-09-25 14:56:08 +03:00
Egor 02b7365ba3 Merge pull request #397 from Fr1ngg/bedolaga/fix-multiple-user-promo-group-assignment-b2wz1u
Track last auto promo group to avoid duplicate assignments
2025-09-25 14:55:12 +03:00
Egor a6da530848 Track last auto promo group to avoid duplicate assignments 2025-09-25 14:54:46 +03:00
Egor f6e6af5e42 Merge pull request #395 from Fr1ngg/revert-394-bedolaga/fix-multiple-user-promo-group-assignment
Revert "Prevent auto promo group overrides after manual change"
2025-09-25 14:43:53 +03:00
Egor 6f834aeec0 Revert "Prevent auto promo group overrides after manual change" 2025-09-25 14:43:45 +03:00
Egor 8bfdde0d51 Merge pull request #394 from Fr1ngg/bedolaga/fix-multiple-user-promo-group-assignment
Prevent auto promo group overrides after manual change
2025-09-25 14:36:47 +03:00
Egor 137ac3c58c Respect manual promo group overrides 2025-09-25 14:36:26 +03:00
Egor d7a0d9f7a5 Merge pull request #393 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group-shqdqq
Fix server management button for promo groups with multiple servers
2025-09-25 14:23:47 +03:00
Egor 9b477a700d Ensure server management button respects promo group servers 2025-09-25 14:23:32 +03:00
Egor 77a9dcf5af Merge pull request #392 from Fr1ngg/revert-391-bedolaga/fix-server-switch-function-in-promo-group-aicbmk
Revert "Fix server management visibility for promo groups"
2025-09-25 14:22:45 +03:00
Egor 1c1f7ade78 Revert "Fix server management visibility for promo groups" 2025-09-25 14:22:36 +03:00
Egor 4aa593f8c6 Merge pull request #391 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group-aicbmk
Fix server management visibility for promo groups
2025-09-25 14:18:27 +03:00
Egor cefd39f7e9 Ensure server management button appears when multiple options exist 2025-09-25 14:18:11 +03:00
Egor cfeb8fd9a3 Merge pull request #390 from Fr1ngg/revert-389-bedolaga/fix-server-switch-function-in-promo-group
Revert "Fix server management visibility for promo groups"
2025-09-25 14:17:22 +03:00
Egor 241a053252 Revert "Fix server management visibility for promo groups" 2025-09-25 14:17:15 +03:00
Egor b53726276c Merge pull request #389 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group
Fix server management visibility for promo groups
2025-09-25 14:16:25 +03:00
Egor 7316e4aa48 Fix server management visibility for promo groups 2025-09-25 14:16:10 +03:00
Egor 889629e3c1 Merge pull request #388 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-1ead69
Fix addon discount calculations and server availability
2025-09-25 14:06:37 +03:00
Egor fbecf5bf7a Fix addon discount calculations and server availability 2025-09-25 14:06:18 +03:00
Egor faae87212e Merge pull request #387 from Fr1ngg/revert-381-bedolaga/fix-discount-logic-for-additional-services-9agrnv
Revert "Fix addon discounts for server/device/traffic purchases"
2025-09-25 14:06:04 +03:00
Egor bfa355e956 Revert "Fix addon discounts for server/device/traffic purchases" 2025-09-25 14:05:55 +03:00
Egor a2c0001f5a Merge pull request #381 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-9agrnv
Fix addon discounts for server/device/traffic purchases
2025-09-25 14:02:54 +03:00
Egor 5fa2339018 Merge pull request #386 from Fr1ngg/revert-385-bedolaga/fix-discount-logic-for-additional-services-hdjty5
Revert "Fix add-on discount handling across subscription flows"
2025-09-25 14:01:49 +03:00
Egor 9ba183b6a6 Revert "Fix add-on discount handling across subscription flows" 2025-09-25 14:01:40 +03:00
Egor 6d142a7f38 Merge pull request #385 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-hdjty5
Fix add-on discount handling across subscription flows
2025-09-25 14:00:59 +03:00
Egor 6f157e2923 Fix addon discount application and cleanup formatting 2025-09-25 14:00:41 +03:00
Egor dccb070376 Merge pull request #384 from Fr1ngg/revert-383-bedolaga/fix-discount-logic-for-additional-services-k1wn8a
Revert "Fix addon discount calculations for extra services"
2025-09-25 13:55:14 +03:00
Egor f0b70c9424 Revert "Fix addon discount calculations for extra services" 2025-09-25 13:55:06 +03:00
Egor 9a847ec6b6 Merge pull request #383 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-k1wn8a
Fix addon discount calculations for extra services
2025-09-25 13:52:54 +03:00
Egor 4a94d6a966 Fix addon discounts for servers, devices, and traffic 2025-09-25 13:52:26 +03:00
Egor ae654452e4 Merge pull request #382 from Fr1ngg/revert-380-bedolaga/fix-discount-logic-for-additional-services
Revert "Fix addon discounts for server/device/traffic purchases"
2025-09-25 13:51:56 +03:00
Egor 2e71fd89c9 Revert "Fix addon discounts for server/device/traffic purchases" 2025-09-25 13:51:46 +03:00
Egor caf497ed28 Merge pull request #380 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services
Fix addon discounts for server/device/traffic purchases
2025-09-25 13:49:29 +03:00
Egor bb5dd7176b Fix addon discount calculations and availability checks 2025-09-25 13:49:12 +03:00
Egor ac83a65d3b Fix addon discount calculations and availability checks 2025-09-25 13:49:02 +03:00
Egor 147cbfa625 Merge pull request #379 from Fr1ngg/revert-372-revert-371-bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Revert "Revert "Add addon discount toggle for promo groups""
2025-09-25 13:31:02 +03:00
Egor 497775c382 Revert "Revert "Add addon discount toggle for promo groups"" 2025-09-25 13:30:52 +03:00
Egor 3a9b8dc0f8 Merge pull request #378 from Fr1ngg/revert-377-bedolaga/add-discount-toggle-for-additional-services-wadtco
Revert "Add promo group add-on discount toggle"
2025-09-25 13:30:36 +03:00
Egor 0fd3452dd6 Revert "Add promo group add-on discount toggle" 2025-09-25 13:30:28 +03:00
Egor 1d5c277df0 Merge pull request #377 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-wadtco
Add promo group add-on discount toggle
2025-09-25 13:26:18 +03:00
Egor 6483efc099 Add promo group add-on discount toggle 2025-09-25 13:25:58 +03:00
Egor 149a9f599d Merge pull request #376 from Fr1ngg/revert-375-bedolaga/add-discount-toggle-for-additional-services-pqdau3
Revert "Add localization for promo group add-on discount toggle"
2025-09-25 13:24:29 +03:00
Egor 1bd55c4d80 Revert "Add localization for promo group add-on discount toggle" 2025-09-25 13:24:21 +03:00
Egor ef9abe8f91 Merge pull request #375 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-pqdau3
Add localization for promo group add-on discount toggle
2025-09-25 13:21:17 +03:00
Egor 461b820ef1 feat: localize promo group addon discount toggle 2025-09-25 13:18:40 +03:00
Egor 0535353124 Merge pull request #374 from Fr1ngg/revert-373-bedolaga/add-discount-toggle-for-additional-services-ghttwf
Revert "Add promo group add-on discount toggle"
2025-09-25 13:10:12 +03:00
Egor 19804607ac Revert "Add promo group add-on discount toggle" 2025-09-25 13:10:03 +03:00
Egor 272de0bd33 Merge pull request #373 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-ghttwf
Add promo group add-on discount toggle
2025-09-25 13:07:22 +03:00
Egor b53a54a237 feat: add toggle for promo group addon discounts 2025-09-25 13:07:03 +03:00
Egor e57c6d0ade Merge pull request #372 from Fr1ngg/revert-371-bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Revert "Add addon discount toggle for promo groups"
2025-09-25 13:06:02 +03:00
Egor 18556f54d3 Revert "Add addon discount toggle for promo groups" 2025-09-25 13:05:54 +03:00
Egor a5c081887e Merge pull request #371 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Add addon discount toggle for promo groups
2025-09-25 13:03:53 +03:00
Egor 80efd4ab86 Add addon discount toggle for promo groups 2025-09-25 13:03:31 +03:00
Egor 3df541689b Merge pull request #370 from Fr1ngg/revert-369-bedolaga/add-discount-toggle-for-additional-services-tx3ab5
Revert "feat: add promo group addon discount toggle"
2025-09-25 12:51:30 +03:00
Egor 06c90dc3ca Revert "feat: add promo group addon discount toggle" 2025-09-25 12:51:22 +03:00
Egor d3599a6a0c Merge pull request #369 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-tx3ab5
feat: add promo group addon discount toggle
2025-09-25 12:46:21 +03:00
Egor afab5f2515 fix: import addon discount helper in traffic switch 2025-09-25 12:46:04 +03:00
Egor 37e7e74d28 Merge pull request #368 from Fr1ngg/revert-367-bedolaga/add-discount-toggle-for-additional-services
Revert "Add promo group toggle for add-on discounts"
2025-09-25 12:45:38 +03:00
Egor 25eff6929b Revert "Add promo group toggle for add-on discounts" 2025-09-25 12:45:24 +03:00
Egor 72888ff02d Merge pull request #367 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services
Add promo group toggle for add-on discounts
2025-09-25 12:43:47 +03:00
Egor 9c572a2771 Add promo group option for add-on discounts 2025-09-25 12:43:13 +03:00
Egor 0f6d6256a2 Merge pull request #366 from Fr1ngg/dev2
Режим happ_cryptolink
2025-09-25 12:12:48 +03:00
Egor 2191e63803 Merge pull request #365 from Fr1ngg/bedolaga/fix-missing-localization-keys
Add missing Happ subscription localization keys
2025-09-25 12:09:21 +03:00
Egor 43d75dc3ec Add missing Happ subscription localization keys 2025-09-25 12:09:04 +03:00
Egor 59cae2fde6 Update README.md 2025-09-25 11:59:48 +03:00
Egor 4f4c9e5426 Update .env.example 2025-09-25 11:59:11 +03:00
Egor df03f16a9d Merge pull request #364 from Fr1ngg/bedolaga/add-direct-connection-button-for-happ_cryptolink-95birt
Add redirect button support for Happ cryptolink mode
2025-09-25 11:53:03 +03:00
Egor 57db753218 Support Happ redirect button for cryptolink mode 2025-09-25 11:52:47 +03:00
Egor 5d4a2b51ef Merge pull request #363 from Fr1ngg/revert-362-bedolaga/add-direct-connection-button-for-happ_cryptolink
Revert "Add optional Happ redirect button for cryptolink mode"
2025-09-25 11:52:20 +03:00
Egor 67c6185f8b Revert "Add optional Happ redirect button for cryptolink mode" 2025-09-25 11:52:11 +03:00
Egor e3d18f7a1d Merge pull request #362 from Fr1ngg/bedolaga/add-direct-connection-button-for-happ_cryptolink
Add optional Happ redirect button for cryptolink mode
2025-09-25 11:51:19 +03:00
Egor 4bcefaa7b8 Add Happ redirect support for cryptolink mode 2025-09-25 11:50:30 +03:00
Egor 9d9551c072 Merge pull request #360 from Fr1ngg/revert-359-bedolaga/fix-unsupported-url-protocol-error-6s98ne
Revert "Handle unsupported Happ cryptolink URLs in Telegram"
2025-09-25 11:39:42 +03:00
Egor 4e6bc5ae71 Revert "Handle unsupported Happ cryptolink URLs in Telegram" 2025-09-25 11:39:34 +03:00
Egor c353c69180 Merge pull request #359 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error-6s98ne
Handle unsupported Happ cryptolink URLs in Telegram
2025-09-25 11:38:34 +03:00
Egor 9c0e489f3b Fix Happ cryptolink button to avoid unsupported URL 2025-09-25 11:38:19 +03:00
Egor d4028a9d9f Merge pull request #358 from Fr1ngg/revert-357-bedolaga/fix-unsupported-url-protocol-error-8xd80s
Revert "Handle Happ cryptolink without unsupported Telegram URL"
2025-09-25 11:37:49 +03:00
Egor 25960c180f Revert "Handle Happ cryptolink without unsupported Telegram URL" 2025-09-25 11:37:40 +03:00
Egor 926bd0cc55 Merge pull request #357 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error-8xd80s
Handle Happ cryptolink without unsupported Telegram URL
2025-09-25 11:36:45 +03:00
Egor 0d714b4dff Handle Happ cryptolink without unsupported Telegram URL 2025-09-25 11:36:26 +03:00
Egor f5ccc1f103 Merge pull request #356 from Fr1ngg/revert-355-bedolaga/fix-unsupported-url-protocol-error
Revert "Handle non-http Happ subscription links"
2025-09-25 11:36:16 +03:00
Egor 565e6d963b Revert "Handle non-http Happ subscription links" 2025-09-25 11:36:07 +03:00
Egor c057d1e3d7 Merge pull request #355 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error
Handle non-http Happ subscription links
2025-09-25 11:34:55 +03:00
Egor 10c3151d9e Handle non-http Happ subscription links 2025-09-25 11:34:37 +03:00
Egor cbf88226b4 Merge pull request #354 from Fr1ngg/bedolaga/fix-issues-with-happ-connection-screen-qc79aw
Add Happ cryptolink keyboard with platform buttons
2025-09-25 11:23:22 +03:00
Egor 4ad53ca762 Add Happ cryptolink keyboard and update download options 2025-09-25 11:23:03 +03:00
Egor a2ace90898 Merge pull request #353 from Fr1ngg/revert-352-bedolaga/fix-issues-with-happ-connection-screen
Revert "Add Happ cryptolink keyboard and Mac download option"
2025-09-25 11:22:33 +03:00
Egor 814e38ef59 Revert "Add Happ cryptolink keyboard and Mac download option" 2025-09-25 11:22:23 +03:00
Egor 871e1ae675 Merge pull request #352 from Fr1ngg/bedolaga/fix-issues-with-happ-connection-screen
Add Happ cryptolink keyboard and Mac download option
2025-09-25 11:21:33 +03:00
Egor 67c973124a Add Happ cryptolink keyboard and Mac download option 2025-09-25 11:21:18 +03:00
Egor 42cfc3745d Merge pull request #351 from Fr1ngg/bedolaga/fix-window-issue-in-happ_cryptolink-mode
Add Happ cryptolink subscription translations
2025-09-25 11:09:47 +03:00
Egor ccb4dfa267 Add Happ cryptolink subscription translations 2025-09-25 11:09:31 +03:00
Egor 8a9606db97 Merge pull request #350 from Fr1ngg/revert-343-revert-342-bedolaga/fix-subscription-link-generation-issue
Revert "Revert "Handle Happ cryptolink buttons without unsupported URLs""
2025-09-25 11:04:22 +03:00
Egor c3846a998a Revert "Revert "Handle Happ cryptolink buttons without unsupported URLs"" 2025-09-25 11:04:12 +03:00
Egor 0bcaab3c50 Merge pull request #349 from Fr1ngg/revert-348-bedolaga/fix-subscription-link-generation-issue-nwaeuv
Revert "Add Happ cryptoLink proxy support"
2025-09-25 11:03:58 +03:00
Egor 6dc5150804 Revert "Add Happ cryptoLink proxy support" 2025-09-25 11:03:49 +03:00
Egor 7bba18673a Merge pull request #348 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-nwaeuv
Add Happ cryptoLink proxy support
2025-09-25 11:02:46 +03:00
Egor de8853bd7c Add Happ cryptoLink proxy support 2025-09-25 11:02:27 +03:00
Egor 6e938b2693 Merge pull request #347 from Fr1ngg/revert-346-bedolaga/fix-subscription-link-generation-issue-xh6pew
Revert "Handle Happ crypto links in subscription flows"
2025-09-25 11:01:50 +03:00
Egor cb41f9263f Revert "Handle Happ crypto links in subscription flows" 2025-09-25 11:01:41 +03:00
Egor fe3f837a58 Merge pull request #346 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-xh6pew
Handle Happ crypto links in subscription flows
2025-09-25 11:01:09 +03:00
Egor 65374e38b3 Handle Happ crypto links in subscription flows 2025-09-25 11:00:52 +03:00
Egor 24fd4ef842 Merge pull request #345 from Fr1ngg/revert-344-bedolaga/fix-subscription-link-generation-issue-ls1p2r
Revert "Hide Happ cryptolinks behind connect button"
2025-09-25 11:00:34 +03:00
Egor 5682971722 Revert "Hide Happ cryptolinks behind connect button" 2025-09-25 11:00:27 +03:00
Egor c286c44041 Merge pull request #344 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-ls1p2r
Hide Happ cryptolinks behind connect button
2025-09-25 10:59:21 +03:00
Egor 1dc3013376 Hide Happ cryptolinks behind connect button 2025-09-25 10:59:03 +03:00
Egor ea61734f3a Merge pull request #343 from Fr1ngg/revert-342-bedolaga/fix-subscription-link-generation-issue
Revert "Handle Happ cryptolink buttons without unsupported URLs"
2025-09-25 10:58:50 +03:00
Egor ec521b84e7 Revert "Handle Happ cryptolink buttons without unsupported URLs" 2025-09-25 10:58:24 +03:00
Egor 62189f8642 Merge pull request #342 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue
Handle Happ cryptolink buttons without unsupported URLs
2025-09-25 10:50:13 +03:00
Egor a8e1085884 Handle Happ cryptolink buttons without unsupported URLs 2025-09-25 10:49:54 +03:00
Egor 2bd76d118a Merge pull request #341 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-mode-a0mn2q
Document happ cryptolink mode in env example
2025-09-25 10:25:23 +03:00
Egor b258bd95bd Document happ cryptolink mode in env example 2025-09-25 10:25:06 +03:00
Egor ab566986c7 Merge pull request #340 from Fr1ngg/revert-339-bedolaga/add-happ_cryptolink-output-mode
Revert "Add Happ crypto link connection mode with download prompts"
2025-09-25 08:51:42 +03:00
Egor 033230f73e Revert "Add Happ crypto link connection mode with download prompts" 2025-09-25 08:51:34 +03:00
Egor 063d77928c Merge pull request #339 from Fr1ngg/bedolaga/add-happ_cryptolink-output-mode
Add Happ crypto link connection mode with download prompts
2025-09-25 08:50:42 +03:00
Egor ff71f4b637 Add Happ crypto link mode and download flow 2025-09-25 08:50:22 +03:00
Egor 88f4b2b2fa Merge pull request #338 from Fr1ngg/revert-337-bedolaga/add-happ_cryptolink-subscription-mode
Revert "Add Happ cryptoLink connect mode with download flow"
2025-09-25 04:33:43 +03:00
Egor b64bda64dc Revert "Add Happ cryptoLink connect mode with download flow" 2025-09-25 04:33:28 +03:00
Egor dfeea949dd Merge pull request #337 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-mode
Add Happ cryptoLink connect mode with download flow
2025-09-25 04:32:05 +03:00
Egor 54d798f358 Add Happ cryptoLink mode and download support 2025-09-25 04:31:44 +03:00
Egor 4546450587 Merge pull request #336 from Fr1ngg/revert-333-bedolaga/add-happ_cryptolink-subscription-output-mode
Revert "feat: add Happ crypto link mode and download flow"
2025-09-25 04:12:53 +03:00
Egor ec6b6e6b09 Revert "feat: add Happ crypto link mode and download flow" 2025-09-25 04:12:42 +03:00
Egor cb5d4df4f0 Merge pull request #335 from Fr1ngg/revert-334-bedolaga/implement-universal-migration-for-subscriptions
Revert "Add happ_crypto_link column handling to universal migration"
2025-09-25 04:12:34 +03:00
Egor 1a060759f9 Revert "Add happ_crypto_link column handling to universal migration" 2025-09-25 04:12:24 +03:00
Egor 44fc2fd771 Merge pull request #334 from Fr1ngg/bedolaga/implement-universal-migration-for-subscriptions
Add happ_crypto_link column handling to universal migration
2025-09-25 04:09:09 +03:00
Egor 92dbf3269b Add happ_crypto_link column handling to universal migration 2025-09-25 04:08:49 +03:00
Egor c6b4de05f3 Merge pull request #333 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-output-mode
feat: add Happ crypto link mode and download flow
2025-09-25 04:00:07 +03:00
Egor ae3c34c2fe chore: tidy migration imports 2025-09-25 03:59:03 +03:00
Egor 34c19ff041 Update Dockerfile 2025-09-24 22:53:55 +03:00
Egor 80c9857475 Update docker-registry.yml 2025-09-24 22:53:40 +03:00
Egor c147a6c010 Update docker-hub.yml 2025-09-24 22:53:21 +03:00
Egor 88a720543e Merge pull request #320 from Fr1ngg/2zxk2o-bedolaga/modify-welcome-message-for-new-users
Skip trial welcome message for campaign newcomers
2025-09-24 22:26:52 +03:00
Egor e780e3731d Skip trial welcome message for campaign newcomers 2025-09-24 22:26:14 +03:00
Egor 853cda603e Merge pull request #319 from Fr1ngg/bedolaga-ovk4pz
Add configurable MulenPay payment limits
2025-09-24 21:51:47 +03:00
Egor 5f8287f389 Add configurable MulenPay payment limits 2025-09-24 21:51:19 +03:00
Egor 8fa7cbc060 Merge pull request #318 from Fr1ngg/165bmh-bedolaga/fix-mulenpay-status-check-error
Handle long MulenPay status messages
2025-09-24 21:40:50 +03:00
Egor fe1b0fcac3 Handle long MulenPay status messages 2025-09-24 21:40:22 +03:00
Egor 6f9a1a907d Merge pull request #317 from Fr1ngg/dev5
Dev5
2025-09-24 21:12:08 +03:00
Egor 82bb72fc82 Merge pull request #316 from Fr1ngg/yd19lw-bedolaga/fix-paid_at-column-missing-error
Ensure mulenpay schema includes paid_at column
2025-09-24 21:09:46 +03:00
Egor 6812ff84b9 Ensure mulenpay schema includes paid_at column 2025-09-24 21:08:57 +03:00
Egor 3096985466 Merge pull request #315 from Fr1ngg/dev5
Dev5
2025-09-24 20:56:56 +03:00
Egor ce9e6d4379 Merge pull request #314 from Fr1ngg/ospgos-bedolaga/fix-missing-column-mulen_payment_id
Ensure mulenpay schema has mulen_payment_id column
2025-09-24 20:54:26 +03:00
Egor 1f376d4b5d Ensure mulenpay schema has mulen_payment_id column 2025-09-24 20:54:09 +03:00
Egor cfb830a694 Merge pull request #313 from Fr1ngg/dyb67m-bedolaga/split-ddl-into-separate-statements
Split server squad promo group DDL statements
2025-09-24 18:40:37 +03:00
Egor c072b4a932 Split promo group table creation statements 2025-09-24 18:40:24 +03:00
Egor 9fe67eaeb2 Merge pull request #312 from Fr1ngg/dev5
Фикс приветственного сообщения при переходе по ссылке рк
2025-09-24 18:05:20 +03:00
Egor 389501239c Merge pull request #311 from Fr1ngg/3xfosq-bedolaga/fix-bot-crash-with-welcome-message
Handle missing subscription URL in main menu keyboard
2025-09-24 18:00:26 +03:00
Egor 9512813b58 Handle missing subscription URL in main menu keyboard 2025-09-24 18:00:09 +03:00
Egor bebf636b4e Merge pull request #310 from Fr1ngg/dev5
Активация/деактивация серверов для определенных промогрупп
2025-09-24 17:34:55 +03:00
Egor 1db1acf44d Merge pull request #309 from Fr1ngg/3xafxc-bedolaga/add-country-settings-configuration
Add promo group access control for servers
2025-09-24 17:24:43 +03:00
Egor be58a1c04e Add promo group access control for servers 2025-09-24 17:24:24 +03:00
Egor fbdc98f8bd Merge pull request #308 from Fr1ngg/revert-307-zqf0tt-bedolaga/add-country-settings-configuration
Revert "Restrict admin server selection by promo group"
2025-09-24 17:23:27 +03:00
Egor ba79cf292f Revert "Restrict admin server selection by promo group" 2025-09-24 17:23:19 +03:00
Egor 93b870a139 Merge pull request #307 from Fr1ngg/zqf0tt-bedolaga/add-country-settings-configuration
Restrict admin server selection by promo group
2025-09-24 17:22:42 +03:00
Egor cfa50739d8 Restrict admin country management by promo groups 2025-09-24 17:22:12 +03:00
Egor 86eedf1985 Merge pull request #306 from Fr1ngg/revert-305-nuuguw-bedolaga/add-country-settings-configuration
Revert "Restrict server access by promo group"
2025-09-24 17:21:46 +03:00
Egor 1bc7a1c143 Revert "Restrict server access by promo group" 2025-09-24 17:21:37 +03:00
Egor d6241d31f8 Merge pull request #305 from Fr1ngg/nuuguw-bedolaga/add-country-settings-configuration
Restrict server access by promo group
2025-09-24 17:18:41 +03:00
Egor a6465b0b0b Tweak server edit menu promo group display 2025-09-24 17:18:12 +03:00
Egor d1b28b669f Merge pull request #304 from Fr1ngg/dev5
Расширение уведомлений + настройка
2025-09-24 09:51:45 +03:00
Egor 68452974f1 Merge pull request #303 from Fr1ngg/0qkjeg-bedolaga/add-logo-to-monitoring-notifications
Add logo support to monitoring notifications
2025-09-24 09:48:54 +03:00
Egor 09810d0b6c Add logo support to monitoring notifications 2025-09-24 09:48:35 +03:00
Egor 7358c4e3fa Merge pull request #302 from Fr1ngg/93hmyg-bedolaga/add-test-notification-sending-in-admin-panel
Add notification previews in monitoring settings
2025-09-24 09:36:29 +03:00
Egor 7380f4a15e Add notification previews in monitoring settings 2025-09-24 09:36:12 +03:00
Egor dbb11957ae Merge pull request #301 from Fr1ngg/bzdqp9-bedolaga/add-back-button-after-settings-update
Add back button after updating notification settings
2025-09-24 09:28:28 +03:00
Egor 151b72baff Add back button after updating notification settings 2025-09-24 09:28:14 +03:00
Egor d716e261b4 Merge pull request #300 from Fr1ngg/3boc4w-bedolaga/fix-bad-request-error-in-authmiddleware
Handle editing notification message without text
2025-09-24 09:20:52 +03:00
Egor 7ba21d058c Handle editing notification message without text 2025-09-24 09:20:27 +03:00
Egor bbc64f685e Merge pull request #299 from Fr1ngg/revert-298-07mey9-bedolaga/fix-bad-request-error-in-authmiddleware
Revert "Handle Telegram edit errors for admin notification settings"
2025-09-24 09:20:17 +03:00
Egor 2c28fda5e3 Revert "Handle Telegram edit errors for admin notification settings" 2025-09-24 09:20:08 +03:00
Egor 5a9ae02f61 Merge pull request #298 from Fr1ngg/07mey9-bedolaga/fix-bad-request-error-in-authmiddleware
Handle Telegram edit errors for admin notification settings
2025-09-24 09:18:33 +03:00
Egor da6d971de4 Handle empty text edit errors for admin notification settings 2025-09-24 09:18:11 +03:00
Egor b4e7dfd099 Merge pull request #297 from Fr1ngg/revert-296-cy9wh5-bedolaga/fix-bad-request-error-in-authmiddleware
Revert "Handle missing text when updating notification settings"
2025-09-24 09:17:56 +03:00
Egor 39577b31bd Revert "Handle missing text when updating notification settings" 2025-09-24 09:17:48 +03:00
Egor 6b039c1cb0 Merge pull request #296 from Fr1ngg/cy9wh5-bedolaga/fix-bad-request-error-in-authmiddleware
Handle missing text when updating notification settings
2025-09-24 09:16:12 +03:00
Egor 2cbff15837 Handle missing text when updating notification settings 2025-09-24 09:15:47 +03:00
Egor 921a36c94d Merge pull request #295 from Fr1ngg/5yuyol-bedolaga/fix-subscription-check-after-discount-edit
Allow admins to bypass channel subscription check
2025-09-24 09:09:26 +03:00
Egor 14412871bf Allow admins to bypass channel subscription check 2025-09-24 09:09:02 +03:00
Egor afed43571d Merge pull request #294 from Fr1ngg/6xcvey-bedolaga/fix-notification-editing-error
Handle business connection id when editing monitoring notifications
2025-09-24 09:02:49 +03:00
Egor 773dc11888 Fix business connection id handling in notification editor 2025-09-24 09:01:15 +03:00
Egor e0f6dd4097 Merge pull request #293 from Fr1ngg/revert-292-8tc2kv-bedolaga/fix-notification-editing-error
Revert "Fix notification edit message call"
2025-09-24 09:00:58 +03:00
Egor 711ad05983 Revert "Fix notification edit message call" 2025-09-24 09:00:47 +03:00
Egor 2d0a9f334b Merge pull request #292 from Fr1ngg/8tc2kv-bedolaga/fix-notification-editing-error
Fix notification edit message call
2025-09-24 08:58:48 +03:00
Egor 97996048ae Fix notification edit message call 2025-09-24 08:58:32 +03:00
Egor 66bd7f758e Merge pull request #291 from Fr1ngg/wosx55-bedolaga/fix-notification-settings-input-error
Fix admin notification value edits triggering unknown command response
2025-09-24 08:52:48 +03:00
Egor c51158bad1 Fix notification edit input blocked by unknown handler 2025-09-24 08:52:32 +03:00
Egor ccf881c054 Merge pull request #290 from Fr1ngg/revert-289-9mj3ph-bedolaga/fix-notification-settings-input-error
Revert "Fix admin notification editing state filter"
2025-09-24 08:51:53 +03:00
Egor 1f780a0ae8 Revert "Fix admin notification editing state filter" 2025-09-24 08:51:44 +03:00
Egor 17ac08af8f Merge pull request #289 from Fr1ngg/9mj3ph-bedolaga/fix-notification-settings-input-error
Fix admin notification editing state filter
2025-09-24 08:51:02 +03:00
Egor 214488cf80 Fix admin notification editing state filter 2025-09-24 08:49:58 +03:00
Egor c6c842adc9 Merge pull request #288 from Fr1ngg/revert-287-akwh56-bedolaga/fix-notification-settings-input-error
Revert "Limit unknown message handler to default state"
2025-09-24 08:49:46 +03:00
Egor 4cb23dc2b1 Revert "Limit unknown message handler to default state" 2025-09-24 08:49:36 +03:00
Egor 7c9285061a Merge pull request #287 from Fr1ngg/akwh56-bedolaga/fix-notification-settings-input-error
Limit unknown message handler to default state
2025-09-24 08:47:52 +03:00
Egor 0edfa973e4 Limit unknown message handler to default state 2025-09-24 08:47:33 +03:00
Egor 0be20d514e Merge pull request #286 from Fr1ngg/xm0o19-bedolaga/fix-subscription-reminder-errors
Handle unreachable Telegram users in monitoring notifications
2025-09-24 08:40:21 +03:00
Egor e414ae40d4 Handle unreachable users in monitoring notifications 2025-09-24 08:40:04 +03:00
Egor b54b2a2dc2 Merge pull request #285 from Fr1ngg/33x8fg-bedolaga/add-automated-user-notifications
Add monitoring settings shortcut to admin settings submenu
2025-09-24 08:29:54 +03:00
Egor f545c0d7ad Add monitoring settings shortcut to admin settings submenu 2025-09-24 08:29:39 +03:00
Egor 18142201bd Merge pull request #284 from Fr1ngg/revert-283-wmf5on-bedolaga/add-automated-user-notifications
Revert "Implement subscription follow-up notifications and admin configuration"
2025-09-24 08:29:10 +03:00
Egor 50320ba868 Revert "Implement subscription follow-up notifications and admin configuration" 2025-09-24 08:29:00 +03:00
Egor 12b05a7d0a Merge pull request #283 from Fr1ngg/wmf5on-bedolaga/add-automated-user-notifications
Implement subscription follow-up notifications and admin configuration
2025-09-24 08:27:17 +03:00
Egor 7e19a5a1ff Add monitoring settings entry to admin menus 2025-09-24 08:26:55 +03:00
Egor 096e3a1629 Merge pull request #282 from Fr1ngg/revert-281-qmxipx-bedolaga/add-automated-user-notifications
Revert "Add notification scheduling and admin controls"
2025-09-24 08:21:30 +03:00
Egor 51a5c4a65e Revert "Add notification scheduling and admin controls" 2025-09-24 08:21:22 +03:00
Egor e5472b4c67 Merge pull request #281 from Fr1ngg/qmxipx-bedolaga/add-automated-user-notifications
Add notification scheduling and admin controls
2025-09-24 08:15:41 +03:00
Egor ebf4c07142 Add notification scheduling and admin controls 2025-09-24 08:15:17 +03:00
Egor 4ab8aac38f Merge pull request #280 from Fr1ngg/revert-279-74ifk5-bedolaga/add-automated-user-notifications
Revert "Implement subscription follow-up notifications and admin configuration"
2025-09-24 08:14:42 +03:00
Egor 8d32b1463a Revert "Implement subscription follow-up notifications and admin configuration" 2025-09-24 08:14:33 +03:00
Egor ec09142445 Merge pull request #279 from Fr1ngg/74ifk5-bedolaga/add-automated-user-notifications
Implement subscription follow-up notifications and admin configuration
2025-09-24 08:11:08 +03:00
Egor 1e3c713c95 Add subscription follow-up notifications and admin controls 2025-09-24 08:10:50 +03:00
Egor 1747170ec3 Merge pull request #278 from Fr1ngg/revert-277-rbs1li-bedolaga/add-automated-user-notifications
Revert "Add automated inactivity and post-expiration notifications"
2025-09-24 08:10:19 +03:00
Egor 1e436189b7 Revert "Add automated inactivity and post-expiration notifications" 2025-09-24 08:10:11 +03:00
Egor a247485a38 Merge pull request #277 from Fr1ngg/rbs1li-bedolaga/add-automated-user-notifications
Add automated inactivity and post-expiration notifications
2025-09-24 08:06:20 +03:00
Egor b352cb2701 Add automated inactivity and post-expiration notifications 2025-09-24 08:05:50 +03:00
Egor b0b2c3f8d8 Merge pull request #276 from Fr1ngg/dev5
Отчеты (В топики тоже)
2025-09-24 07:40:07 +03:00
Egor aba98031f7 Merge pull request #275 from Fr1ngg/00n617-bedolaga/add-close-button-for-reports
Add close action for admin reports and move menu entry
2025-09-24 07:37:36 +03:00
Egor 66ec241c48 Add close action for admin reports and move menu entry 2025-09-24 07:37:21 +03:00
Egor 2ee80b41e0 Merge pull request #274 from Fr1ngg/cozprs-bedolaga/add-daily-summary-report-feature
Add scheduled admin reports and manual sending
2025-09-24 07:26:34 +03:00
Egor c1aa08b266 Add scheduled admin reports and manual sending 2025-09-24 07:26:18 +03:00
Egor 7b7bdc3d88 Merge pull request #273 from Fr1ngg/revert-272-4r6xny-bedolaga/add-daily-summary-report-feature
Revert "Add automated admin reports"
2025-09-24 07:25:53 +03:00
Egor 71588fda63 Revert "Add automated admin reports" 2025-09-24 07:25:44 +03:00
Egor d4b1b20016 Merge pull request #272 from Fr1ngg/4r6xny-bedolaga/add-daily-summary-report-feature
Add automated admin reports
2025-09-24 07:24:32 +03:00
Egor 952face2ae Add automated admin reports 2025-09-24 07:24:10 +03:00
Egor fa8c7bd460 Merge pull request #271 from Fr1ngg/dev5
Изменение в докупке доп услуг, если средств не хватает - предлагает пополнить баланс и способы
2025-09-24 06:53:22 +03:00
Egor de8c506618 Merge pull request #270 from Fr1ngg/6eudmz-bedolaga/update-insufficient-funds-notification
Enhance add-on top-up flow with preset amounts
2025-09-24 06:39:22 +03:00
Egor 18a7d179f8 Enhance add-on top-up flow with preset amounts 2025-09-24 06:39:06 +03:00
Egor 56c6e97874 Merge pull request #269 from Fr1ngg/dev5
Dev5
2025-09-24 06:29:03 +03:00
Egor b574c01264 Merge pull request #268 from Fr1ngg/hmojzu-bedolaga/add-promo-group-assignment-on-completion
Run promo group assignment after completing transactions
2025-09-24 06:28:31 +03:00
Egor 6d64ba8ffd Run promo group assignment on transaction completion 2025-09-24 06:28:11 +03:00
Egor 6d0a85b7be Merge pull request #267 from Fr1ngg/dev5
Dev5
2025-09-24 06:26:01 +03:00
Egor 33d5da9530 Merge pull request #266 from Fr1ngg/mbqvvx-bedolaga/fix-auto-assignment-logic-for-promo-groups
Allow auto promo reassignment for upgrades
2025-09-24 06:25:04 +03:00
Egor 2a92004fbf Allow auto promo reassignment for upgrades 2025-09-24 06:24:38 +03:00
Egor 97ec8594d9 Merge pull request #265 from Fr1ngg/dev5
Автовыдача промогрупп + доработанное редактирование промопогрупп
2025-09-24 06:17:41 +03:00
Egor 713a2cd924 Merge pull request #264 from Fr1ngg/vie7rm-bedolaga/add-auto-allocation-for-promo-groups
Add promo group auto assignment and improved editing
2025-09-24 06:13:07 +03:00
Egor 0d4fd3d6e9 Add promo group auto assignment and improved editing 2025-09-24 06:12:45 +03:00
Egor 7cc9d8dc18 Merge pull request #263 from Fr1ngg/revert-262-nus2xf-bedolaga/add-auto-assignment-feature-for-promo-groups
Revert "Add promo group auto assignment after top-ups"
2025-09-24 05:49:51 +03:00
Egor f896d59609 Revert "Add promo group auto assignment after top-ups" 2025-09-24 05:49:30 +03:00
Egor 707858900a Merge pull request #262 from Fr1ngg/nus2xf-bedolaga/add-auto-assignment-feature-for-promo-groups
Add promo group auto assignment after top-ups
2025-09-24 05:43:06 +03:00
Egor de0b361062 Add auto promo group assignment on top-up 2025-09-24 05:42:49 +03:00
Egor 111b038fec Merge pull request #261 from Fr1ngg/dev5
Скидка на дни периода для промогрупп
2025-09-24 05:12:18 +03:00
Egor a2f90d5de0 Merge pull request #260 from Fr1ngg/bedolaga-3h7w7d
feat: add period-based discounts for promo groups
2025-09-24 05:04:36 +03:00
Egor 084363b3d6 feat: add period-based discounts for promo groups 2025-09-24 05:04:19 +03:00
Egor 9c0dfa7aa3 Merge pull request #259 from Fr1ngg/dev5
external_link_miniapp для мониторинга серверов - (открывает ссылку во встроенном мини-приложении Telegram)
2025-09-24 04:29:09 +03:00
Egor c4bf18cf0f Merge pull request #258 from Fr1ngg/p1psk9-bedolaga/add-external_link_miniapp-to-server-monitoring-menu
Add mini app server status mode
2025-09-24 04:25:11 +03:00
Egor 9663eb4931 Add mini app server status mode 2025-09-24 04:24:50 +03:00
Egor da846df924 Merge pull request #257 from Fr1ngg/dev5
Фикс удаления юзера + бекапы/восстановление дополненное
2025-09-24 04:12:47 +03:00
Egor a5bb663f9e Merge pull request #256 from Fr1ngg/p280hy-bedolaga/fix-user-deletion-function
Fix admin user deletion and improve backups
2025-09-24 04:04:40 +03:00
Egor b234905860 Fix admin user deletion and improve backups 2025-09-24 04:04:20 +03:00
Egor f658cfcd03 Update .env.example 2025-09-24 03:55:54 +03:00
Egor 56297cde91 Merge pull request #255 from Fr1ngg/dev5
Dev5
2025-09-24 03:51:45 +03:00
Egor f51c74d09e Merge pull request #254 from Fr1ngg/xvtjb1-bedolaga/fix-database-session-management-in-process
Ensure Pal24 webhook closes DB session
2025-09-24 03:49:22 +03:00
Egor b06da932cd Ensure Pal24 webhook closes DB session 2025-09-24 03:49:01 +03:00
Egor f869b73bf5 Merge pull request #253 from Fr1ngg/o0107y-bedolaga/fix-premature-db-session-closure-in-webhook
Fix Pal24 webhook DB session handling
2025-09-24 03:32:10 +03:00
Egor 4f40cb8862 Fix Pal24 webhook DB session handling 2025-09-24 03:31:46 +03:00
Egor 161e9b575c Merge pull request #252 from Fr1ngg/dev5
PayPalych
2025-09-24 03:24:24 +03:00
Egor 42ccd3f3c1 Merge pull request #251 from Fr1ngg/flrwap-bedolaga/refactor-paypal-balance-top-up-process
Fix PayPalych webhook to reuse main event loop
2025-09-24 03:10:32 +03:00
Egor 5dcb37032e Fix PayPalych webhook to reuse main event loop 2025-09-24 03:08:33 +03:00
Egor e42bf2de73 Merge pull request #250 from Fr1ngg/jvzqka-bedolaga/integrate-paypalych-api-into-telegram-bot
Add PayPalych payment integration
2025-09-24 02:57:43 +03:00
Egor 4225404673 Add PayPalych payment integration 2025-09-24 02:56:51 +03:00
Egor cbfa388568 Merge pull request #249 from Fr1ngg/dev5
MulenPay
2025-09-24 01:44:42 +03:00
Egor d471a84125 Update README.md 2025-09-24 01:40:21 +03:00
Egor 2cc18422fd Update .env.example 2025-09-24 01:35:56 +03:00
Egor 4ba60dab16 Merge pull request #248 from Fr1ngg/yicik4-bedolaga/add-hmac-verification-to-mulen-pay-webhook
Secure Mulen Pay webhook with signature validation
2025-09-24 01:30:06 +03:00
Egor 588e4acbd3 Secure Mulen Pay webhook with signature validation 2025-09-24 01:29:42 +03:00
Egor 3a67bbe298 Merge pull request #247 from Fr1ngg/qj2j17-bedolaga/integrate-mulenpay-for-balance-top-ups
Add Mulen Pay integration
2025-09-24 01:21:08 +03:00
Egor eb730ecd6e Add Mulen Pay integration for balance top-ups 2025-09-24 01:14:15 +03:00
Egor 112da2c873 Merge pull request #240 from Fr1ngg/dev5
Dev5
2025-09-23 23:42:26 +03:00
Egor 37ff3c0d17 Merge pull request #239 from Fr1ngg/y717db-bedolaga/fix-stars-balance-top-up-issue
Fix Telegram Stars payments handler priority
2025-09-23 23:40:33 +03:00
Egor 1451bfac12 Fix Telegram Stars payments handler priority 2025-09-23 23:40:17 +03:00
Egor c445e24ff6 Merge pull request #238 from Fr1ngg/kko1hi-bedolaga/fix-duplicate-imports-in-pricing_utils.py
Fix duplicate imports and avoid cyclic referral code dependency
2025-09-23 23:24:49 +03:00
Egor bb5af66656 Fix duplicate import and referral code generation 2025-09-23 23:24:24 +03:00
Egor 85e8b7fc24 Merge pull request #237 from Fr1ngg/pe7u0s-bedolaga/fix-notification-status-for-first-deposit
Fix top-up status detection in admin notifications
2025-09-23 23:19:31 +03:00
Egor c4802fe442 Fix top-up status detection in admin notifications 2025-09-23 23:19:13 +03:00
Egor 91ee1d96b3 Merge pull request #236 from Fr1ngg/1mkicc-bedolaga/remove-my-subscription-button-on-trial-activation
Remove redundant subscription menu button after activation
2025-09-23 23:17:11 +03:00
Egor 19fe40b172 Remove subscription button from post-purchase prompts 2025-09-23 23:16:51 +03:00
Egor 355ac7a0fa Merge pull request #233 from PEDZEO/my-fix
Система модерации и улучшения тикетов
2025-09-23 23:03:15 +03:00
Egor 361a8a6297 Merge pull request #235 from Fr1ngg/dev5
Фикс
2025-09-23 23:02:14 +03:00
Egor 0b68aa85c7 Merge pull request #234 from Fr1ngg/bedolaga-gim3jh
Show checkout resume button for expired subscriptions
2025-09-23 22:54:51 +03:00
Egor 3105b811d4 Show checkout resume button for expired subscriptions 2025-09-23 22:54:33 +03:00
Legacyyy777 b4d857eb88 Merge branch 'Fr1ngg:main' into DEV 2025-09-23 21:40:33 +05:00
PEDZEO b438b6df61 Merge branch 'main' into my-fix 2025-09-23 16:02:16 +03:00
PEDZEO 15bda0560a feat: модерация, обновленное меню тикетов, SLA и управление уведомлениями 2025-09-23 15:39:16 +03:00
Egor 3433ea6aef Merge pull request #231 from Fr1ngg/revert-230-dev5
Revert "Dev5"
2025-09-23 05:53:29 +03:00
Egor 745ebf64ef Revert "Dev5" 2025-09-23 05:53:13 +03:00
Egor 80ff06ef24 Merge pull request #230 from Fr1ngg/dev5
Dev5
2025-09-23 05:42:15 +03:00
Egor f270ac9f9d Merge pull request #229 from Fr1ngg/ze53nx-bedolaga/restrict-bot-responses-in-topics
Ignore topic messages
2025-09-23 05:39:22 +03:00
Egor 7d5d89302b Ignore topic messages 2025-09-23 05:38:59 +03:00
Egor 155482779e Update Dockerfile 2025-09-23 04:34:08 +03:00
Egor d1bacf140c Update docker-registry.yml 2025-09-23 04:33:57 +03:00
Egor 55a38a5e05 Update docker-hub.yml 2025-09-23 04:33:36 +03:00
Egor f21d451c13 Merge pull request #224 from Fr1ngg/dev5
Доп фильтр в рассылках по нулевому используемому трафику
2025-09-23 04:19:19 +03:00
Egor 29c3e00c27 Merge pull request #223 from Fr1ngg/revert-218-vrfwqu-bedolaga/add-auto-apply-user-groups-feature
Revert "feat: support auto promo group assignment by spending"
2025-09-23 04:17:26 +03:00
Egor 44d249ad5b Revert "feat: support auto promo group assignment by spending" 2025-09-23 04:17:17 +03:00
Egor 4333412169 Merge pull request #222 from Fr1ngg/revert-221-tkt9ga-bedolaga/add-button-selection-for-promo-group-creation
Revert "Add auto-assign controls and prevent repeated promo group reassignment"
2025-09-23 04:16:55 +03:00
Egor 50c7eaa29a Revert "Add auto-assign controls and prevent repeated promo group reassignment" 2025-09-23 04:16:47 +03:00
Egor b2e168dbec Merge pull request #221 from Fr1ngg/tkt9ga-bedolaga/add-button-selection-for-promo-group-creation
Add auto-assign controls and prevent repeated promo group reassignment
2025-09-23 04:14:35 +03:00
Egor a1d4e9c7b3 Add auto-assign controls and prevent repeated promo group reassignment 2025-09-23 04:14:15 +03:00
Egor 82d1b48314 Merge pull request #220 from Fr1ngg/revert-219-6eo1zk-bedolaga/add-button-selection-for-promo-group-creation
Revert "Add single-use promo group auto assignment"
2025-09-23 04:13:18 +03:00
Egor eaffaa11b7 Revert "Add single-use promo group auto assignment" 2025-09-23 04:13:09 +03:00
Egor 2c13995625 Merge pull request #219 from Fr1ngg/6eo1zk-bedolaga/add-button-selection-for-promo-group-creation
Add single-use promo group auto assignment
2025-09-23 04:12:08 +03:00
Egor 7b0892e379 Add single-use promo group auto assignment 2025-09-23 04:11:40 +03:00
Egor 483ccab3f7 Merge pull request #218 from Fr1ngg/vrfwqu-bedolaga/add-auto-apply-user-groups-feature
feat: support auto promo group assignment by spending
2025-09-23 03:53:24 +03:00
Egor 7f62af6955 feat: support auto promo group assignment by spending 2025-09-23 03:53:00 +03:00
Egor 857dfc7e27 Merge pull request #217 from Fr1ngg/bedolaga-eehnom
Add extended broadcast filters for subscription states
2025-09-23 03:34:01 +03:00
Egor f47ca40974 Merge branch 'dev5' into bedolaga-eehnom 2025-09-23 03:33:47 +03:00
Egor 0256203f44 Fix broadcast target parsing for zero-traffic filters 2025-09-23 03:33:16 +03:00
Egor 5bda94794e Merge pull request #216 from Fr1ngg/revert-215-bedolaga-0l8zql
Revert "Adjust zero-traffic audience filters"
2025-09-23 03:32:56 +03:00
Egor 0d218980f7 Revert "Adjust zero-traffic audience filters" 2025-09-23 03:32:31 +03:00
Egor 73d8c30d9c Merge pull request #215 from Fr1ngg/bedolaga-0l8zql
Adjust zero-traffic audience filters
2025-09-23 03:31:37 +03:00
Egor 7abafbeaa1 Merge branch 'dev5' into bedolaga-0l8zql 2025-09-23 03:31:28 +03:00
Egor 2a7728a76c Adjust zero-traffic audience filters 2025-09-23 03:30:37 +03:00
Egor d266997720 Merge pull request #214 from Fr1ngg/bedolaga-hyr7z5
Add extended broadcast filters for subscription states
2025-09-23 03:24:47 +03:00
Egor d6fb26d425 Add extended broadcast filters for subscription states 2025-09-23 03:24:28 +03:00
Egor 1eb3658ce5 Merge pull request #213 from Fr1ngg/revert-212-bedolaga-99q9z7
Revert "Add broadcast filters for expired and zero traffic subscriptions"
2025-09-23 03:23:20 +03:00
Egor badf679bc3 Revert "Add broadcast filters for expired and zero traffic subscriptions" 2025-09-23 03:23:12 +03:00
Egor 1e542a0aa1 Merge pull request #212 from Fr1ngg/bedolaga-99q9z7
Add broadcast filters for expired and zero traffic subscriptions
2025-09-23 03:21:29 +03:00
Egor cdbeca245f Add broadcast filters for expired and zero traffic subscriptions 2025-09-23 03:21:02 +03:00
Egor 5a75626625 Merge pull request #210 from Fr1ngg/wy1u0m-bedolaga/add-referral-system-enhancements-and-analytics
Fix campaign stats to include subscription payments
2025-09-23 03:08:51 +03:00
Egor 17e3985671 Fix campaign stats to include subscription payments 2025-09-23 03:08:15 +03:00
Egor 5a5f151b38 Merge pull request #209 from Fr1ngg/xbnsin-bedolaga/add-referral-system-enhancements-and-analytics
Add advertising campaign stats to admin user info
2025-09-23 02:54:41 +03:00
Egor 97c8ddf4e1 Add advertising campaign stats to admin user info 2025-09-23 02:54:19 +03:00
Egor df7414b670 Merge pull request #208 from Fr1ngg/revert-207-sha36e-bedolaga/add-referral-system-enhancements-and-analytics
Revert "Enhance campaign analytics and registration visibility"
2025-09-23 02:54:00 +03:00
Egor 8dfe2f2934 Revert "Enhance campaign analytics and registration visibility" 2025-09-23 02:53:52 +03:00
Egor 3f3968dda2 Merge pull request #207 from Fr1ngg/sha36e-bedolaga/add-referral-system-enhancements-and-analytics
Enhance campaign analytics and registration visibility
2025-09-23 02:50:33 +03:00
Egor 3930564f9b Fix duplicate campaign registration assignment 2025-09-23 02:49:16 +03:00
Egor 90edc1dc49 Merge pull request #206 from Fr1ngg/dev5
Фикс текстовой надписи (14 дней) при продлении
2025-09-23 02:28:08 +03:00
Egor c9f7906e10 Merge pull request #205 from Fr1ngg/yebrv1-bedolaga/remove-1-month-from-renewal-button
Fix 14-day period description
2025-09-23 02:25:38 +03:00
Egor 1758eabe0e Fix 14-day period description 2025-09-23 02:25:18 +03:00
Egor 7ec73c9bc7 Merge pull request #204 from Fr1ngg/dev5
Обновление мониторинга серверов в режиме xray
2025-09-23 02:14:09 +03:00
Egor 1b0ad8f8b6 Merge pull request #203 from Fr1ngg/9j3nj4-bedolaga/add-refresh-button-and-server-status
feat: enhance server status view
2025-09-23 02:10:22 +03:00
Egor 555f76a4ec feat: enhance server status view 2025-09-23 02:09:22 +03:00
Egor bfb9c5d09c Create docker-compose.local.yml 2025-09-23 01:37:58 +03:00
Egor 9a72a7e3e7 Merge pull request #198 from Fr1ngg/dev5
Фикс блокировки/разблокировки в тикет системе + дополнение переводов
2025-09-23 01:28:36 +03:00
Egor b48c3f1235 Merge pull request #197 from Fr1ngg/oz3a4v-bedolaga/fix-localization-key-issues-for-russian
Add missing ticket management localization strings
2025-09-23 01:27:48 +03:00
Egor 85f59566a6 Add missing ticket management localization strings 2025-09-23 01:27:30 +03:00
Egor 1c3f2c7500 Update texts.py 2025-09-23 01:21:47 +03:00
Egor 47ca096e48 Merge pull request #196 from Fr1ngg/vxzeis-bedolaga/fix-user-unblock-errors-in-ticket-system
Fix admin ticket rendering after user block toggles
2025-09-23 01:16:24 +03:00
Egor a9da2a0e63 Merge branch 'dev5' into vxzeis-bedolaga/fix-user-unblock-errors-in-ticket-system 2025-09-23 01:16:07 +03:00
Egor aaea98049e Fix FSM context usage in admin ticket handlers 2025-09-23 01:15:26 +03:00
PEDZEO 93668da99f Resolve merge conflicts: integrate server status and ticket system features
- Added server status handler and service
- Integrated server status keyboard functions
- Merged support settings and ticket configurations
- Updated bot.py with new handler registrations
- Resolved conflicts in config.py and inline.py
2025-09-23 01:13:49 +03:00
Egor 53c7df0b8e Merge pull request #195 from Fr1ngg/q3k8jm-bedolaga/fix-user-unblock-errors-in-ticket-system
Fix admin ticket rendering after user block toggles
2025-09-23 01:08:57 +03:00
Egor 86e8ac39c3 Handle admin ticket view after block toggles 2025-09-23 01:08:28 +03:00
Egor a9a10e3e90 Merge pull request #187 from Fr1ngg/dev5
Полноценная тикет-система by @PEDZEO
2025-09-23 00:39:57 +03:00
Egor 1de81a6aa4 Merge pull request #185 from yazhog/main
Автовыбор количества устройств в заказе, если TRIAL_DEVICE_LIMIT < DEFAULT_DEVICE_LIMIT
2025-09-23 00:38:22 +03:00
Egor 540a0e35a1 Merge pull request #184 from PEDZEO/dev3
Fix ticket system: add noop handlers, pagination, validation, and adm…
2025-09-23 00:30:09 +03:00
PEDZEO f0ae702933 Fix ticket system: add noop handlers, pagination, validation, and admin back button routing 2025-09-22 21:40:10 +03:00
PEDZEO 19be4348aa Fix ticket system: add noop handlers, pagination, validation, and admin back button routing 2025-09-22 21:20:50 +03:00
Egor 84ed1fc5ca Merge pull request #182 from Fr1ngg/dev3
Мониторинг серверов
2025-09-22 17:51:11 +03:00
Egor de38f8219c Merge pull request #181 from Fr1ngg/h47qjb-bedolaga/add-server-status-button-with-modes
Add server status menu integration
2025-09-22 17:29:52 +03:00
Egor 12061f0752 Add server status menu integration 2025-09-22 17:29:20 +03:00
Egor a5174f10d5 Merge pull request #180 from Fr1ngg/dev3
Фикс отключения скидки на дл. периоды для базовых юзеров
2025-09-22 14:54:40 +03:00
Egor 69eeaf6c46 Merge pull request #179 from Fr1ngg/v2tigz-bedolaga/fix-discount-settings-in-env
Respect base promo period discount flag
2025-09-22 14:53:43 +03:00
Egor ac25e1b4c6 Respect base promo period discount flag 2025-09-22 14:53:23 +03:00
Legacyyy777 51ba5b980c Merge branch 'Fr1ngg:main' into DEV 2025-09-21 10:57:47 +05:00
Egor 26e0800d70 Update Dockerfile 2025-09-21 08:19:43 +03:00
Egor d0197aacd7 Update docker-registry.yml 2025-09-21 08:19:16 +03:00
Egor b097c5db44 Update docker-hub.yml 2025-09-21 08:19:06 +03:00
Egor 302f64ad5c Merge pull request #178 from Fr1ngg/dev3
Вывод скидок за длительный период, за промогруппу при покупке/продлении подписки
2025-09-21 08:09:02 +03:00
Egor 23cd2097da Merge pull request #177 from Fr1ngg/embv4q-bedolaga/add-navigation-to-promo-groups-after-creation
Add return button after creating promo group
2025-09-21 08:05:56 +03:00
Egor 58257a6eac Add return button after creating promo group 2025-09-21 08:01:44 +03:00
Egor ea7b982b7b Merge pull request #176 from Fr1ngg/0h9kvj-bedolaga/display-promo-group-discounts-on-subscription-page
Show promo group discounts during subscription flow
2025-09-21 07:56:00 +03:00
Egor 1fcd76db9e Show promo discounts during subscription flow 2025-09-21 07:55:31 +03:00
Egor 66d92cfa51 Merge pull request #175 from Fr1ngg/dev3
Фикс покупки/продления подпсики для режима FIXED трафика
2025-09-21 06:43:43 +03:00
Egor 9dc5ee22ee Merge pull request #174 from Fr1ngg/p3trbh-bedolaga/fix-pricing-calculation-error-in-fixed-mode
Handle missing traffic package prices
2025-09-21 06:38:57 +03:00
Egor b5df729d97 Handle missing traffic package prices 2025-09-21 06:38:34 +03:00
Egor 56e3541b80 Merge pull request #173 from Fr1ngg/dev3
Фикс продления подписки
2025-09-21 06:31:24 +03:00
Egor 9681bce540 Merge pull request #172 from Fr1ngg/2osn6z-bedolaga/fix-subscription-renewal-error
Fix subscription extension success message
2025-09-21 06:27:40 +03:00
Egor 7330c729e0 Fix subscription extension success message 2025-09-21 06:27:10 +03:00
Egor 977a19a4c9 Merge pull request #171 from Fr1ngg/revert-170-a3l29i-bedolaga/fix-subscription-renewal-error
Revert "Fix edit_text patch signature"
2025-09-21 06:25:49 +03:00
Egor d226fedf75 Revert "Fix edit_text patch signature" 2025-09-21 06:24:59 +03:00
Egor c9ee1da728 Merge pull request #170 from Fr1ngg/a3l29i-bedolaga/fix-subscription-renewal-error
Fix edit_text patch signature
2025-09-21 06:14:37 +03:00
Egor d6753501e7 Fix edit_text patch signature 2025-09-21 06:13:59 +03:00
yazhog c3f62a4ff6 Merge branch 'Fr1ngg:main' into main 2025-09-20 19:31:51 +03:00
Legacyyy777 9fb6d7ecf6 Merge branch 'Fr1ngg:main' into DEV 2025-09-20 18:13:25 +05:00
Egor 48101eee39 Merge pull request #168 from Fr1ngg/dev3
Фикс удаления промогрупп
2025-09-20 10:38:01 +03:00
Egor 16e771f1c3 Merge pull request #167 from Fr1ngg/dbzwiq-bedolaga/fix-promo-group-deletion-not-working
Fix promo group deletion confirm handling
2025-09-20 10:31:45 +03:00
Egor 0aa49a6a94 Fix promo group delete confirmation handler 2025-09-20 10:31:31 +03:00
Egor c810619a13 Merge pull request #166 from Fr1ngg/revert-165-pa311q-bedolaga/fix-promo-group-deletion-not-working
Revert "Handle promo group deletion when default group is missing"
2025-09-20 10:30:58 +03:00
Egor 817d277091 Revert "Handle promo group deletion when default group is missing" 2025-09-20 10:30:35 +03:00
Egor d3a822e8b3 Merge pull request #165 from Fr1ngg/pa311q-bedolaga/fix-promo-group-deletion-not-working
Handle promo group deletion when default group is missing
2025-09-20 10:28:31 +03:00
Egor ec8c37744a Handle missing default promo group on deletion 2025-09-20 10:27:54 +03:00
Egor 732c36630c Merge pull request #163 from Fr1ngg/dev3
Промогруппы + Скидка базовым юзерам на доп услуги за продолжительный период подписки
2025-09-20 09:46:59 +03:00
Egor 498d2f04b6 Update .env.example 2025-09-20 09:42:14 +03:00
Egor d167147725 Update README.md 2025-09-20 09:41:45 +03:00
Egor 19800971d6 Merge pull request #162 from Fr1ngg/bedolaga
Add configurable base promo group period discounts
2025-09-20 09:12:58 +03:00
Egor 4b457240b9 Add configurable base promo group period discounts 2025-09-20 09:12:26 +03:00
Egor b63c5867d4 Merge pull request #161 from Fr1ngg/feature
Add promo group management to admin user profile
2025-09-20 08:35:23 +03:00
Egor 598dd8b6c1 Add promo group management to admin user profile 2025-09-20 08:35:06 +03:00
Egor 6cd399cea7 Merge pull request #160 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column-anc9e0
Expand universal migration to provision promo groups
2025-09-20 08:25:39 +03:00
Egor 466033d04c Delete migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py 2025-09-20 08:25:29 +03:00
Egor 99bca0220b Delete migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-09-20 08:25:14 +03:00
Egor a10b9f0ad7 Expand universal migration to provision promo groups 2025-09-20 08:23:57 +03:00
Egor 825d549822 Delete migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-09-20 08:15:21 +03:00
Egor 03cb06e757 Delete migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py 2025-09-20 08:15:09 +03:00
Egor 113b33e68d Delete migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py 2025-09-20 08:14:37 +03:00
Egor f3a327c9c7 Merge pull request #158 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column-rg4e47
Add promo groups migration
2025-09-20 08:07:51 +03:00
Egor 2ca4a989e2 Handle existing sent_notifications table in migration 2025-09-20 08:07:23 +03:00
Egor 7339f2cd4f Merge pull request #157 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column
Add promo groups migration
2025-09-20 07:57:26 +03:00
Egor 827897b771 Add promo groups table and link users 2025-09-20 07:57:06 +03:00
Egor eb5064d591 Merge pull request #156 from Fr1ngg/codex/add-promo-group-section-in-admin-panel
Add admin promo groups and integrate discounts
2025-09-20 07:49:12 +03:00
Egor 177862f178 Add admin promo groups and integrate discounts 2025-09-20 07:48:41 +03:00
Egor 7276dcf1e0 Merge pull request #155 from Fr1ngg/main
w
2025-09-20 06:58:06 +03:00
Egor 7587aec913 Update Dockerfile 2025-09-20 03:31:36 +03:00
Egor 7186c93078 Update docker-registry.yml 2025-09-20 03:31:22 +03:00
Egor 727e20584f Update docker-hub.yml 2025-09-20 03:31:11 +03:00
Egor 9c13775eec Merge pull request #153 from Fr1ngg/codex/update-readme-with-available-features
docs: expand functionality overview
2025-09-20 03:09:16 +03:00
Egor e767ef7d92 docs: expand functionality overview 2025-09-20 03:08:24 +03:00
Egor ce22df4d60 Update inline.py 2025-09-20 02:45:49 +03:00
Egor 7c4567ae70 Merge pull request #150 from Fr1ngg/dev3
Фиксы покупки подписки в апдейте с локалью
2025-09-20 02:24:19 +03:00
Egor af1125f6ed Merge pull request #151 from Fr1ngg/codex/fix-subscription-country-change-issue
Fix NameError in manage countries keyboard
2025-09-20 02:23:54 +03:00
Egor ee65e65108 Fix missing texts initialization in manage countries keyboard 2025-09-20 02:23:20 +03:00
Egor 5c1f7d54ba Merge pull request #149 from Fr1ngg/codex/fix-subscription-purchase-button-issue
Fix subscription checkout resume callbacks registration
2025-09-20 02:15:48 +03:00
Egor 21e5af9e9e Fix subscription checkout resume callbacks registration 2025-09-20 02:15:38 +03:00
Egor f7d674adf4 Merge pull request #148 from Fr1ngg/codex/fix-subscription-purchase-error
Add missing localization for subscription checkout button
2025-09-20 02:09:10 +03:00
Egor 5a7de9731a Add missing subscription checkout localization 2025-09-20 02:08:10 +03:00
Egor f987dc2716 фикс регистрации
Dev3
2025-09-20 01:58:14 +03:00
Egor bbdc1274e4 Merge pull request #146 from Fr1ngg/codex/fix-registration-issue-for-new-users-1a7pwj
Fix async rules loading for new users
2025-09-20 01:49:46 +03:00
Egor feb95fe105 Fix async rules loading for new users 2025-09-20 01:49:29 +03:00
Egor 91b0d3782a Merge pull request #131 from yazhog/feature/locale
Feature/locale
2025-09-20 01:02:40 +03:00
Legacyyy777 d297e9b6d1 Add deploy workflow 2025-09-19 22:08:52 +05:00
yazhog 6d613cab85 Merge pull request #41 from yazhog/codex/set-device-limit-on-subscription-upgrade
Ensure trial-to-paid upgrade defaults to paid device limit
2025-09-19 19:17:09 +03:00
yazhog a0b6aa13a3 Adjust default device selection when upgrading from trial 2025-09-19 18:28:36 +03:00
yazhog a794f5da70 Merge pull request #40 from yazhog/codex/add-localization-for-campaign-keys
Add campaign localization strings to internal locales
2025-09-19 18:06:47 +03:00
yazhog e571edb354 Remove legacy YAML locale files 2025-09-19 18:06:37 +03:00
yazhog 84dece39a7 Add campaign localization strings to internal locales 2025-09-19 18:04:28 +03:00
yazhog f49bf21caa Merge branch 'main' into feature/locale 2025-09-19 18:03:17 +03:00
Egor 64da0ef94f Merge pull request #142 from Fr1ngg/dev3
Правки в меню редактирования профиля юзера
2025-09-19 15:48:46 +03:00
Egor 3177b5ab80 Merge pull request #141 from Fr1ngg/codex/refactor-user-edit-menu-for-subscriptions
Unify admin subscription and settings menu
2025-09-19 15:36:55 +03:00
Egor ec1b459689 Unify admin subscription and settings menu 2025-09-19 15:36:11 +03:00
Egor 08f7f87035 Merge pull request #140 from Fr1ngg/main
w
2025-09-19 15:25:12 +03:00
Egor 59189cae2a Merge pull request #114 from Gy9vin/dev
Добавлена фильтрация пользователей по балансу
2025-09-19 15:16:26 +03:00
Egor 573dcd034a Merge pull request #138 from Fr1ngg/dev_test
Редактирование РК + фикс удаления
2025-09-19 13:40:12 +03:00
Egor 0f29a8a33b Merge pull request #137 from Fr1ngg/codex/fix-editing-of-ad-campaigns
Fix campaign edit menu for captioned messages
2025-09-19 13:36:38 +03:00
Egor 98ec7e4165 Fix campaign edit menu for captioned messages 2025-09-19 13:36:27 +03:00
Egor 46c7d53f6e Merge pull request #136 from Fr1ngg/revert-135-codex/fix-campaign-deletion-and-add-editing-yipzjx
Revert "Handle campaign edit menu for media messages"
2025-09-19 13:27:07 +03:00
Egor 61758b8c2a Revert "Handle campaign edit menu for media messages" 2025-09-19 13:26:54 +03:00
Egor 42ac9555ac Merge pull request #135 from Fr1ngg/codex/fix-campaign-deletion-and-add-editing-yipzjx
Handle campaign edit menu for media messages
2025-09-19 13:25:20 +03:00
Egor f7416f2255 Merge branch 'dev_test' into codex/fix-campaign-deletion-and-add-editing-yipzjx 2025-09-19 13:25:12 +03:00
Egor 509c92217f Fix campaign edit menu fallback for non-text messages 2025-09-19 13:23:30 +03:00
Egor c0d5ae1e9e Merge pull request #134 from Fr1ngg/codex/fix-campaign-deletion-and-add-editing
Fix campaign deletion confirmation and add editing workflow
2025-09-19 13:15:35 +03:00
Egor cf9fbc5ccc Fix campaign deletion confirmation and add editing workflow 2025-09-19 13:15:17 +03:00
gy9vin ad19be83d2 Merge branch 'dev' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot into dev 2025-09-19 13:14:00 +03:00
yazhog 3dd5e41bda Merge pull request #39 from yazhog/codex/fix-syntaxerror-in-subscription.py
Fix subscription success text formatting
2025-09-19 12:38:27 +03:00
yazhog 639a45611e Fix subscription success text formatting 2025-09-19 12:38:08 +03:00
Egor 773299c317 Merge pull request #130 from Fr1ngg/dev_test
Рекламные компании
2025-09-19 12:34:53 +03:00
yazhog ff39b10761 Merge pull request #38 from yazhog/codex/fix-f-string-syntaxerror-in-subscription.py
Fix trial success text formatting
2025-09-19 12:33:32 +03:00
yazhog 5fd5472fd3 Fix trial success text formatting 2025-09-19 12:33:20 +03:00
Egor adf94802b0 Merge pull request #129 from Fr1ngg/codex/add-advertising-link-functionality-to-admin-eu5f3n
Refresh user state after campaign bonuses
2025-09-19 12:30:22 +03:00
Egor 15378a412f Merge branch 'dev_test' into codex/add-advertising-link-functionality-to-admin-eu5f3n 2025-09-19 12:30:06 +03:00
Egor 32da16f652 Refresh user state after campaign bonuses 2025-09-19 12:29:28 +03:00
yazhog 075d92e4bd Merge pull request #37 from yazhog/codex/extract-text-for-localization
Localize user-facing texts and payment method copy
2025-09-19 12:28:04 +03:00
yazhog a7cbcd284c Localize subscription, referral, and payment texts 2025-09-19 12:27:42 +03:00
Egor 5682d508a3 Merge pull request #128 from Fr1ngg/codex/add-advertising-link-functionality-to-admin
Clean up campaign-related imports
2025-09-19 12:16:12 +03:00
Egor c738b5703f Fix import cleanup 2025-09-19 12:15:56 +03:00
yazhog 6875fce2cb Merge pull request #36 from yazhog/codex/locate-missing-localization-keys
Add missing English localization strings for trial activation
2025-09-19 12:09:31 +03:00
yazhog 57b7ad6732 Add missing English localizations for trial activation 2025-09-19 12:09:11 +03:00
yazhog 8377db323b Merge pull request #35 from yazhog/codex/fix-language-setting-in-interface
Respect DEFAULT_LANGUAGE in onboarding
2025-09-19 12:01:13 +03:00
yazhog 78d768ea4d Respect DEFAULT_LANGUAGE in onboarding 2025-09-19 12:01:01 +03:00
yazhog 9dee654eee Merge pull request #34 from yazhog/codex/move-interface-text-to-locales
Localize interface strings in payments and shared handlers
2025-09-19 11:46:25 +03:00
Egor a02f49916a Merge pull request #126 from Fr1ngg/main
w
2025-09-19 11:44:19 +03:00
yazhog 8d25e9b760 Localize stars payment and common handler texts 2025-09-19 11:43:54 +03:00
Egor 62ebbb2b5f Merge pull request #125 from Fr1ngg/dev_test
Возможность указывать ссылку на саппорт, а не только юзернейм
2025-09-19 11:42:02 +03:00
Egor e62caa725f Merge pull request #124 from Fr1ngg/codex/allow-links-in-support-section
Allow support contact to use URLs
2025-09-19 11:38:25 +03:00
Egor db5f81e6be Allow support contact to use URLs 2025-09-19 11:38:11 +03:00
Egor 15cba3da22 Merge pull request #123 from Fr1ngg/dev_test
Сохранение истории покупки в кеше + предложение продолжить покупку после пополнения баланса
2025-09-19 11:16:11 +03:00
Egor ca93182f68 Merge pull request #122 from Fr1ngg/codex/update-bot-to-return-to-order-summary-6na3g2
Show resume checkout option after Tribute top-ups
2025-09-19 11:08:53 +03:00
Egor b1a2b6774d Show resume checkout option after Tribute top-ups 2025-09-19 11:08:41 +03:00
yazhog ebc98099d3 Merge pull request #33 from yazhog/codex/refactor-localization-for-configurable-default-language
Use configurable default language
2025-09-19 10:58:07 +03:00
yazhog e2137f94f8 Use configurable default language 2025-09-19 10:57:49 +03:00
Egor 3fdc081548 Merge pull request #121 from Fr1ngg/codex/update-bot-to-return-to-order-summary-qgf9b5
Ensure subscription checkout resume after top-ups
2025-09-19 10:54:33 +03:00
Egor 57cc2d2e3a Fix subscription checkout resume after top-ups 2025-09-19 10:54:16 +03:00
Egor b883d2a95c Merge pull request #120 from Fr1ngg/codex/update-bot-to-return-to-order-summary
Add subscription checkout resume support
2025-09-19 10:40:22 +03:00
Egor cfcb35c7ee Add subscription checkout draft resume support 2025-09-19 10:40:05 +03:00
gy9vin 7f4899b885 Добавлена фильтрация пользователей по балансу 2025-09-19 08:40:29 +03:00
Egor 719d656a64 Update docker-registry.yml 2025-09-19 05:39:04 +03:00
Egor 04476ed463 Update docker-hub.yml 2025-09-19 05:38:46 +03:00
Egor 7ef7b1d107 Update Dockerfile 2025-09-19 05:10:54 +03:00
Egor 0cb9ab5704 Update docker-registry.yml 2025-09-19 05:10:45 +03:00
Egor 4d109b948a Update docker-hub.yml 2025-09-19 05:10:33 +03:00
Egor efd8b191ff Update subscription.py 2025-09-19 04:46:31 +03:00
Egor 8273a31512 Update inline.py 2025-09-19 04:41:44 +03:00
Egor 7baebd85c7 Update balance.py 2025-09-19 04:40:56 +03:00
Egor e43f6afd8a Update subscription.py 2025-09-19 04:39:24 +03:00
Egor ec3430db27 Update states.py 2025-09-19 04:34:32 +03:00
Egor a2c0551349 Merge pull request #106 from Fr1ngg/dependabot/pip/python-dependencies-14836bb5a9
deps(deps): bump the python-dependencies group with 12 updates
2025-09-19 03:46:55 +03:00
Egor a66719b865 Merge pull request #105 from Fr1ngg/dependabot/docker/python-3.13-slim
docker(deps): bump python from 3.11-slim to 3.13-slim
2025-09-19 03:46:12 +03:00
Egor b201543014 Merge pull request #104 from Fr1ngg/dependabot/github_actions/github-actions-e8a2446fba
ci(deps): bump the github-actions group with 2 updates
2025-09-19 03:45:55 +03:00
Egor e269f31056 Update dependabot.yml 2025-09-19 03:44:57 +03:00
dependabot[bot] 0c1db66be6 deps(deps): bump the python-dependencies group with 12 updates
---
updated-dependencies:
- dependency-name: aiogram
  dependency-version: 3.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiohttp
  dependency-version: 3.12.15
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: asyncpg
  dependency-version: 0.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.16.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiosqlite
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dotenv
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: apscheduler
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dateutil
  dependency-version: 2.9.0.post0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:43:47 +00:00
dependabot[bot] 5e46507f04 docker(deps): bump python from 3.11-slim to 3.13-slim
Bumps python from 3.11-slim to 3.13-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.13-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:43:05 +00:00
dependabot[bot] f3616abec2 ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

Updates `docker/build-push-action` from 5 to 6
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:42:59 +00:00
Egor 86ec46dbe7 Create dependabot.yml 2025-09-19 03:42:10 +03:00
Egor 863b5df0c2 Delete .github/workflows/dependabot.yml 2025-09-19 03:41:41 +03:00
Egor 929b3b8e95 Merge pull request #101 from Gy9vin/dev
Добавлена функция покупки подписки администратором с преобразованием …
2025-09-19 03:40:20 +03:00
Egor f79393c46e Create dependabot.yml 2025-09-19 03:38:52 +03:00
Egor 559dd1df71 Update docker-registry.yml 2025-09-19 03:30:37 +03:00
Egor cb959bdee7 Update docker-hub.yml 2025-09-19 03:30:14 +03:00
Egor ac1b0ec8b3 Update users.py 2025-09-19 03:25:06 +03:00
yazhog fe779a29b4 Merge branch 'Fr1ngg:main' into feature/locale 2025-09-19 01:12:27 +03:00
yazhog b4a1d10037 Промежутки.. 2025-09-19 01:04:43 +03:00
gy9vin 03856d30ec Добавлена функция покупки подписки администратором с преобразованием триала в безлимитную подписку 2025-09-18 23:50:01 +03:00
yazhog e2e3e444ec Промежуточный коммит 2025-09-18 22:05:20 +03:00
Egor cd98d150d6 Merge pull request #100 from Fr1ngg/dev
Починка покупки подписки
2025-09-18 16:29:07 +03:00
Egor 92814ae535 Update subscription.py 2025-09-18 16:22:22 +03:00
Egor 3268c2a509 Update user_utils.py 2025-09-18 16:20:06 +03:00
Egor a09b6c1431 Update subscription.py 2025-09-18 16:19:07 +03:00
yazhog 31c560093d Промежуточный этап локализации 2025-09-18 09:09:55 +03:00
Egor 728bd0450d Merge pull request #95 from Legacyyy777/DEV
№6
2025-09-18 03:13:43 +03:00
Egor 3df3fdbcfe Merge pull request #99 from Fr1ngg/dev
Dev
2025-09-18 03:13:00 +03:00
Egor 296756dac4 Merge pull request #96 from Gy9vin/dev
Пополнение баланса кнопками
2025-09-18 03:11:39 +03:00
Egor 7cb766f3d0 Merge pull request #98 from yazhog/main
Фикс отправки кнопок в рассылках, скрытие в детализации подписки нулевых значений за сервера/траффик
2025-09-18 03:10:49 +03:00
Egor 5e94b287b9 Delete .github/workflows/deploy.yml 2025-09-18 03:08:50 +03:00
yazhog e9eb8f17d3 Merge pull request #31 from yazhog/codex/remove-zero-cost-server-traffic-options-from-calculations
Hide zero-cost options from subscription pricing breakdown
2025-09-18 00:46:18 +03:00
yazhog dfbaf9de21 Filter zero-cost subscription pricing details 2025-09-18 00:37:02 +03:00
yazhog 4dcca0da4a Merge pull request #30 from yazhog/codex/replace-button-text-from-to
Rename referrals button text to Партнерка
2025-09-18 00:33:04 +03:00
yazhog ea85d4f62e Rename referrals button to partnerka 2025-09-18 00:30:01 +03:00
yazhog bef1a3ecd9 Merge pull request #29 from yazhog/codex/analyze-button-attachment-to-message-delivery
Make home button optional in broadcasts
2025-09-18 00:07:50 +03:00
yazhog 22be75508e Add optional broadcast buttons for connect, subscription, and support 2025-09-17 22:51:18 +03:00
yazhog dba8241c5c Make home button optional in broadcasts 2025-09-17 22:19:55 +03:00
gy9vin b3d0348821 2 кнопки баланса в ряду 2025-09-17 18:54:20 +03:00
gy9vin 3006a438e2 Merge branch 'dev' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot into dev 2025-09-17 18:34:24 +03:00
gy9vin d2f74481cd Добавление кнопок пополнения с суммой подписки 2025-09-17 18:31:21 +03:00
Legacyyy777 d29ad223a7 Добавлено уведомление пользователю о пополнении/списании баланса. Реализована отправка сообщений через Telegram-бота с информацией о транзакции и администраторе, выполнившем операцию. 2025-09-17 19:22:43 +05:00
Legacyyy777 c62ccd0790 Merge branch 'Fr1ngg:main' into DEV 2025-09-17 18:14:03 +05:00
Egor 8ff240cbe9 Add files via upload 2025-09-17 16:06:22 +03:00
Egor 4a5769d37e Create docker-hub.yml 2025-09-17 16:05:51 +03:00
Egor 7d27ee7937 Delete .github/workflows directory 2025-09-17 16:04:00 +03:00
Egor a927a6fa70 Create docker-registry.yml 2025-09-17 16:02:47 +03:00
Egor be4db68d00 Delete .github/ISSUE_TEMPLATE/bug_report.md 2025-09-17 16:01:36 +03:00
Egor 6e3807cf78 Delete .github/ISSUE_TEMPLATE/custom.md 2025-09-17 16:01:28 +03:00
Egor ff9adfc30f Delete .github/ISSUE_TEMPLATE/feature_request.md 2025-09-17 16:01:18 +03:00
Egor a6037b60eb Update yookassa_service.py 2025-09-17 15:58:27 +03:00
Egor a0b95cc24c Update payment_service.py 2025-09-17 15:57:27 +03:00
Egor a2536b576e Update inline.py 2025-09-17 15:55:39 +03:00
Egor 0e544040fc Update balance.py 2025-09-17 15:54:58 +03:00
Egor 940957fa98 Update config.py 2025-09-17 15:52:29 +03:00
Legacyyy777 2e6b808f6d Merge branch 'Fr1ngg:main' into DEV 2025-09-17 17:10:15 +05:00
Egor 9ff4734659 Merge pull request #93 from Fr1ngg/main
к
2025-09-17 15:09:21 +03:00
Egor 7a08f483b9 Merge pull request #91 from Fr1ngg/dev
Dev
2025-09-17 14:47:06 +03:00
Egor c2b10e969e Add files via upload 2025-09-17 14:36:29 +03:00
Egor 7bf9b10963 Update user_utils.py 2025-09-17 14:31:44 +03:00
Egor 6402cbef06 Update subscription_service.py 2025-09-17 14:28:42 +03:00
Legacyyy777 b8855bf73b Merge branch 'DEV' of https://github.com/Legacyyy777/remnawave-bedolaga-telegram-bot_777 into DEV 2025-09-17 16:10:19 +05:00
Legacyyy777 5453efadb5 Добавлен файл конфигурации для автоматического деплоя бота на VPS при пуше в ветку DEV. 2025-09-17 16:07:56 +05:00
Egor 8246405c31 Merge pull request #88 from Gy9vin/dev
Добавление СБП отдельной кнопкой для yookassa и указание кастомных сумм для yookasse
2025-09-17 14:01:58 +03:00
Egor 777f16f3c1 Delete Dockerfile 2025-09-17 13:53:01 +03:00
Egor 11860ff21a Delete .github/workflows/docker-registry.yml 2025-09-17 13:52:43 +03:00
Egor 0a6df56dd2 Delete .github/workflows/docker-hub.yml 2025-09-17 13:52:27 +03:00
Egor 9dd643ecab Режим удаления пользователей из панели Remnawave by @Legacyyy777
№5
2025-09-17 13:46:27 +03:00
Egor d8d01384b1 Delete .github/workflows/deploy.yml 2025-09-17 13:44:22 +03:00
Legacyyy777 d0b3bbd488 Update .env.example 2025-09-17 14:29:22 +05:00
Legacyyy777 e8c458f2c4 Добавлен новый параметр REMNAWAVE_USER_DELETE_MODE в конфигурацию и обновлен процесс удаления пользователя RemnaWave с учетом выбранного режима удаления или деактивации. Обработаны возможные ошибки при взаимодействии с Remnawave. 2025-09-17 14:25:37 +05:00
Legacyyy777 e324e50713 Изменен процесс удаления пользователя RemnaWave: добавлена попытка удаления пользователя из панели Remnawave с обработкой ошибок и резервным вариантом деактивации через SubscriptionService. 2025-09-17 14:04:55 +05:00
Legacyyy777 d506ba7317 Добавлен файл конфигурации для деплоя бота на VPS при пуше в ветку DEV 2025-09-17 13:46:37 +05:00
Legacyyy777 2c960813de restore deploy.yml for my fork deploy 2025-09-17 13:38:39 +05:00
gy9vin 1d98bc75b0 Fix gpg 2025-09-17 11:31:31 +03:00
gy9vin 19dddb3cc0 changing the minimum and maximum amount for yookassa 2025-09-17 11:26:25 +03:00
gy9vin 863dd2423d adding the SBP method for YooKassa 2025-09-17 10:48:58 +03:00
Egor 4cc5e92b83 Update backup_service.py 2025-09-17 03:49:21 +03:00
Egor fe626f3442 Update Dockerfile 2025-09-17 02:00:41 +03:00
Egor 50ad9a7a92 Update docker-registry.yml 2025-09-17 02:00:33 +03:00
Egor a73ec96f5c Update docker-hub.yml 2025-09-17 02:00:18 +03:00
Egor 1d072d4544 Merge pull request #87 from Fr1ngg/dev
Вывод названия триального сквада при активации и в разделе с подпиской
2025-09-17 01:46:11 +03:00
Egor 4997acd8bc Update texts.py 2025-09-17 01:44:17 +03:00
Egor 94e202c596 Update subscription.py 2025-09-17 01:43:47 +03:00
Egor 9044060816 Update user_service.py 2025-09-17 01:09:15 +03:00
Egor 3be1a34725 Merge pull request #86 from Fr1ngg/dev
Правка бекапов/восстановления, удаления юзеров, исправление вывода курса за звезды+динамическая инфа в способах оплаты
2025-09-17 01:00:25 +03:00
Egor 41405b83ef Update balance.py 2025-09-17 00:51:08 +03:00
Egor 2133355fbb Update telegram_stars.py 2025-09-17 00:50:34 +03:00
Egor dd7e9ad43f Add files via upload 2025-09-17 00:39:30 +03:00
Egor 460b8208d3 Update inline.py 2025-09-17 00:39:07 +03:00
Egor 5c040a3b03 Update balance.py 2025-09-17 00:38:08 +03:00
Egor 7dd7643a76 Update user_service.py 2025-09-17 00:25:01 +03:00
Egor 12a6ea1097 Update backup_service.py 2025-09-17 00:10:54 +03:00
Egor fb71414096 Merge pull request #84 from yazhog/main
Фикс кнопки назад в покупке подписки, когда только 1 страна доступна
2025-09-16 22:43:34 +03:00
Egor a814a95c34 Merge pull request #83 from Legacyyy777/DEV
№4
2025-09-16 22:43:01 +03:00
Egor 3732359043 Delete .github/workflows/deploy.yml 2025-09-16 22:42:30 +03:00
yazhog 18d637c2fb Merge pull request #20 from yazhog/codex/fix-foreign-key-violation-on-user-delete
Prevent user deletion from failing when welcome texts exist
2025-09-16 16:06:56 +03:00
yazhog 48a7d2a112 Merge pull request #27 from yazhog/codex/disable-service-if-channel_is_required_sub-is-false
Conditionally register channel checker middleware
2025-09-16 14:18:53 +03:00
yazhog 4639020a8e Conditionally register channel checker middleware 2025-09-16 14:11:44 +03:00
yazhog 27da3def3c Merge pull request #26 from yazhog/codex/define-subscription-behavior-for-single-country
Skip country step on back when only one option
2025-09-16 14:03:22 +03:00
yazhog 77027adad3 Skip country step on back when only one option 2025-09-16 14:00:38 +03:00
Legacyyy777 76dd83ac58 Улучшение обработки недостатка средств на балансе: добавлено отображение конкретной суммы недостающих средств 2025-09-16 14:52:38 +05:00
Legacyyy777 eb40143b76 Keep my deploy.yml 2025-09-16 14:40:39 +05:00
Egor 1cda346174 Merge pull request #82 from yazhog/main
Фикс автобэкапов
2025-09-16 10:49:10 +03:00
yazhog f534abdb6f Merge branch 'Fr1ngg:main' into main 2025-09-16 10:43:30 +03:00
yazhog 5204a38528 Merge pull request #25 from yazhog/codex/document-backup-creation-process
Schedule initial auto backup at configured time
2025-09-16 10:41:25 +03:00
yazhog fcb9c1e3b7 Schedule initial auto backup at configured time 2025-09-16 10:20:11 +03:00
Egor 5af41f8d4e Update channel_checker.py 2025-09-16 07:45:09 +03:00
Egor 592c9de77a Merge pull request #81 from Fr1ngg/dev
Фикс проверки подписки на группу
2025-09-16 07:26:20 +03:00
Egor f67f4edc21 Update README.md 2025-09-16 07:25:38 +03:00
Egor a251835a79 Update .env.example 2025-09-16 07:25:07 +03:00
Egor ba8a57e381 Add files via upload 2025-09-16 07:23:14 +03:00
Legacyyy777 75f581924e Update deploy.yml 2025-09-16 08:25:36 +05:00
Egor 10f2a8327c Update channel_checker.py 2025-09-16 06:20:45 +03:00
Egor 127a16b4d6 Update README.md 2025-09-16 06:16:49 +03:00
Egor f9ded2b857 Delete .github/workflows/deploy.yml 2025-09-16 06:12:01 +03:00
Egor 622412992f Merge pull request #80 from Legacyyy777/DEV
№3
2025-09-16 06:11:01 +03:00
Legacyyy777 5156c3481c Улучшена обработка медиафайлов V2 2025-09-16 07:45:16 +05:00
Legacyyy777 1d3d4ac36e Добавлено отображение предпросмотра медиафайлов 2025-09-16 07:29:39 +05:00
Legacyyy777 3449dc9d2c Улучшение обработки медиа в сообщениях 2025-09-16 07:24:32 +05:00
Legacyyy777 acd01910ac restore deploy.yml for my fork deploy 2025-09-16 07:04:32 +05:00
Egor e16bcafce0 Update README.md 2025-09-16 03:36:38 +03:00
Egor b8e664aad8 Update Dockerfile 2025-09-16 03:18:56 +03:00
Egor b365394ff4 Update docker-registry.yml 2025-09-16 03:18:46 +03:00
Egor e53549d7cc Update docker-hub.yml 2025-09-16 03:18:28 +03:00
Egor 743e99af85 Merge pull request #78 from yazhog/main
Шаблон описания пользователя в панели Remnawave, с плейсхолдерами
2025-09-16 01:38:53 +03:00
Egor 9616fe8d44 Merge pull request #79 from Fr1ngg/dev
Dev
2025-09-16 01:32:10 +03:00
Egor 4ae6361599 Merge pull request #76 from FireWookie/codex/required-sub-channel
Codex/required sub channel
2025-09-16 01:29:40 +03:00
yazhog 89c12e6ba9 Merge pull request #24 from yazhog/codex/add-user-description-template-to-.env.example
Add configurable RemnaWave user description template
2025-09-15 22:02:04 +03:00
yazhog 5d6bc82d8a Support username_clean placeholder for RemnaWave descriptions 2025-09-15 21:54:13 +03:00
yazhog a4f3fe5a49 Run RemnaWave middleware updates asynchronously 2025-09-15 21:43:30 +03:00
yazhog 3a58a3490a Update RemnaWave description from auth middleware 2025-09-15 21:31:49 +03:00
yazhog e2408702ea Update description when refreshing RemnaWave users 2025-09-15 21:22:01 +03:00
yazhog 3f1f27e5aa Merge pull request #23 from yazhog/revert-22-codex/find-user-mapping-between-telegram-bot-and-remnawave-api
Revert "Format RemnaWave username with Telegram names"
2025-09-15 20:41:01 +03:00
yazhog bbd0ec2edd Revert "Format RemnaWave username with Telegram names" 2025-09-15 20:40:49 +03:00
yazhog eb22b19d98 Merge pull request #22 from yazhog/codex/find-user-mapping-between-telegram-bot-and-remnawave-api
Format RemnaWave username with Telegram names
2025-09-15 16:30:50 +03:00
yazhog 45b9d44fd0 Transliterate names for RemnaWave usernames 2025-09-15 16:16:43 +03:00
yazhog a92003f0f5 Sanitize RemnaWave username for API 2025-09-15 16:09:08 +03:00
yazhog 4ac9387ef2 Relax RemnaWave username format 2025-09-15 16:01:23 +03:00
yazhog a24b5e8145 Format RemnaWave username with Telegram names 2025-09-15 15:52:04 +03:00
firewookie 7244e3978b upd channel check 2025-09-15 16:37:21 +05:00
firewookie 41cabf5e0e add middleware and keyboard 2025-09-14 23:42:47 +05:00
yazhog 99c7dc4b29 Clear welcome text references when deleting user 2025-09-14 16:47:40 +03:00
Egor 338bc03cd6 Update messages.py 2025-09-14 14:26:29 +03:00
Egor 985260144e Update messages.py 2025-09-14 13:07:30 +03:00
Egor b487925fbf Add files via upload 2025-09-14 09:21:22 +03:00
Egor 07a89443b3 Merge pull request #74 from Fr1ngg/dev
Фиксы сервиса мониторинга
2025-09-14 09:13:11 +03:00
Egor 54ebb2d96d Update admin.py 2025-09-14 09:11:25 +03:00
Egor 34a7cd1f6c Add files via upload 2025-09-14 09:09:16 +03:00
Egor 455b8fc709 Add files via upload 2025-09-14 09:06:13 +03:00
Egor 591b422302 Add files via upload 2025-09-14 09:02:39 +03:00
Egor fc73c34a03 Update admin.py 2025-09-14 08:32:30 +03:00
Egor cc1c6e3088 Update admin.py 2025-09-14 08:05:44 +03:00
Egor 966f7124c4 Update Dockerfile 2025-09-14 04:52:52 +03:00
Egor 1cebec5af4 Update docker-registry.yml 2025-09-14 04:52:41 +03:00
Egor d31b5097ff Update docker-hub.yml 2025-09-14 04:52:27 +03:00
Egor 21a8fe4025 Merge pull request #73 from Fr1ngg/dev
Медиа в рассылках
2025-09-14 04:43:51 +03:00
Egor 6a4adcfe0c Update admin.py 2025-09-14 04:39:28 +03:00
Egor c9acd46cd3 Update states.py 2025-09-14 04:38:55 +03:00
Egor 6018113a3d Update messages.py 2025-09-14 04:38:29 +03:00
Egor fe0825957a Update universal_migration.py 2025-09-14 04:37:48 +03:00
Egor d0cef51e35 Update models.py 2025-09-14 04:37:34 +03:00
Egor 2aa0d11f4c Merge pull request #72 from Fr1ngg/dev
Доработка раздела с правилами + валидация
2025-09-14 04:08:39 +03:00
Egor b8a1aa3f3c Add files via upload 2025-09-14 04:05:02 +03:00
Egor 80e95bc77b Update rules.py 2025-09-14 04:04:31 +03:00
Egor 87af0312cb Update validators.py 2025-09-14 04:04:05 +03:00
Egor e988c9aa14 Merge pull request #70 from Fr1ngg/dev
Новые уведомления об изменении доп услуг
2025-09-14 03:39:03 +03:00
Egor cdee98f690 Update subscription.py 2025-09-14 03:37:55 +03:00
Egor 268c619ad6 Update admin_notification_service.py 2025-09-14 03:37:24 +03:00
Egor 352434ae35 Update admin_notification_service.py 2025-09-14 03:06:23 +03:00
Egor b1e39bae32 Merge pull request #69 from yazhog/main
Кнопка подключиться в главное меню, Переход к покупке/продлению подписки с окна уведомления о платеже
2025-09-14 02:58:12 +03:00
yazhog 44a2c929c4 Merge pull request #19 from yazhog/codex/add-online-today-and-this-week
Display daily and weekly online stats in admin panel
2025-09-13 22:11:49 +03:00
yazhog 42a9865fac Display daily and weekly online stats in admin panel 2025-09-13 22:08:22 +03:00
yazhog c730863162 Merge pull request #18 from yazhog/codex/add-connect-button-to-main-menu
Add connect button to main menu
2025-09-13 21:50:46 +03:00
yazhog b58db19127 Подвигал кнопки 2025-09-13 21:49:43 +03:00
yazhog 2820c2d942 Add connect button to main menu 2025-09-13 21:33:04 +03:00
yazhog 0be172c675 Merge pull request #17 from yazhog/codex/add-subscription-button-to-payment-message-1bqk73
Add subscription button to payment success notification
2025-09-13 21:22:56 +03:00
yazhog b26fd77190 Add subscription buttons to all payment success messages 2025-09-13 21:20:24 +03:00
yazhog 9317a1703a Add subscription button to payment success notification 2025-09-13 21:04:53 +03:00
yazhog f166279507 Merge branch 'Fr1ngg:main' into main 2025-09-13 19:40:45 +03:00
Egor 60216519af Merge pull request #68 from Fr1ngg/dev
Добавляемые кнопки для рассылок
2025-09-13 14:23:32 +03:00
Egor ed83d31b22 Add files via upload 2025-09-13 14:19:53 +03:00
yazhog cf6e2955c7 Merge branch 'Fr1ngg:main' into main 2025-09-13 14:16:07 +03:00
Egor 4e3a183e2b Update Dockerfile 2025-09-13 11:07:33 +03:00
Egor 0879855d3f Update docker-registry.yml 2025-09-13 11:07:20 +03:00
Egor 2358552961 Update docker-hub.yml 2025-09-13 11:07:04 +03:00
Egor 35b461b60c Update README.md 2025-09-13 11:06:28 +03:00
Egor 1b5cfc3920 Update README.md 2025-09-13 10:23:10 +03:00
Egor 4a179ccc1e Update README.md 2025-09-13 10:22:18 +03:00
Egor 4921578af3 Update .env.example 2025-09-13 10:21:31 +03:00
Egor 55fb1f2044 Update CONTRIBUTING.md 2025-09-13 10:19:21 +03:00
Egor ad38aae7b0 Update issue templates 2025-09-13 10:16:27 +03:00
Egor 7e0840ef09 Create SECURITY.md 2025-09-13 10:15:33 +03:00
Egor 03fcb5f2f7 Create CONTRIBUTING.md 2025-09-13 10:12:53 +03:00
Egor 0c4ea86e03 Update users.py 2025-09-13 09:29:26 +03:00
Egor 209bb9763e Merge pull request #66 from Fr1ngg/dev
Исправлена ошибок с устаревшими callback queries при перезапуске бота
2025-09-13 09:27:31 +03:00
Egor 187de6807e Update decorators.py 2025-09-13 09:22:56 +03:00
Egor b8c9302018 Add files via upload 2025-09-13 09:21:32 +03:00
Egor a86fccffc0 Update bot.py 2025-09-13 09:19:55 +03:00
Egor a448adec93 Create global_error.py 2025-09-13 09:18:55 +03:00
Egor d45ff62a57 Update decorators.py 2025-09-13 09:07:22 +03:00
Egor 3c50fc6a71 Update README.md 2025-09-13 08:02:29 +03:00
Egor 292a120f74 Merge pull request #65 from Fr1ngg/dev
X-Api-key заголовки + TinyAuth
2025-09-13 07:55:55 +03:00
Egor a7868787a4 Update remnawave_service.py 2025-09-13 07:53:39 +03:00
Egor 5275f4fbeb Update maintenance_service.py 2025-09-13 07:52:28 +03:00
Egor ba25ec7ad0 Update subscription_service.py 2025-09-13 07:44:16 +03:00
Egor 301b4124ef Update .env.example 2025-09-13 07:36:17 +03:00
Egor a936a97e5e Update remnawave_service.py 2025-09-13 07:34:44 +03:00
Egor c390d18399 Update config.py 2025-09-13 07:34:16 +03:00
Egor 43df9bff48 Update remnawave_api.py 2025-09-13 07:33:29 +03:00
Egor c15a9ea7cd Update README.md 2025-09-13 06:57:19 +03:00
Egor 271742b306 Update main.py 2025-09-13 06:47:50 +03:00
Egor ebdadd81c0 Update Dockerfile 2025-09-13 05:50:25 +03:00
Egor 1364855303 Update docker-registry.yml 2025-09-13 05:50:14 +03:00
Egor b5d862b231 Update docker-hub.yml 2025-09-13 05:49:57 +03:00
Egor 048a8adaa1 Update README.md 2025-09-13 05:49:23 +03:00
Egor 4b7b999d43 Правка статистики серверов 2025-09-13 05:47:57 +03:00
Egor d02fa83af5 Merge pull request #64 from Fr1ngg/dev
Исправление синка счетчика юзеров на серверах в админ панели в разделе с подписками юзеров
2025-09-13 05:43:13 +03:00
Egor 89045095b1 Исправление синка счетчика юзеров на серверах в админ панели в разделе с подписками юзеров 2025-09-13 05:42:27 +03:00
Egor 0de721e67d Merge pull request #63 from Fr1ngg/dev
Изменение отображения юзеров в админке
2025-09-13 05:16:03 +03:00
Egor c7c171a1d3 Update users.py 2025-09-13 05:15:04 +03:00
Egor a2500148fd Merge pull request #62 from Fr1ngg/dev
Возможность скрыть ссылку на подписку в разделе с подпиской
2025-09-13 05:03:47 +03:00
Egor 99b8a3726b Update README.md 2025-09-13 05:02:44 +03:00
Egor 8b426e0f6c Update .env.example 2025-09-13 05:01:37 +03:00
Egor caca561997 Update subscription.py 2025-09-13 04:58:38 +03:00
Egor 385bd78021 Update .env.example 2025-09-13 04:57:33 +03:00
Egor 8ca127e93f Update config.py 2025-09-13 04:57:00 +03:00
Egor ac9b8aaf68 Merge pull request #61 from Fr1ngg/dev
Правки в админском меню
2025-09-13 04:49:29 +03:00
Egor 4939f56f1b Update states.py 2025-09-13 04:47:50 +03:00
Egor 659e950722 Update states.py 2025-09-13 04:44:30 +03:00
Egor 5186ae4334 Update main.py 2025-09-13 04:42:42 +03:00
Egor 64cd38e2c3 Update admin.py 2025-09-13 04:41:46 +03:00
Egor cc29c887c5 Merge pull request #60 from Legacyyy777/DEV
Первый раз в первый класс
2025-09-13 04:28:48 +03:00
Egor 7fd7dd7cc8 Delete .github/workflows/deploy.yaml 2025-09-13 04:28:09 +03:00
Legacyyy777 9ac3088b72 Добавлено отображение текущего количества онлайн-пользователей VPN в админ-панели. Обработаны возможные ошибки при получении статистики. 2025-09-13 04:17:29 +05:00
Legacyyy777 9745885f1b Create deploy.yaml 2025-09-13 03:37:04 +05:00
yazhog 818189e8e0 Merge branch 'Fr1ngg:main' into main 2025-09-13 00:53:39 +03:00
Egor 2ccaaebfc9 Update Dockerfile 2025-09-13 00:45:49 +03:00
yazhog 82e75f2170 Delete .github/workflows/deploy.yml 2025-09-13 00:25:08 +03:00
yazhog 1b265fd95b Update deploy.yml 2025-09-12 23:52:01 +03:00
yazhog 7a8905e0bc Update deploy.yml 2025-09-12 23:50:37 +03:00
yazhog 6d8ba6d30c Create deploy.yml 2025-09-12 23:38:19 +03:00
Egor 74544c9a9e Update Dockerfile 2025-09-12 22:52:41 +03:00
Egor 3314bb7694 Update docker-registry.yml 2025-09-12 22:52:31 +03:00
Egor c920bac33e Update docker-hub.yml 2025-09-12 22:52:14 +03:00
Egor e6881ef617 Update .env.example 2025-09-12 22:07:28 +03:00
Egor 94a7bb43f6 Update README.md 2025-09-12 21:54:15 +03:00
Egor 97841f0cbe Merge pull request #59 from Fr1ngg/dev
Активация/Деактивация приветственного сообщения
2025-09-12 21:47:04 +03:00
Egor de392bd4de Update start.py 2025-09-12 21:45:11 +03:00
Egor f2a3e5f2fc Update universal_migration.py 2025-09-12 21:27:34 +03:00
Egor 20a2566fe6 Update start.py 2025-09-12 21:23:47 +03:00
Egor 96bd411839 Update admin.py 2025-09-12 21:21:21 +03:00
Egor 773196fd84 Update welcome_text.py 2025-09-12 21:20:35 +03:00
Egor 26dc8b4963 Update welcome_text.py 2025-09-12 21:19:43 +03:00
Egor 06a9202c7c Update models.py 2025-09-12 21:18:06 +03:00
Egor 634786917d Merge pull request #58 from yazhog/main
Пропуск правил, рефкода при регистрации, отправка бэкап файлов в тг
2025-09-12 19:16:34 +03:00
yazhog 4ea839c30a Merge pull request #15 from yazhog/codex/add-telegram-backup-notification-settings
feat: send backups to telegram
2025-09-12 13:55:26 +03:00
yazhog 10f6ea382b feat: caption backup send 2025-09-12 13:52:53 +03:00
yazhog 929414e31f fix: send backup file without caption 2025-09-12 13:47:27 +03:00
yazhog cd87a3e773 chore: clarify backup env comment 2025-09-12 13:31:36 +03:00
yazhog 7a3168dcfd Merge pull request #14 from yazhog/codex/add-skip-rules-and-referral-options-to-.env
feat: allow skipping rules and referral prompts
2025-09-12 10:48:58 +03:00
yazhog 888f8fb01a Пропуск окна активации теста при регистрации 2025-09-12 10:37:03 +03:00
yazhog 6f06ec5aa0 feat: add skip options for rules and referral 2025-09-12 10:35:47 +03:00
Egor e10cb3f2bb Update Dockerfile 2025-09-12 09:51:59 +03:00
Egor 2edd3cb5ab Update docker-registry.yml 2025-09-12 09:51:49 +03:00
Egor d6d3dc83f5 Update docker-hub.yml 2025-09-12 09:51:24 +03:00
Egor ae392691b1 Merge pull request #57 from Fr1ngg/dev
🔄 Переключение лимита трафика
2025-09-12 09:11:21 +03:00
Egor 35a202d280 Add files via upload
🔄 Переключение лимита трафика
2025-09-12 09:09:32 +03:00
Egor e9a2d8a232 Update texts.py 2025-09-12 09:07:16 +03:00
Egor b76b99ca2e Update inline.py 2025-09-12 09:06:52 +03:00
Egor 8960ea9c10 Update subscription.py 2025-09-12 09:06:25 +03:00
Egor 9f94acb580 Merge pull request #56 from Fr1ngg/dev
Индивидуальный сброс устройств
2025-09-12 08:34:50 +03:00
Egor c9811fd518 Update inline.py 2025-09-12 08:32:29 +03:00
Egor e25fb1b178 Update subscription.py 2025-09-12 08:31:30 +03:00
Egor 779a03d008 Merge pull request #55 from Fr1ngg/dev
Переработка меню управлением кол-вом устройств, добавлена возможность уменьшить кол-во устройств на подписке
2025-09-12 04:19:46 +03:00
Egor f2a972aeea Update texts.py 2025-09-12 04:18:21 +03:00
Egor 2a9163a28b Update inline.py 2025-09-12 04:17:48 +03:00
Egor 20a628089d Update subscription.py 2025-09-12 04:16:07 +03:00
Egor cf01d917d1 Merge pull request #54 from yazhog/main
Все сообщения бота с прикрепленным логотипом
2025-09-12 02:51:24 +03:00
yazhog 1544da7e12 Образец логотипа 2025-09-11 22:44:06 +03:00
yazhog 94a533508e Merge pull request #13 from yazhog/codex/find-message-handling-functions-in-bot
Send referral messages as images
2025-09-11 22:40:56 +03:00
yazhog 98cdfcff81 merge main 2025-09-11 22:40:24 +03:00
yazhog 23ddaa81be Set connect button mode to guide 2025-09-11 22:29:31 +03:00
yazhog 667e988c47 refactor: add logo file setting 2025-09-11 22:29:26 +03:00
yazhog ea6b20fefa feat: toggle logo mode 2025-09-11 21:50:29 +03:00
Egor e734763eaf Update Dockerfile 2025-09-11 05:28:23 +03:00
Egor cb98efc47e Update docker-registry.yml 2025-09-11 05:28:07 +03:00
Egor dbd3d87779 Update docker-hub.yml 2025-09-11 05:27:50 +03:00
Egor 533ae6dd80 Update admin.py 2025-09-11 05:27:10 +03:00
Egor ca62321b42 Merge pull request #52 from Fr1ngg/dev
Редактируемое приветственное сообщение
2025-09-11 05:16:41 +03:00
Egor cd3d2c3731 Update README.md 2025-09-11 05:14:22 +03:00
Egor 8b8d29a75d Update start.py 2025-09-11 05:09:30 +03:00
Egor 3f97757ec0 Update welcome_text.py 2025-09-11 05:08:33 +03:00
Egor d5f3299bd2 Update welcome_text.py 2025-09-11 05:07:52 +03:00
Egor 0b99965731 Update universal_migration.py 2025-09-11 05:04:08 +03:00
Egor 8f679832fb Update welcome_text.py 2025-09-11 04:57:49 +03:00
Egor 46564a8e91 Update welcome_text.py 2025-09-11 04:55:08 +03:00
Egor 09b0dbb60c Update welcome_text.py 2025-09-11 04:54:22 +03:00
Egor 28d53e7e3f Update welcome_text.py 2025-09-11 04:49:34 +03:00
Egor 43de39f4ec Update admin.py 2025-09-11 04:45:40 +03:00
Egor 49df0100c9 Update welcome_text.py 2025-09-11 04:43:51 +03:00
Egor c020f33c86 Update welcome_text.py 2025-09-11 04:33:08 +03:00
Egor 813daef2b1 Update start.py 2025-09-11 04:27:27 +03:00
Egor 13d4e934ec Update welcome_text.py 2025-09-11 04:25:07 +03:00
Egor 7721464611 Update admin.py 2025-09-11 04:18:18 +03:00
Egor 9e42c3e7d1 Update welcome_text.py 2025-09-11 04:17:17 +03:00
Egor badbc150ab Update welcome_text.py 2025-09-11 04:16:05 +03:00
Egor 5d57c88e4b Update bot.py 2025-09-11 04:06:13 +03:00
Egor ec18f82d26 Update start.py 2025-09-11 04:04:08 +03:00
Egor 846e5e6bf0 Update states.py 2025-09-11 04:02:12 +03:00
Egor 16b6df4d05 Update admin.py 2025-09-11 04:01:31 +03:00
Egor 9e45bd6d75 Update models.py 2025-09-11 04:00:04 +03:00
Egor d01436bf14 Create welcome_text.py 2025-09-11 03:59:24 +03:00
Egor f50e170f8d Create welcome_text.py 2025-09-11 03:58:34 +03:00
Egor 2dd76d8bd2 Update README.md 2025-09-11 03:39:30 +03:00
Egor b9956391f0 Update README.md 2025-09-11 03:37:44 +03:00
Egor fdfd0e2355 Merge pull request #51 from Fr1ngg/dev
Бекапы/Восстановление
2025-09-11 03:32:44 +03:00
Egor 548dd2fdc6 Update Dockerfile 2025-09-11 03:31:44 +03:00
Egor cdcdd7abdc Update README.md 2025-09-11 03:31:18 +03:00
Egor d5c8795e75 Delete app/data/2 2025-09-11 03:25:20 +03:00
Egor ceb0ac7538 Create 2 2025-09-11 03:25:04 +03:00
Egor 305026c37d Update Dockerfile 2025-09-11 03:14:30 +03:00
Egor f95367d12e Update .env.example 2025-09-11 03:09:02 +03:00
Egor ac962c43e8 Update backup_service.py 2025-09-11 03:03:25 +03:00
Egor 26efc70042 Update backup.py 2025-09-11 03:02:48 +03:00
Egor 7f431e1485 Update backup_service.py 2025-09-11 02:32:44 +03:00
Egor 1292046646 Update backup_service.py 2025-09-11 02:20:32 +03:00
Egor d54b455007 Update backup_service.py 2025-09-11 02:09:45 +03:00
Egor ca55d8942e Update backup.py 2025-09-11 01:59:37 +03:00
Egor f00fdcb7c8 Update backup.py 2025-09-11 01:55:56 +03:00
Egor 5443fdf064 Update requirements.txt 2025-09-10 22:57:35 +03:00
Egor c33e49661c Update config.py 2025-09-10 22:55:50 +03:00
Egor 92bd0c362e Update bot.py 2025-09-10 22:55:22 +03:00
Egor fe8cafdd99 Update main.py 2025-09-10 22:54:23 +03:00
Egor f0f8c4e4cf Update admin.py 2025-09-10 22:47:12 +03:00
Egor f8e2e23082 Create backup.py 2025-09-10 22:45:42 +03:00
Egor b915c65c41 Create backup_service.py 2025-09-10 22:42:35 +03:00
yazhog 4e7154e817 Fix QR detection for returning logo 2025-09-10 02:04:29 +03:00
yazhog 7992cf9e26 Change QR photo back to logo when leaving referral QR 2025-09-10 01:58:26 +03:00
yazhog ea958ea6c0 refactor referral handlers with photo helper 2025-09-10 01:48:41 +03:00
yazhog 9b32de5fb2 Avoid replacing media when editing captions 2025-09-10 01:37:43 +03:00
yazhog b71d0d058f Respond to referral QR callbacks immediately 2025-09-10 01:26:14 +03:00
yazhog d36e353d9b Patch message methods to send logo image 2025-09-10 01:17:30 +03:00
Egor 02106bd64b Update remnawave_api.py 2025-09-10 01:13:00 +03:00
yazhog e68d6ae19a Remove vpn logo asset from repo 2025-09-10 01:10:58 +03:00
Egor 16ae480430 Update Dockerfile 2025-09-10 00:26:52 +03:00
Egor 0b222c06a1 Update docker-hub.yml 2025-09-10 00:26:21 +03:00
Egor ff340e90d6 Update docker-registry.yml 2025-09-10 00:26:02 +03:00
Egor 4125141d01 Merge pull request #49 from yazhog/main
Режим link для ссылки подписки, фикс напоминаний о истечении подписки
2025-09-10 00:06:38 +03:00
yazhog 2f40ad53b5 Merge pull request #10 from yazhog/codex/implement-notification-tracking-system
Persist subscription expiry notifications
2025-09-09 23:36:56 +03:00
yazhog 2d3674eff7 Cascade delete sent notifications 2025-09-09 23:29:06 +03:00
yazhog 560e60b856 Persist subscription expiry notifications 2025-09-09 19:13:15 +03:00
yazhog 7cf20234b8 Merge branch 'Fr1ngg:main' into main 2025-09-09 18:52:42 +03:00
yazhog ecab0a0f45 Merge pull request #9 from yazhog/codex/add-connect_button_mode-for-link-subscription
feat: add link connect button mode
2025-09-09 18:42:32 +03:00
yazhog 587722ed5c feat: add link connect button mode 2025-09-09 13:32:20 +03:00
Egor bd7d9b3d5e Update docker-registry.yml 2025-09-09 11:05:30 +03:00
Egor 60787a2aa9 Merge pull request #48 from yazhog/main
Тест подписка сразу после старта бота, продление в любой момент
2025-09-09 10:38:59 +03:00
yazhog 9675ac3c14 Allow subscription renewal anytime 2025-09-09 10:21:13 +03:00
yazhog ca9a2f5997 Add extend subscription button for paid users 2025-09-09 10:21:13 +03:00
yazhog 652eb4f5a7 Respect renewal period settings in extension keyboard 2025-09-09 10:21:13 +03:00
yazhog 6ffbad747d Правка текстов 2025-09-09 10:21:13 +03:00
yazhog af46c596a9 Фикс текста 2025-09-09 10:21:13 +03:00
yazhog 1fcc218f26 Тексты поменял 2025-09-09 10:21:00 +03:00
yazhog 39a5d674c9 feat: add greeting and VPN advantages to trial offer 2025-09-09 10:19:25 +03:00
yazhog bdfe6ae1cf feat: add trial prompt after registration 2025-09-09 10:19:25 +03:00
yazhog 06e6c65ada Убрал про Ваш реферер получит... 2025-09-09 10:19:25 +03:00
Egor 8a7e4ff1c9 Update docker-registry.yml 2025-09-09 09:10:16 +03:00
Egor f0a1e1c338 Update docker-hub.yml 2025-09-09 09:09:57 +03:00
Egor 843af67483 Update Dockerfile 2025-09-09 09:09:31 +03:00
Egor 80d251c2d1 Update README.md 2025-09-09 09:02:04 +03:00
Egor a1c2907cce Update webhook_server.py 2025-09-09 08:56:15 +03:00
Egor c1369f113c Update README.md 2025-09-09 08:20:12 +03:00
Egor 8ea5bc5330 Update README.md 2025-09-09 08:18:02 +03:00
Egor 285dda4795 Merge pull request #47 from Fr1ngg/cp
Корректное удаление записей криптобота
2025-09-09 08:12:37 +03:00
Egor fae423177b Update user_service.py 2025-09-09 08:11:28 +03:00
Egor 51bf43289d Merge pull request #46 from Fr1ngg/cp
Интеграция Cryptobot
2025-09-09 08:03:32 +03:00
Egor 3fd87eee9a Update README.md 2025-09-09 08:00:57 +03:00
Egor ad14ab5d08 Update universal_migration.py 2025-09-09 07:42:45 +03:00
Egor aab68767fd Update payment_service.py 2025-09-09 07:31:52 +03:00
Egor 84f10229da Update balance.py 2025-09-09 07:27:10 +03:00
Egor 3a6ce31103 Create currency_converter.py 2025-09-09 07:25:39 +03:00
Egor da7675a853 Update payment_service.py 2025-09-09 07:14:33 +03:00
Egor 09726f8df0 Update universal_migration.py 2025-09-09 07:04:49 +03:00
Egor 8072ce6954 Update universal_migration.py 2025-09-09 06:59:37 +03:00
Egor 1e870e49c8 Update universal_migration.py 2025-09-09 06:55:32 +03:00
Egor eeca712a3e Update webhook_server.py 2025-09-09 06:48:30 +03:00
Egor 29c3e455e4 Update .env.example 2025-09-09 06:33:21 +03:00
Egor c23a777783 Update webhook_server.py 2025-09-09 06:32:10 +03:00
Egor 0439279602 Update inline.py 2025-09-09 06:31:21 +03:00
Egor 27c81d451b Update balance.py 2025-09-09 06:30:11 +03:00
Egor 339881f288 Update payment_service.py 2025-09-09 06:27:50 +03:00
Egor ef6bfb8ea3 Update models.py 2025-09-09 06:22:31 +03:00
Egor 4bd33fbbe2 Update config.py 2025-09-09 06:21:12 +03:00
Egor 8a5f097808 Update config.py 2025-09-09 06:20:26 +03:00
Egor 239eb9705d Create cryptobot.py 2025-09-09 06:19:32 +03:00
Egor fee141ea2b Create cryptobot.py 2025-09-09 06:18:24 +03:00
Egor ec7c121e03 Update user_service.py 2025-09-09 05:32:33 +03:00
Egor 8b047fd18c Update user_service.py 2025-09-09 05:31:42 +03:00
Egor 0c04868bef Update universal_migration.py 2025-09-09 05:26:10 +03:00
Egor deaf16e6ae Update user_service.py 2025-09-09 05:18:23 +03:00
Egor 389cac3e0a Create .dockerignore 2025-09-09 04:30:52 +03:00
Egor c04968818f Update Dockerfile 2025-09-09 04:29:55 +03:00
Egor 3a391d79d2 Update docker-registry.yml 2025-09-09 04:29:46 +03:00
Egor d1a8019c31 Update models.py 2025-09-08 20:22:38 +03:00
Egor 1c211b0a41 Update README.md 2025-09-08 20:16:31 +03:00
Egor 7c1ba6d92b Update README.md 2025-09-08 10:33:50 +03:00
Egor c0953eecf3 Update docker-registry.yml 2025-09-08 09:52:34 +03:00
Egor 7f70826023 Update docker-hub.yml 2025-09-08 09:52:06 +03:00
Egor a7aaa6fc88 Update Dockerfile 2025-09-08 09:51:29 +03:00
Egor a72b3bf705 Update version_service.py 2025-09-08 09:47:52 +03:00
Egor 71656df0bd Update version_service.py 2025-09-08 09:08:26 +03:00
Egor 7d5cff0aa9 Update version_service.py 2025-09-08 09:06:17 +03:00
Egor 4e27396a09 Update version_service.py 2025-09-08 08:51:34 +03:00
Egor 52a72a11b0 Update docker-registry.yml 2025-09-08 08:18:55 +03:00
Egor c17886489b Update Dockerfile 2025-09-08 07:46:58 +03:00
Egor ade2f106ba Update Dockerfile 2025-09-08 07:15:42 +03:00
Egor a096b2bc1a Merge pull request #43 from Fr1ngg/dev
Проверка обновлений
2025-09-08 06:45:52 +03:00
Egor de65390680 Update .env.example 2025-09-08 06:44:01 +03:00
Egor bd5f92c121 Update Dockerfile 2025-09-08 06:36:57 +03:00
Egor ad53a6bc09 Update requirements.txt 2025-09-08 06:36:20 +03:00
Egor f3b2c8056e Update Dockerfile 2025-09-08 06:36:05 +03:00
Egor 1e90903d54 Update bot.py 2025-09-08 06:22:21 +03:00
Egor 8537875537 Update admin.py 2025-09-08 06:19:31 +03:00
Egor a988e562a0 Update main.py 2025-09-08 06:17:43 +03:00
Egor d014a54181 Update config.py 2025-09-08 06:13:08 +03:00
Egor 4e5d57bf92 Update admin_notification_service.py 2025-09-08 06:11:53 +03:00
Egor 6f8ebbb9f0 Create updates.py 2025-09-08 06:10:34 +03:00
Egor 131ac32596 Create version_service.py 2025-09-08 06:09:01 +03:00
Egor 56017d023c Update docker-registry.yml 2025-09-08 05:57:46 +03:00
Egor 3afdc6cd31 Update docker-hub.yml 2025-09-08 05:57:22 +03:00
Egor 52a89eac77 Merge pull request #42 from yazhog/main
Фикс косяков с начислениями при первом пополнении
2025-09-08 00:10:30 +03:00
yazhog 0cdd311832 Merge pull request #6 from yazhog/codex/add-button-to-top-up-balance
feat: add top up prompt on insufficient balance
2025-09-07 22:17:25 +03:00
yazhog 1d5b52ef5b Переброс при недостаточно балансе на пополнение баланса 2025-09-07 22:16:49 +03:00
yazhog 3f37be4509 feat: add top up prompt on insufficient balance 2025-09-07 22:03:27 +03:00
yazhog 1636af1585 Merge pull request #5 from yazhog/codex/update-referral-bonus-calculation-logic
feat: calculate referral inviter bonus as max flat or percent
2025-09-07 21:07:33 +03:00
yazhog 127409955c feat: calculate referral inviter bonus as max flat or percent 2025-09-07 21:01:40 +03:00
Egor 423ce5e41b Update docker-compose.yml 2025-09-07 19:43:06 +03:00
Egor 770b7e8358 Update docker-compose.yml 2025-09-07 19:40:53 +03:00
Egor 0daf08ceed Update docker-compose.yml 2025-09-07 19:33:22 +03:00
Egor 1cda2259c6 Update config.py 2025-09-07 19:30:46 +03:00
Egor 5879080f33 Update .env.example 2025-09-07 19:26:54 +03:00
Egor cd3ad8bff9 Merge pull request #41 from yazhog/main
Убрал копейки + QR код для рефералки
2025-09-07 18:26:46 +03:00
yazhog 033a748069 Эмодзи на кнопках потерялись 2025-09-07 14:08:53 +03:00
yazhog de890dbd7f Merge pull request #4 from yazhog/codex/-qr
Add referral QR code generation
2025-09-07 13:53:43 +03:00
yazhog 7c5ae09a6e Refactor QR code display to edit message 2025-09-07 13:48:22 +03:00
yazhog e587e3b087 Fix back navigation after QR code 2025-09-07 13:35:19 +03:00
yazhog d698b3cfe7 Show referral QR code via screen transition 2025-09-07 13:30:41 +03:00
yazhog a01680fa2d Add referral QR code generation 2025-09-07 13:23:49 +03:00
yazhog 531fc0fb22 Merge pull request #3 from yazhog/codex/remove-decimal-points-in-ruble-display
feat: display ruble prices without kopecks
2025-09-07 13:19:36 +03:00
yazhog bb8e513598 feat: display ruble prices without kopecks 2025-09-07 13:13:03 +03:00
Egor f56679cd9f Update validators.py 2025-09-07 11:15:13 +03:00
Egor 9885e050a1 Update README.md 2025-09-07 10:22:21 +03:00
Egor 68be5cbe4b Merge pull request #39 from Fr1ngg/dev
Антихлебная валидация HTML разметки при создании сообщения в меню
2025-09-07 08:45:21 +03:00
Egor 3315c83e0b Update user_messages.py 2025-09-07 08:38:25 +03:00
Egor ca9b6f4063 Update user_messages.py 2025-09-07 08:34:46 +03:00
Egor cc490b616f Update user_messages.py 2025-09-07 08:28:02 +03:00
Egor 988f9eb4eb Update validators.py 2025-09-07 08:21:14 +03:00
Egor c41eeb2c8b Update user_messages.py 2025-09-07 08:12:39 +03:00
Egor 881963ca7b Update validators.py 2025-09-07 08:11:55 +03:00
Egor db6733083c Update validators.py 2025-09-07 08:07:45 +03:00
Egor 77851b090a Merge pull request #38 from Fr1ngg/dev
Новый модуль, с настройками в админке. Выводит рандомные сообщения юзера, html поддержка
2025-09-07 06:36:30 +03:00
Egor e0ada505aa Update admin.py 2025-09-07 06:25:53 +03:00
Egor 3179f33b67 Update menu.py 2025-09-07 06:19:46 +03:00
Egor 9bbd72b65f Update menu.py 2025-09-07 06:13:18 +03:00
Egor 68ace1358b Update admin.py 2025-09-07 06:07:48 +03:00
Egor f7934fd0fe Update user_messages.py 2025-09-07 05:59:44 +03:00
Egor 15cbbc04ce Update user_messages.py 2025-09-07 05:54:15 +03:00
Egor d4260106cb Update user_messages.py 2025-09-07 05:49:31 +03:00
Egor 0a46bd18db Update user_messages.py 2025-09-07 05:43:36 +03:00
Egor 0ccbad4077 Update bot.py 2025-09-07 05:40:15 +03:00
Egor 013ce09846 Update bot.py 2025-09-07 05:37:10 +03:00
Egor 736ec95572 Update start.py 2025-09-07 05:32:19 +03:00
Egor bddf36261d Create user_messages.py 2025-09-07 05:24:30 +03:00
Egor cbd74049dd Update admin.py 2025-09-07 05:22:34 +03:00
Egor 84f3db4548 Create user_message.py 2025-09-07 05:21:38 +03:00
Egor c07ccf0daf Update models.py 2025-09-07 05:20:29 +03:00
Egor 3ff4906214 Merge pull request #37 from Fr1ngg/dev
Dev
2025-09-07 04:41:57 +03:00
Egor 1bc5133c95 Update remnawave_api.py 2025-09-07 04:37:34 +03:00
Egor dcbad389b3 Update servers.py 2025-09-07 04:36:34 +03:00
Egor dfea057a25 Update maintenance.py 2025-09-07 04:35:59 +03:00
Egor 3a6bf029d1 Update admin_notification_service.py 2025-09-07 04:35:19 +03:00
Egor 667041558e Update maintenance_service.py 2025-09-07 04:34:58 +03:00
Egor 01e51f2dfd Update remnawave_service.py 2025-09-07 04:34:04 +03:00
Egor 483971d12f Update remnawave_service.py 2025-09-07 04:28:40 +03:00
Egor fad93dab53 Update maintenance.py 2025-09-07 04:11:36 +03:00
Egor ff13b7685b Update admin_notification_service.py 2025-09-07 04:10:54 +03:00
Egor 4281701957 Update remnawave_service.py 2025-09-07 04:09:29 +03:00
Egor a0ff5dbcd4 Update admin.py 2025-09-07 03:59:50 +03:00
Egor ed9ac80e68 Update maintenance.py 2025-09-07 03:57:25 +03:00
Egor e9662b9b42 Update remnawave_service.py 2025-09-07 03:55:34 +03:00
Egor ba3fc35a14 Update admin_notification_service.py 2025-09-07 03:50:51 +03:00
Egor 18471f0162 Update maintenance_service.py 2025-09-07 03:48:30 +03:00
Egor 71073fd0e5 Update tribute_service.py 2025-09-07 03:38:02 +03:00
Egor 2e1ee6bb28 Update payment_service.py 2025-09-07 03:33:19 +03:00
Egor 239f14acaf Update subscription.py 2025-09-07 03:24:06 +03:00
Egor e87a62c180 Update admin_notification_service.py 2025-09-07 03:19:29 +03:00
Egor cb275fc4b6 Update admin_notification_service.py 2025-09-07 03:17:01 +03:00
Egor 4042dee884 Update README.md 2025-09-07 03:12:47 +03:00
Egor 2d3dc8e024 Update remnawave.py 2025-09-07 01:04:23 +03:00
Egor 530759fc81 Merge pull request #36 from yazhog/main
Фикс присылания рефкода сообщением
2025-09-07 00:45:50 +03:00
yazhog 0f29850d7f Merge pull request #2 from yazhog/codex/fix-handling-of-unregistered-users
Fix auth middleware referral state handling
2025-09-07 00:18:40 +03:00
yazhog e060c39790 Fix state detection for registration in auth middleware 2025-09-07 00:16:29 +03:00
yazhog 612d7ad529 Allow messages in registration states 2025-09-07 00:08:27 +03:00
Egor a310e88905 Merge pull request #35 from yazhog/main
Проверка подписи API ключем для Tribute
2025-09-06 23:57:46 +03:00
yazhog bda959e8d6 Merge pull request #1 from yazhog/codex/add-payment-signature-validation
feat: validate Tribute webhook signatures
2025-09-06 23:36:33 +03:00
Egor bd9d12c185 Merge pull request #34 from yazhog/main
Исправление начисления партнерки за первый платеж реферала
2025-09-06 23:31:08 +03:00
yazhog adf509dc81 chore: require Tribute signature 2025-09-06 23:11:18 +03:00
yazhog 22f5fc43dd feat(tribute): verify webhook signature 2025-09-06 23:02:57 +03:00
root 49b10b2d4a Исправление начисления партнерки за первый платеж реферала 2025-09-06 22:57:59 +03:00
root 2f218b7567 Сборка вместо latest образа 2025-09-06 22:50:47 +03:00
Egor 1bb0894894 Delete screens directory 2025-09-06 19:40:38 +03:00
Egor 39da15fec2 Merge pull request #33 from Fr1ngg/dev
Переключение типа подписки
2025-09-06 19:18:10 +03:00
Egor 9ff66b9650 Update users.py 2025-09-06 19:11:21 +03:00
Egor c5353b46b1 Update users.py 2025-09-06 19:07:12 +03:00
Egor 25aa214ffa Update users.py 2025-09-06 19:03:55 +03:00
Egor 28279b7ec2 Update README.md 2025-09-06 18:06:17 +03:00
Egor 126170bee7 Update docker-compose.yml 2025-09-06 18:03:23 +03:00
Egor 8ce7935d49 Update Dockerfile 2025-09-06 18:02:05 +03:00
Egor f756c9a7f9 Update user.py 2025-09-06 17:48:53 +03:00
Egor a59905b98f Update referral_service.py 2025-09-06 17:37:40 +03:00
Egor f0d74185a1 Update user.py 2025-09-06 17:34:09 +03:00
Egor 06937e026f Update tribute_service.py 2025-09-06 17:30:45 +03:00
Egor 65acaf7058 Update tribute_service.py 2025-09-06 17:02:29 +03:00
Egor 7fc574daf3 Update README.md 2025-09-06 07:13:53 +03:00
Egor a93eb8d2d1 Update tribute_service.py 2025-09-06 07:08:24 +03:00
Egor 9fbd15f700 Merge pull request #32 from Fr1ngg/dev
Dev
2025-09-06 07:03:27 +03:00
Egor cec96d866c Update tribute_service.py 2025-09-06 07:01:07 +03:00
Egor 80031306a4 Update tribute_service.py 2025-09-06 06:57:18 +03:00
Egor 9783781bfb Merge pull request #31 from Fr1ngg/dev
Dev
2025-09-06 06:42:23 +03:00
Egor aba7302833 Update tribute_service.py 2025-09-06 06:39:45 +03:00
Egor 61ce10c52e Update transaction.py 2025-09-06 06:36:45 +03:00
Egor f307ad03bf Update README.md 2025-09-06 06:06:31 +03:00
Egor f150bfad37 Merge pull request #30 from Fr1ngg/dev
Уведомления в канал об: 🎯 Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную  Продление подписки 💰 Пополнение баланса
2025-09-06 05:57:58 +03:00
Egor 8c22df1f52 Update .env.example 2025-09-06 05:55:47 +03:00
Egor f396ca7df4 Update README.md 2025-09-06 05:54:36 +03:00
Egor 0b1a2b7f68 Update user_service.py 2025-09-06 05:45:04 +03:00
Egor d92b9559ac Update admin_notification_service.py 2025-09-06 05:37:02 +03:00
Egor fb3942e29d Update subscription.py 2025-09-06 05:20:00 +03:00
Egor 6009d15a51 Update transaction.py 2025-09-06 05:13:16 +03:00
Egor e2469a14c9 Update payment_service.py 2025-09-06 05:11:58 +03:00
Egor 4daacae0d9 Update balance.py 2025-09-06 05:09:31 +03:00
Egor 47dbced347 Update balance.py 2025-09-06 05:08:58 +03:00
Egor 49b129a8f4 Update subscription.py 2025-09-06 05:03:12 +03:00
Egor f8315ba18e Update config.py 2025-09-06 04:55:46 +03:00
Egor a9809e74c9 Update config.py 2025-09-06 04:55:16 +03:00
Egor 4fe61ec870 Create admin_notification_service.py 2025-09-06 04:54:48 +03:00
Egor 172ee773ee Update README.md 2025-09-06 04:47:29 +03:00
Egor b8edc4ec0d Update README.md 2025-09-06 04:45:36 +03:00
Egor d8f2f413a8 Update README.md 2025-09-06 03:52:34 +03:00
Egor 360a04fbe8 Update .env.example 2025-09-06 03:52:01 +03:00
Egor 2613d42b57 Merge pull request #29 from Fr1ngg/dev
Правки реф в админке, промокодах, в разделе с подписками. Поправлены кнопки обновления во всех разделах, вырезаны лишние кнопки. Добавлена модель бд для отслеживания конверсии перехода из триальных в платные подписки
2025-09-06 02:40:43 +03:00
Egor a06d55af38 Update subscriptions.py 2025-09-06 02:34:32 +03:00
Egor bf28617d99 Update servers.py 2025-09-06 02:32:32 +03:00
Egor 878a28cb11 Update subscription_conversion.py 2025-09-06 02:24:18 +03:00
Egor ade062c7cd Update subscription.py 2025-09-06 02:19:51 +03:00
Egor 5d774bd1aa Update subscription.py 2025-09-06 02:15:29 +03:00
Egor 8d81bfcf94 Update universal_migration.py 2025-09-06 02:10:10 +03:00
Egor 095c3a8490 Update subscription.py 2025-09-06 02:05:49 +03:00
Egor f7c8c82775 Update subscription.py 2025-09-06 02:04:17 +03:00
Egor 3d75543cf0 Create subscription_conversion.py 2025-09-06 02:03:04 +03:00
Egor 1223948596 Update models.py 2025-09-06 02:02:02 +03:00
Egor f12964ecc8 Update subscription.py 2025-09-06 01:54:51 +03:00
Egor 693dce2e28 Update subscription.py 2025-09-06 01:46:49 +03:00
Egor 166050c581 Update users.py 2025-09-06 01:29:14 +03:00
Egor d499574c4d Update referral.py 2025-09-06 01:27:27 +03:00
Egor 98363e2c28 Update users.py 2025-09-06 01:21:53 +03:00
Egor d87622417a Update promocodes.py 2025-09-06 01:07:52 +03:00
Egor 8be1af5adb Update admin.py 2025-09-06 01:06:55 +03:00
Egor 72a2bb7d04 Merge pull request #28 from Fr1ngg/dev
Правка раздела рефералов в админка, его починка, актуализация данных и доработка + правки раздела статистики в админке
2025-09-06 01:02:19 +03:00
Egor a847d4c1f6 Update statistics.py 2025-09-06 00:58:43 +03:00
Egor b860752d80 Update referrals.py 2025-09-06 00:53:34 +03:00
Egor 0a466b973b Update statistics.py 2025-09-06 00:52:50 +03:00
Egor 9ca0ca2db2 Update referrals.py 2025-09-06 00:39:33 +03:00
Egor af3f746ae1 Update referrals.py 2025-09-06 00:31:15 +03:00
Egor c64c53a26b Update referral.py 2025-09-06 00:30:13 +03:00
Egor 765e912dd1 Update referrals.py 2025-09-06 00:23:42 +03:00
Egor f205acfb0a Update referral.py 2025-09-06 00:16:51 +03:00
Egor 3c424143dc Update referrals.py 2025-09-06 00:11:51 +03:00
Egor ba87ffa0be Update referrals.py 2025-09-06 00:03:09 +03:00
Egor f211c93794 Merge pull request #27 from Fr1ngg/dev
Доработка статистики рефералов, плюс уведомления для рефералов и приглашаюших
2025-09-05 23:15:18 +03:00
Egor 8994e3e9ba Update inline.py 2025-09-05 23:03:19 +03:00
Egor 6018c07c0d Update start.py 2025-09-05 22:57:59 +03:00
Egor 41bfffdf12 Update start.py 2025-09-05 22:52:07 +03:00
Egor 1a06b88db9 Update inline.py 2025-09-05 22:48:27 +03:00
Egor bd6d9013ae Update referral_service.py 2025-09-05 22:47:48 +03:00
Egor 093d178bf8 Update referral.py 2025-09-05 22:46:08 +03:00
Egor 6169f34911 Update user_utils.py 2025-09-05 22:33:13 +03:00
Egor 3aca29777c Update user_utils.py 2025-09-05 22:28:32 +03:00
Egor c7f2b0752b Update referral.py 2025-09-05 22:21:22 +03:00
Egor e113b7e842 Update config.py 2025-09-05 22:18:10 +03:00
Egor 359bbaa31c Update config.py 2025-09-05 22:17:02 +03:00
Egor 862aeee558 Update config.py 2025-09-05 22:16:19 +03:00
Egor 4c3a3b91a6 Update user.py 2025-09-05 22:15:41 +03:00
Egor 199e78cb25 Update inline.py 2025-09-05 22:14:25 +03:00
Egor 87845e2691 Update payment_service.py 2025-09-05 22:13:52 +03:00
Egor 0c30d1ea30 Update start.py 2025-09-05 22:12:16 +03:00
Egor 2f64aa0961 Update user_utils.py 2025-09-05 22:05:08 +03:00
Egor 2266609111 Update referral_service.py 2025-09-05 22:03:28 +03:00
Egor 28e9cf6ab7 Update referral.py 2025-09-05 18:51:37 +03:00
Egor 7c55c54f23 Merge pull request #26 from Fr1ngg/dev
Dev
2025-09-05 18:46:43 +03:00
Egor 060d0a6970 Update README.md 2025-09-05 18:45:41 +03:00
Egor f0465a9ea4 Update .env.example 2025-09-05 18:44:57 +03:00
Egor 29db1f4e56 Update user.py 2025-09-05 18:40:05 +03:00
Egor 755ef03f98 Update user.py 2025-09-05 18:32:09 +03:00
Egor 458b71ea28 Update user.py 2025-09-05 18:28:34 +03:00
Egor f1f409dc63 Update payment_service.py 2025-09-05 18:27:39 +03:00
Egor 64d0f9fc2d Update user.py 2025-09-05 18:17:45 +03:00
Egor 01db9036f5 Update referral_service.py 2025-09-05 18:08:33 +03:00
Egor 674e1b73f6 Update user.py 2025-09-05 18:06:27 +03:00
Egor 9bfb9bc711 Update subscription.py 2025-09-05 17:49:11 +03:00
Egor 96f905563f Update subscription.py 2025-09-05 17:45:58 +03:00
Egor 6b278de538 Update subscription.py 2025-09-05 17:41:26 +03:00
Egor c6fc770fc1 Update models.py 2025-09-05 17:25:53 +03:00
Egor e50f208064 Update models.py 2025-09-05 17:25:21 +03:00
Egor 637af5c216 Update models.py 2025-09-05 17:20:28 +03:00
Egor 08373be215 Update universal_migration.py 2025-09-05 16:46:30 +03:00
Egor 5e4c7e339a Update balance.py 2025-09-05 16:43:21 +03:00
Egor 476dbcca93 Update subscription.py 2025-09-05 16:42:15 +03:00
Egor e8e4330b1c Update referral.py 2025-09-05 16:39:34 +03:00
Egor d5c0146d48 Update balance.py 2025-09-05 16:38:41 +03:00
Egor 83c461230c Update referral_service.py 2025-09-05 16:37:17 +03:00
Egor f27a51639c Update models.py 2025-09-05 16:35:58 +03:00
Egor ab8d4f0340 Update config.py 2025-09-05 16:35:11 +03:00
Egor 1ee15bf6d3 Merge pull request #25 from yazhog/feature/change-buttons
Немного юзерфрендли
2025-09-05 14:41:46 +03:00
yazhog 56238ad93c Немного юзерфрендли 2025-09-05 14:22:02 +03:00
Egor 2fa2c6278c Update README.md 2025-09-05 05:56:00 +03:00
Egor 519978f634 Merge pull request #24 from Fr1ngg/dev
Изменение логики расчета цен за доп. услуги (трафик, сервера, устройства). Теперь рассчитывается цена за месяц
2025-09-05 02:45:20 +03:00
Egor 7095d8f015 Update inline.py 2025-09-05 02:25:19 +03:00
Egor e8dcfa0687 Update inline.py 2025-09-05 02:22:22 +03:00
Egor a0e12fa89a Update subscription.py 2025-09-05 02:21:40 +03:00
Egor 097be9aa0f Update inline.py 2025-09-05 02:13:47 +03:00
Egor f50b5296f4 Update subscription.py 2025-09-05 02:08:59 +03:00
Egor 14e94053ee Update inline.py 2025-09-05 02:07:40 +03:00
Egor 3c27fa4d90 Update subscription.py 2025-09-05 01:56:33 +03:00
Egor 2a78ace675 Update subscription.py 2025-09-05 01:40:31 +03:00
Egor 81774f87d8 Update subscription.py 2025-09-05 01:36:49 +03:00
Egor 6735c2b38f Update subscription_service.py 2025-09-05 01:30:49 +03:00
Egor 8bdc5a2332 Update subscription.py 2025-09-05 01:29:13 +03:00
Egor d26262bfbf Update subscription_service.py 2025-09-05 01:28:55 +03:00
Egor 48f2f01bdd Update __init__.py 2025-09-05 01:28:00 +03:00
Egor ef68a43589 Create pricing_utils.py 2025-09-05 01:27:26 +03:00
Egor 733c1b72ec Update subscription.py 2025-09-05 01:25:59 +03:00
Egor c9ca360286 Update subscription_service.py 2025-09-05 01:18:14 +03:00
Egor 5aad1e025b Update users.py 2025-09-04 21:56:27 +03:00
Egor e811e7c877 Merge pull request #22 from Fr1ngg/dev
Dev
2025-09-04 21:45:36 +03:00
Egor 932e9f292b Update admin.py 2025-09-04 21:42:20 +03:00
Egor 03749574d6 Update users.py 2025-09-04 21:33:09 +03:00
Egor 1059a51688 Update users.py 2025-09-04 21:30:31 +03:00
Egor a1963d5b17 Update users.py 2025-09-04 21:26:07 +03:00
Egor 426e736cd5 Update users.py 2025-09-04 21:18:18 +03:00
Egor fb03cff403 Update users.py 2025-09-04 21:11:28 +03:00
Egor 40780f1184 Update users.py 2025-09-04 21:00:58 +03:00
Egor c76290b51e Update users.py 2025-09-04 20:55:01 +03:00
Egor 5b6dd3058e Update users.py 2025-09-04 20:46:52 +03:00
Egor c8d91a1ec1 Update admin.py 2025-09-04 20:43:55 +03:00
Egor 21952f84eb Update states.py 2025-09-04 20:42:54 +03:00
Egor 7bdfaa312b Update users.py 2025-09-04 20:42:19 +03:00
Egor 1829a469e7 Merge pull request #21 from Fr1ngg/dev
Dev
2025-09-04 18:40:02 +03:00
Egor ddd37a4e98 Update monitoring_service.py 2025-09-04 18:38:44 +03:00
Egor 2804c701c0 Update monitoring_service.py 2025-09-04 18:35:59 +03:00
Egor b3c85b2db5 Delete app/handlers/subscription_notifications.py 2025-09-04 18:28:42 +03:00
Egor 987b8170c6 Update monitoring_service.py 2025-09-04 18:26:38 +03:00
Egor ab49f3cd1e Merge pull request #20 from Fr1ngg/dev
Dev
2025-09-04 16:50:49 +03:00
Egor 732e8f7b8b Update README.md 2025-09-04 16:48:09 +03:00
Egor 9273a4e15b Update .env.example 2025-09-04 16:47:42 +03:00
Egor d89293f76a Update user_service.py 2025-09-04 16:45:57 +03:00
Egor 174462183d Update config.py 2025-09-04 16:18:34 +03:00
Egor 783927be37 Update subscription_service.py 2025-09-04 16:13:58 +03:00
Egor 98c68dd8dc Update inline.py 2025-09-04 16:01:38 +03:00
Egor 13585eab54 Update config.py 2025-09-04 15:53:22 +03:00
Egor 169336da43 Update config.py 2025-09-04 15:48:36 +03:00
Egor 9ef875f6bf Update config.py 2025-09-04 15:46:36 +03:00
Egor 3f54706a72 Update config.py 2025-09-04 15:39:09 +03:00
Egor 835e5e6f7f Update config.py 2025-09-04 15:24:29 +03:00
Egor b292442e8d Update config.py 2025-09-04 15:16:56 +03:00
Egor 9faaf5c963 Update inline.py 2025-09-04 15:13:50 +03:00
Egor 117e88fe4a Update subscription.py 2025-09-04 15:04:07 +03:00
Egor 96bf08a07c Update inline.py 2025-09-04 15:02:59 +03:00
Egor 4523403de5 Update config.py 2025-09-04 15:01:25 +03:00
Egor 02682e88c1 Update subscription.py 2025-09-04 14:48:57 +03:00
Egor 323b9a7e18 Update subscription.py 2025-09-04 14:48:10 +03:00
Egor 100dc3730f Update subscription.py 2025-09-04 14:43:09 +03:00
Egor 919047317a Update subscription.py 2025-09-04 14:38:12 +03:00
Egor 6ae5b59ed1 Update subscription.py 2025-09-04 14:37:47 +03:00
Egor b3123e2e85 Update subscription.py 2025-09-04 14:34:09 +03:00
Egor fbe0ca790e Update subscription.py 2025-09-04 14:31:40 +03:00
Egor 785648ba7a Update subscription.py 2025-09-04 14:27:22 +03:00
Egor 06f8728efa Update inline.py 2025-09-04 14:21:00 +03:00
Egor 99f102a06e Update config.py 2025-09-04 14:19:23 +03:00
Egor d759dd3a66 Update user_service.py 2025-09-04 13:58:58 +03:00
Egor 73dc22b0b8 Update texts.py 2025-09-04 04:27:33 +03:00
Egor 5a082f0eb8 Update texts.py 2025-09-04 04:20:58 +03:00
Egor 073d5a119b Update subscription.py 2025-09-04 04:20:36 +03:00
Egor 2dab106bef Update README.md 2025-09-04 04:13:00 +03:00
Egor ec8c7b7242 Update .env.example 2025-09-04 04:12:46 +03:00
Egor a4b96a089f Update user_service.py 2025-09-04 04:07:09 +03:00
Egor 269f8f6220 Update user_service.py 2025-09-04 04:04:19 +03:00
Egor f1beda2b89 Update user_service.py 2025-09-04 04:01:03 +03:00
Egor 6a00db32ae Update config.py 2025-09-04 03:28:51 +03:00
Egor 276bc5eb40 Update .env.example 2025-09-04 03:27:58 +03:00
Egor 8678afd055 Update subscription.py 2025-09-04 03:26:34 +03:00
Egor 31340ad4a5 Update subscription_service.py 2025-09-04 03:25:08 +03:00
Egor 98ce47ce71 Update inline.py 2025-09-04 03:24:07 +03:00
Egor 3462f5b3b1 Update subscription.py 2025-09-04 02:57:42 +03:00
Egor 7ec606f93c Update config.py 2025-09-03 21:11:15 +03:00
Egor 7d13bf47aa Update README.md 2025-09-03 21:08:32 +03:00
Egor 87773806fc Update .env.example 2025-09-03 21:08:09 +03:00
Egor dad6200ad8 Update bot.py 2025-09-03 21:07:40 +03:00
Egor c3991162c2 Update payment_service.py 2025-09-03 21:06:48 +03:00
Egor 08feb1f876 Update telegram_stars.py 2025-09-03 21:05:33 +03:00
Egor 6f15070052 Update balance.py 2025-09-03 21:05:13 +03:00
Egor 0c4e73ab92 Create stars_payments.py 2025-09-03 21:04:50 +03:00
Egor 48902c4968 Update telegram_stars.py 2025-09-03 20:57:01 +03:00
Egor 3c715577a6 Update telegram_stars.py 2025-09-03 20:55:25 +03:00
Egor 8e67c7a97a Update texts.py 2025-09-03 20:09:01 +03:00
Egor c938d48026 Update texts.py 2025-09-03 20:02:46 +03:00
Egor 99a4927f8a Update texts.py 2025-09-03 19:53:10 +03:00
Egor df5b8dcdcd Update inline.py 2025-09-03 19:52:51 +03:00
Egor d4a1a2e64d Update menu.py 2025-09-03 19:52:34 +03:00
Egor 5e2df269a3 Update start.py 2025-09-03 19:52:16 +03:00
Egor a2056e0441 Update docker-registry.yml 2025-09-03 18:38:28 +03:00
Egor a6bbd8f8fb Add files via upload 2025-09-03 06:39:35 +03:00
Egor 9f4e0cf5f8 Update menu.py 2025-09-03 06:34:44 +03:00
Egor c7e8365d3f Update start.py 2025-09-03 06:33:37 +03:00
Egor 49b10b490e Update inline.py 2025-09-03 06:29:20 +03:00
Egor f26bca8627 Update texts.py 2025-09-03 06:27:46 +03:00
Egor df139b3d34 Update texts.py 2025-09-03 06:22:09 +03:00
Egor 05d1edf278 Update menu.py 2025-09-03 06:21:21 +03:00
Egor a518df8ff0 Update menu.py 2025-09-03 06:16:38 +03:00
Egor 0a13fe4f40 Update menu.py 2025-09-03 06:13:15 +03:00
Egor 6410bd1085 Update menu.py 2025-09-03 06:03:26 +03:00
Egor 55c4549cff Update inline.py 2025-09-03 06:01:29 +03:00
Egor e898948073 Update texts.py 2025-09-03 05:59:21 +03:00
Egor 5f1c8d8ac0 Update README.md 2025-09-03 05:02:51 +03:00
Egor 8d1f8f2355 Update README.md 2025-09-03 04:56:05 +03:00
Egor fbbbad608b Update .env.example 2025-09-03 04:55:38 +03:00
Egor 03fc4abd2a Update subscription.py 2025-09-03 04:32:16 +03:00
Egor f280a37818 Update subscription.py 2025-09-03 04:25:31 +03:00
Egor f100ec62d4 Update subscription.py 2025-09-03 04:24:55 +03:00
Egor cd827612d9 Update subscription.py 2025-09-03 04:23:41 +03:00
Egor 50da2f82c8 Update subscription_service.py 2025-09-03 04:22:05 +03:00
Egor 4c814ec5f4 Update inline.py 2025-09-03 04:13:35 +03:00
Egor fb8c02d5ab Update subscription.py 2025-09-03 04:12:34 +03:00
Egor 465ad3d1ad Update inline.py 2025-09-03 04:10:55 +03:00
Egor 2bfaab7d16 Update subscription_service.py 2025-09-03 04:07:33 +03:00
Egor e356542df7 Update subscription.py 2025-09-03 00:46:11 +03:00
Egor a3dfbdce3b Update inline.py 2025-09-03 00:44:57 +03:00
Egor a666e8d248 Update subscription_service.py 2025-09-03 00:44:18 +03:00
Egor 9ef8df22a8 Update subscription.py 2025-09-03 00:43:54 +03:00
Egor a1471c8b9a Update subscription.py 2025-09-03 00:39:35 +03:00
Egor a99a14922c Update subscription_service.py 2025-09-03 00:17:11 +03:00
Egor ce954ad775 Update subscription.py 2025-09-03 00:15:42 +03:00
Egor efba7c10d0 Update subscription.py 2025-09-03 00:15:01 +03:00
Egor cd10c131b2 Update inline.py 2025-09-03 00:14:30 +03:00
Egor 3e4cb8dfa2 Update inline.py 2025-09-02 23:38:52 +03:00
Egor 6471ab547c Update subscription.py 2025-09-02 23:37:57 +03:00
Egor c10bf07124 Update inline.py 2025-09-02 23:32:14 +03:00
Egor 4e05882522 Update subscription.py 2025-09-02 23:30:51 +03:00
Egor 061b292d92 Update subscription.py 2025-09-02 23:28:30 +03:00
Egor 779a6c309a Update subscription.py 2025-09-02 23:24:16 +03:00
Egor c79c71f209 Update remnawave_api.py 2025-09-02 17:35:45 +03:00
Egor d4dbce5606 Update remnawave_api.py 2025-09-02 17:32:41 +03:00
Egor e53d99223d Update config.py 2025-09-02 15:32:45 +03:00
Egor ec7274e0c9 Update remnawave_api.py 2025-09-02 15:32:29 +03:00
Egor 8fe631a98e Update remnawave_api.py 2025-09-02 15:26:00 +03:00
Egor bd831bd92b Update config.py 2025-09-02 15:21:43 +03:00
Egor 9b31ba8f3d Update remnawave_api.py 2025-09-02 15:21:15 +03:00
Egor 3759be333a Update remnawave_api.py 2025-09-02 15:15:25 +03:00
Egor 555894b26c Update README.md 2025-09-02 14:55:47 +03:00
Egor bb636c003c Update .env.example 2025-09-02 14:50:48 +03:00
Egor 40c7b4aebd Update remnawave_api.py 2025-09-02 14:23:38 +03:00
Egor 028bd4888b Update remnawave_api.py 2025-09-02 14:10:01 +03:00
Egor 27d64e361a Update remnawave_api.py 2025-09-02 14:03:04 +03:00
Egor 6fc7dd49dd Update remnawave_api.py 2025-09-02 13:40:32 +03:00
Egor b835ba1d65 Update remnawave_api.py 2025-09-02 13:33:15 +03:00
Egor 811e985087 Update .env.example 2025-09-02 13:29:29 +03:00
Egor c16733b93d Update config.py 2025-09-02 13:29:13 +03:00
Egor 23ab3c5198 Update remnawave_service.py 2025-09-02 13:28:09 +03:00
Egor 9b8f121e69 Update remnawave_api.py 2025-09-02 13:25:49 +03:00
Egor 86b1b95fb9 Update subscription.py 2025-09-02 08:52:42 +03:00
Egor 9418681a00 Update subscription_service.py 2025-09-02 08:50:35 +03:00
Egor 7ebd937e2c Update README.md 2025-09-02 02:21:40 +03:00
Egor 25c404f999 Update README.md 2025-09-02 02:09:29 +03:00
Egor 6df92bde2a Update README.md 2025-09-02 00:34:13 +03:00
Egor 60ff006fec Update README.md 2025-09-02 00:33:03 +03:00
Egor 0181f5e10c Update docker-compose.yml 2025-09-02 00:30:33 +03:00
Egor 4bbcc60bbb Update subscriptions.py 2025-09-02 00:16:07 +03:00
Egor 8e2138e2aa Update subscriptions.py 2025-09-02 00:14:45 +03:00
Egor 70b9e8c10d Update inline.py 2025-09-01 23:44:56 +03:00
Egor dc1eddb0df Update subscription.py 2025-09-01 23:44:02 +03:00
Egor 630551cc4b Update README.md 2025-09-01 23:17:46 +03:00
Egor 4f94b25182 Update docker-compose.yml 2025-09-01 22:50:17 +03:00
Egor d3b240eae3 Update Dockerfile 2025-09-01 22:49:41 +03:00
Egor e2883d6727 Update main.py 2025-09-01 22:29:47 +03:00
Egor 21757b37ab Update bot.py 2025-09-01 22:27:37 +03:00
Egor 9190857ffa Update maintenance_service.py 2025-09-01 22:27:08 +03:00
Egor a607443aa7 Update README.md 2025-09-01 19:59:39 +03:00
Egor 41969bc0c2 Update .env.example 2025-09-01 19:59:20 +03:00
Egor 0d443afb87 Update balance.py 2025-09-01 19:58:21 +03:00
Egor 3d85294d41 Update config.py 2025-09-01 19:55:23 +03:00
Egor 86896771a6 Update inline.py 2025-09-01 05:02:10 +03:00
Egor 42d52990fa Update README.md 2025-09-01 04:34:16 +03:00
Egor 6296c8257f Update README.md 2025-09-01 04:13:29 +03:00
Egor 14a40cb1b6 Create docker-compose.yml 2025-09-01 04:10:55 +03:00
Egor d0e3650de5 Update config.py 2025-09-01 03:52:56 +03:00
Egor df8ed666ae Update menu.py 2025-09-01 03:51:29 +03:00
Egor 2217b7f800 Update config.py 2025-09-01 03:46:13 +03:00
Egor 924febc2f3 Update menu.py 2025-09-01 03:43:27 +03:00
Egor 729ffb7952 Update messages.py 2025-09-01 01:06:54 +03:00
Egor 1568ca7633 Update README.md 2025-09-01 00:11:27 +03:00
Egor 7d5e476580 Update referral.py 2025-08-31 23:18:00 +03:00
Egor 9bcd187358 Update referral.py 2025-08-31 23:14:44 +03:00
Egor a9cbb6272f Update referral.py 2025-08-31 23:07:27 +03:00
Egor b524decfa7 Update referral.py 2025-08-31 23:04:00 +03:00
Egor 5ee01f7735 Update referral.py 2025-08-31 23:00:20 +03:00
Egor f46f2d6523 Update start.py 2025-08-31 22:45:54 +03:00
Egor 435dbf6cee Update auth.py 2025-08-31 22:42:02 +03:00
Egor 94d38eeb71 Update auth.py 2025-08-31 22:41:31 +03:00
Egor 0e7f6f90ed Update auth.py 2025-08-31 22:40:41 +03:00
Egor f3789287dc Update start.py 2025-08-31 22:40:04 +03:00
Egor a8312d7c10 Update promocode.py 2025-08-31 22:10:21 +03:00
Egor 612f4a7978 Update promocodes.py 2025-08-31 22:07:04 +03:00
Egor 2462347b5f Update promocode.py 2025-08-31 22:06:15 +03:00
Egor 3ec3c0d76b Update promocodes.py 2025-08-31 22:05:16 +03:00
Egor 8224aa98f4 Update promocodes.py 2025-08-31 22:02:00 +03:00
Egor bb9d9422fb Update promocode.py 2025-08-31 22:01:16 +03:00
Egor f3a184ff00 Update promocodes.py 2025-08-31 21:53:25 +03:00
Egor 866e4586df Update promocode.py 2025-08-31 21:52:18 +03:00
Egor a30647893d Update promocodes.py 2025-08-31 21:39:14 +03:00
Egor 73826312e5 Update promocodes.py 2025-08-31 19:08:16 +03:00
Egor dae34dc0ef Update promocodes.py 2025-08-31 18:59:39 +03:00
Egor 17d0c8c26f Update promocodes.py 2025-08-31 18:50:55 +03:00
Egor 67831dc162 Update promocodes.py 2025-08-31 18:45:16 +03:00
Egor ae84fbe141 Update promocodes.py 2025-08-31 18:36:54 +03:00
Egor 8c9bd890e8 Update promocodes.py 2025-08-31 18:35:57 +03:00
Egor e8c1956de5 Update promocodes.py 2025-08-31 18:28:06 +03:00
Egor 1540a41d3a Update .env.example 2025-08-31 17:40:08 +03:00
Egor 9cb61cb696 Update README.md 2025-08-31 17:39:52 +03:00
Egor 3511325285 Update auth.py 2025-08-31 16:18:42 +03:00
Egor 62507c5d28 Update auth.py 2025-08-31 16:10:28 +03:00
Egor 387df29a05 Update start.py 2025-08-31 15:40:31 +03:00
Egor 75dca7e1ad Update start.py 2025-08-31 15:32:25 +03:00
Egor 2d5e54d5ad Update README.md 2025-08-31 06:52:42 +03:00
Egor d62b2b0d6f Update README.md 2025-08-31 06:50:22 +03:00
Egor e273719ad1 Update README.md 2025-08-31 06:49:41 +03:00
Egor f6726f1c94 Update README.md 2025-08-31 06:15:32 +03:00
Egor 92ff4f661f Update README.md 2025-08-31 05:54:54 +03:00
Egor dfebc5e608 Update .gitignore 2025-08-31 05:52:00 +03:00
Egor 8e6121cd0b Update README.md 2025-08-31 05:48:08 +03:00
Egor 6c0d5714bd Update remnawave_service.py 2025-08-31 05:19:43 +03:00
Egor ed29697434 Update remnawave_service.py 2025-08-31 05:18:56 +03:00
Egor 07aebef82f Update remnawave_service.py 2025-08-31 05:16:17 +03:00
Egor 7ce390fd50 Update remnawave_service.py 2025-08-31 05:09:31 +03:00
Egor cebe392d1c Update remnawave_service.py 2025-08-31 04:55:19 +03:00
Egor fd160f585a Update remnawave_service.py 2025-08-31 04:50:31 +03:00
Egor 5c0516fbc0 Update remnawave_service.py 2025-08-31 04:45:25 +03:00
Egor ba075326c1 Update admin.py 2025-08-31 04:39:08 +03:00
Egor c32425341e Update remnawave.py 2025-08-31 04:38:20 +03:00
Egor cc8df1eff3 Update remnawave_service.py 2025-08-31 04:33:56 +03:00
Egor 49cda0791c Update .env.example 2025-08-31 04:25:10 +03:00
Egor 60ea363a62 Update README.md 2025-08-31 04:24:37 +03:00
Egor f66619b225 Update subscription.py 2025-08-31 04:19:18 +03:00
Egor 5999402e1a Update inline.py 2025-08-31 04:17:18 +03:00
Egor 3aebccc090 Update inline.py 2025-08-31 04:16:37 +03:00
Egor ed577dd51a Update config.py 2025-08-31 04:15:45 +03:00
Egor e517487596 Update main.py 2025-08-31 04:03:37 +03:00
Egor f9441909a4 Update balance.py 2025-08-31 02:42:33 +03:00
Egor f13a015bfa Update payment_service.py 2025-08-31 00:47:07 +03:00
Egor 18bf946464 Update yookassa_webhook.py 2025-08-31 00:39:04 +03:00
Egor e4c46b2488 Update yookassa_webhook.py 2025-08-31 00:33:37 +03:00
Egor e0539b116c Update yookassa_webhook.py 2025-08-31 00:28:04 +03:00
Egor acdf7af1d9 Update yookassa_webhook.py 2025-08-31 00:22:18 +03:00
Egor d5b7828079 Update yookassa_webhook.py 2025-08-31 00:12:53 +03:00
Egor 03be071edc Update webhook_server.py 2025-08-30 23:54:27 +03:00
Egor e8742f30a6 Update webhook_server.py 2025-08-30 23:51:25 +03:00
Egor 3d7727dbf2 Update webhook_server.py 2025-08-30 23:46:31 +03:00
Egor 5bff67860f Update tribute.py 2025-08-30 23:45:48 +03:00
Egor 429e967da2 Update tribute.py 2025-08-30 23:44:35 +03:00
Egor c3df3beeae Update tribute.py 2025-08-30 23:42:55 +03:00
Egor 4d664c15d7 Update yookassa_webhook.py 2025-08-30 23:37:58 +03:00
Egor 53be365e38 Update tribute.py 2025-08-30 23:36:58 +03:00
Egor 3c3ffed32e Update yookassa_webhook.py 2025-08-30 23:36:12 +03:00
Egor 015726b3a6 Update webhook_server.py 2025-08-30 23:35:20 +03:00
Egor 4a3267712b Update main.py 2025-08-30 23:34:38 +03:00
Egor 9b45aae410 Update main.py 2025-08-30 23:24:43 +03:00
Egor f43b391d0e Update referral_service.py 2025-08-30 23:22:13 +03:00
Egor b09b7a0c84 Add files via upload 2025-08-30 23:21:04 +03:00
Egor 541b44b933 Update payment_service.py 2025-08-30 19:32:55 +03:00
Egor b21fce0eff Update webhook_server.py 2025-08-30 19:23:58 +03:00
Egor 017aa27c0f Update webhook_server.py 2025-08-30 19:17:31 +03:00
Egor 496978e8a2 Update texts.py 2025-08-30 19:11:45 +03:00
Egor b3711a590d Update user.py 2025-08-30 19:08:50 +03:00
Egor 631a0f2c4c Update payment_service.py 2025-08-30 19:05:55 +03:00
Egor 503f7ca455 Update transaction.py 2025-08-30 19:01:34 +03:00
Egor 80c771b7bc Update texts.py 2025-08-30 18:59:08 +03:00
Egor 6e344d350b Update texts.py 2025-08-30 18:58:30 +03:00
Egor 7738bc83af Update user.py 2025-08-30 18:57:32 +03:00
Egor 6f7a1eecf6 Update transaction.py 2025-08-30 18:56:07 +03:00
Egor bf147a736a Update main.py 2025-08-30 18:51:43 +03:00
Egor ea3decf1ac Update payment_service.py 2025-08-30 18:49:30 +03:00
Egor b1c0315a6e Update Dockerfile 2025-08-30 18:46:10 +03:00
Egor f6fa7e8f41 Update main.py 2025-08-30 18:45:44 +03:00
Egor 33d4d7994c Update tribute.py 2025-08-30 18:44:45 +03:00
Egor 0ce4ce2b08 Update webhook_server.py 2025-08-30 18:43:32 +03:00
Egor 3a9163e919 Update maintenance_service.py 2025-08-30 17:58:52 +03:00
Egor 376cff748b Update subscription_service.py 2025-08-30 16:57:35 +03:00
Egor d5cef080da Update referral_service.py 2025-08-30 16:51:34 +03:00
Egor 33e31213b3 Update balance.py 2025-08-30 16:05:42 +03:00
Egor 8077ba0c35 Update maintenance_service.py 2025-08-30 15:50:01 +03:00
Egor 67ad3847f8 Update maintenance_service.py 2025-08-30 15:48:12 +03:00
Egor 1eda042b34 Update main.py 2025-08-30 15:40:32 +03:00
Egor 961ff46f43 Update maintenance_service.py 2025-08-30 15:39:43 +03:00
Egor 535ec34a1b Update maintenance_service.py 2025-08-30 15:15:42 +03:00
Egor fe474fd0bf Merge pull request #18 from Fr1ngg/MAINTENANCE_MODE
Maintenance mode
2025-08-30 14:50:04 +03:00
Egor bfc737c1d3 Update main.py 2025-08-30 14:47:40 +03:00
Egor ce0eb32d4e Update maintenance_service.py 2025-08-30 14:46:41 +03:00
Egor 090df2e3ea Update maintenance.py 2025-08-30 14:45:02 +03:00
Egor 01d9df2867 Update maintenance.py 2025-08-30 14:43:57 +03:00
Egor 334a14c80c Update bot.py 2025-08-30 14:41:55 +03:00
Egor 3d952570af Update README.md 2025-08-30 14:40:15 +03:00
Egor bf9567fe6f Update README.md 2025-08-30 14:37:22 +03:00
Egor 6c4c39b454 Add files via upload 2025-08-30 14:35:38 +03:00
Egor d39eb3f79f Update user.py 2025-08-30 13:00:32 +03:00
Egor 7f0ce61126 Update user.py 2025-08-30 05:00:44 +03:00
Egor 775056da6f Update user.py 2025-08-30 04:57:06 +03:00
Egor a15ecb375b Update user.py 2025-08-30 04:53:34 +03:00
Egor 3dea941c51 Update user_service.py 2025-08-30 04:49:50 +03:00
Egor 410f0aba95 Update user.py 2025-08-30 04:43:51 +03:00
Egor 1ec13fbf63 Update user.py 2025-08-30 04:39:57 +03:00
Egor 012408b7cd Update user.py 2025-08-30 04:36:23 +03:00
Egor fdca75b153 Update user.py 2025-08-30 04:35:19 +03:00
Egor 40c235cdac Update user_service.py 2025-08-30 04:26:52 +03:00
Egor 7ea8fa735c Update user.py 2025-08-30 04:26:25 +03:00
Egor 758141dbc4 Update user_service.py 2025-08-30 04:22:57 +03:00
Egor 3c37fa45e6 Update user_service.py 2025-08-30 04:17:01 +03:00
Egor 35a80f4585 Update user_service.py 2025-08-30 04:13:58 +03:00
Egor 57e033c7c6 Update config.py 2025-08-30 04:09:00 +03:00
Egor d05d070faa Update user_service.py 2025-08-30 04:07:09 +03:00
Egor 3f3829a745 Update tribute_service.py 2025-08-30 03:04:43 +03:00
Egor 21bb89e5ea Update transaction.py 2025-08-30 03:03:44 +03:00
Egor fad5ecbb89 Update tribute_service.py 2025-08-30 03:00:03 +03:00
Egor 2c64c57edd Update tribute_service.py 2025-08-30 02:53:15 +03:00
Egor 117c637756 Update user.py 2025-08-30 02:46:18 +03:00
Egor 488f161322 Update tribute_service.py 2025-08-30 02:45:00 +03:00
Egor a55ef684e6 Update user.py 2025-08-30 02:33:49 +03:00
Egor 90234ba01e Update tribute_service.py 2025-08-30 02:28:25 +03:00
Egor cd83361050 Update tribute_service.py 2025-08-30 02:22:02 +03:00
Egor 08cda481ba Update tribute.py 2025-08-30 02:17:47 +03:00
Egor 45f998d134 Update tribute.py 2025-08-29 19:46:07 +03:00
Egor 33ee5b4ec4 Update payment_service.py 2025-08-29 18:55:02 +03:00
Egor 545bbaafa5 Update balance.py 2025-08-29 18:38:59 +03:00
Egor cc957460bb Update balance.py 2025-08-29 18:21:32 +03:00
Egor cd51dab845 Update README.md 2025-08-29 12:29:43 +03:00
Egor 74923515cc Merge pull request #17 from Fr1ngg/YooKassa
YooKassa Интеграция
2025-08-29 11:59:37 +03:00
Egor b3f3c415df Update README.md 2025-08-29 11:56:46 +03:00
Egor 0b9de65cbc Update yookassa_service.py 2025-08-29 11:54:52 +03:00
Egor 1c26171b52 Update yookassa_webhook.py 2025-08-29 11:53:32 +03:00
Egor a6bc0afdd3 Update yookassa.py 2025-08-29 11:51:51 +03:00
Egor 966cfe6565 Update config.py 2025-08-29 11:50:12 +03:00
Egor d5b1c5805e Update requirements.txt 2025-08-29 11:48:31 +03:00
Egor a4ce5ff9ca Update .env.example 2025-08-29 11:47:46 +03:00
Egor 8e4b70578b Add files via upload 2025-08-29 11:46:33 +03:00
Egor 5367874062 Update models.py 2025-08-27 13:20:32 +03:00
Egor 321696b5b5 Update remnawave_api.py 2025-08-27 13:19:07 +03:00
Egor b642a5550c Update universal_migration.py 2025-08-27 13:16:13 +03:00
Egor 0e97632099 Update texts.py 2025-08-27 05:50:43 +03:00
Egor 4a3c6c4124 Update formatters.py 2025-08-27 05:50:18 +03:00
Egor 3bf9c143d4 Update monitoring_service.py 2025-08-27 05:49:44 +03:00
Egor 285e5e256e Update balance.py 2025-08-26 18:48:03 +03:00
Egor 5b06b89d7a bedolaga screens 2025-08-26 17:01:38 +03:00
Egor 7e749fa096 Merge pull request #16 from Fr1ngg/FixDbCollisions
Fix db collisions
2025-08-26 03:27:43 +03:00
Egor 13ac3f0e74 Create subscription_utils.py 2025-08-26 03:22:47 +03:00
Egor 32c551999c Update models.py 2025-08-26 03:21:26 +03:00
Egor ff2c09d3be Update subscription.py 2025-08-26 03:20:47 +03:00
Egor 4c97ac4497 Update subscription.py 2025-08-26 03:20:05 +03:00
Egor 86516186d9 Update main.py 2025-08-26 03:18:31 +03:00
Egor e0581bdc3c Create universal_migration.py 2025-08-26 03:14:26 +03:00
Egor 0b7d8d41f6 Update monitoring_service.py 2025-08-25 05:47:31 +03:00
Egor 4ca46537f9 Update subscription_service.py 2025-08-25 05:06:28 +03:00
Egor 75f2d0f1ce Update remnawave.py 2025-08-25 05:05:13 +03:00
Egor 51424bf256 Update user.py 2025-08-25 05:04:01 +03:00
Egor 4cce2f1efb Update remnawave_service.py 2025-08-25 05:03:15 +03:00
Egor 5102286963 Update bot.py 2025-08-25 03:17:27 +03:00
Egor 24c8f25e6e Update subscription.py 2025-08-25 03:16:39 +03:00
Egor de4796ac1c Update menu.py 2025-08-25 03:16:22 +03:00
Egor 95a8da5f19 Update monitoring_service.py 2025-08-25 03:15:45 +03:00
Egor 76f1313550 Update subscription_service.py 2025-08-25 03:15:02 +03:00
Egor 58d22200cf Create subscription_checker.py 2025-08-25 03:14:19 +03:00
Egor eb8b09e658 Update subscription.py 2025-08-25 03:12:46 +03:00
Egor 25cf635b6f Update models.py 2025-08-25 03:12:22 +03:00
Egor a54c1001f9 Update models.py 2025-08-25 03:11:49 +03:00
Egor 6140d7042a Update subscription.py 2025-08-24 23:22:56 +03:00
Egor d0135a2328 Update subscription.py 2025-08-24 23:21:08 +03:00
Egor 1048734548 Update README.md 2025-08-24 18:29:43 +03:00
Egor 62116fd5ba Update README.md 2025-08-24 17:27:52 +03:00
Egor 37a905f056 Update states.py 2025-08-24 16:58:17 +03:00
Egor bce9061401 Update users.py 2025-08-24 16:57:29 +03:00
Egor 118b282a2b Update states.py 2025-08-24 16:52:11 +03:00
Egor 6c250ed36f Update users.py 2025-08-24 16:51:31 +03:00
Egor 3842aeacca Update subscription_service.py 2025-08-24 16:23:04 +03:00
Egor 1d1afbe1da Update remnawave_service.py 2025-08-24 16:21:54 +03:00
Egor d4db55ea66 Update subscription.py 2025-08-24 16:19:32 +03:00
Egor 354964060c Update README.md 2025-08-23 18:25:49 +03:00
Egor 5bc8d8e52c Update README.md 2025-08-23 18:25:08 +03:00
Egor 8f3e8bacc4 Update README.md 2025-08-23 18:24:38 +03:00
Egor 61c94e17a4 Update promocode_service.py 2025-08-23 16:04:54 +03:00
Egor 77c52c1197 Update README.md 2025-08-23 14:38:43 +03:00
Egor c65c703e00 Update README.md 2025-08-23 12:56:42 +03:00
Egor 7560e802cb Update README.md 2025-08-23 12:53:05 +03:00
Egor a5a90a806b Update README.md 2025-08-23 12:35:37 +03:00
Egor aa0a271d14 Update README.md 2025-08-23 12:31:25 +03:00
Egor 5d4e51c862 Update README.md 2025-08-23 12:16:27 +03:00
Egor e6057bb93c Update .env.example 2025-08-23 11:58:48 +03:00
Egor 714bdd0d7c Update inline.py 2025-08-23 11:52:42 +03:00
Egor 41f3eb76c1 Update subscription.py 2025-08-23 11:51:12 +03:00
Egor e9c58b3f76 Update config.py 2025-08-23 11:48:26 +03:00
Egor 3d6976ff6e Update config.py 2025-08-23 11:44:40 +03:00
Egor ee978dff7b Add files via upload 2025-08-23 11:41:20 +03:00
Egor 0bcf5c5f43 Create 3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-08-23 11:31:06 +03:00
Egor 2ceedd6587 Update README.md 2025-08-23 07:41:34 +03:00
Egor 2ded1a4a9e Update README.md 2025-08-23 07:09:06 +03:00
Egor d551a7267f Update README.md 2025-08-23 06:27:24 +03:00
Egor 3ddf6b35c6 Update .env.example 2025-08-23 06:26:37 +03:00
Egor c0d3c70683 Update inline.py 2025-08-23 06:25:15 +03:00
Egor 7267433d99 Update subscription.py 2025-08-23 06:22:41 +03:00
Egor 09f3c4d27f Update config.py 2025-08-23 06:21:43 +03:00
Egor 3083915bc8 Update remnawave_api.py 2025-08-22 15:18:21 +03:00
Egor 5c72c04d27 Update texts.py 2025-08-22 12:25:42 +03:00
Egor 826149da3d Update balance.py 2025-08-22 12:19:14 +03:00
Egor f090be328f Update menu.py 2025-08-22 11:40:57 +03:00
Egor ecbfcd2b54 Update texts.py 2025-08-22 11:40:27 +03:00
Egor 2d21b1c542 Update balance.py 2025-08-22 11:17:22 +03:00
Egor c63438af5e Update monitoring_service.py 2025-08-22 11:11:52 +03:00
Egor 8e47013ae2 Update texts.py 2025-08-22 11:09:54 +03:00
Egor 92fa0ebcc1 Update inline.py 2025-08-22 11:09:19 +03:00
Egor f1d23616f1 Update balance.py 2025-08-22 11:08:37 +03:00
Egor d554f2d801 Create subscription_notifications.py 2025-08-22 11:07:10 +03:00
Egor 1fe4608604 Update monitoring_service.py 2025-08-22 11:05:41 +03:00
Egor bea534b352 Update README.md 2025-08-22 09:17:46 +03:00
Egor e993c19a34 Update README.md 2025-08-22 09:14:40 +03:00
Egor 2deb5d1bc1 Update README.md 2025-08-22 09:06:46 +03:00
Egor fb1b0bbcd5 Update README.md 2025-08-22 09:02:36 +03:00
Egor 0308dd6c58 Update README.md 2025-08-22 09:02:15 +03:00
Egor 860bf7fc7a Update user_service.py 2025-08-22 08:01:34 +03:00
Egor c24024db18 Update user_service.py 2025-08-22 07:50:18 +03:00
Egor 7d330838e0 Update auth.py 2025-08-22 07:48:45 +03:00
Egor feed3fff0d Update start.py 2025-08-22 07:47:36 +03:00
Egor 4ddaafd052 Update requirements.txt 2025-08-22 07:24:08 +03:00
Egor 140194a0ad Update admin.py 2025-08-22 07:21:56 +03:00
Egor 7c6adae87f Update user_service.py 2025-08-22 07:21:19 +03:00
Egor 03deb91a25 Update auth.py 2025-08-22 07:20:20 +03:00
Egor ea2caa98ae Update users.py 2025-08-22 07:19:40 +03:00
Egor dab69b9229 Update bot.py 2025-08-22 07:18:40 +03:00
Egor 0ffa86ff36 Update subscription.py 2025-08-22 02:30:55 +03:00
Egor 3b08b71472 Update inline.py 2025-08-22 02:30:23 +03:00
Egor 6c7b1353df Update subscription.py 2025-08-22 02:29:51 +03:00
Egor 9e009d2ded Update subscription_service.py 2025-08-22 02:29:11 +03:00
Egor dc6a829f0f Update texts.py 2025-08-22 01:12:26 +03:00
Egor 3a3a4aa9aa Update inline.py 2025-08-22 01:05:00 +03:00
Egor 99cd22d76b Update subscription.py 2025-08-22 01:04:42 +03:00
Egor 0a920bffe6 Update remnawave.py 2025-08-22 00:22:37 +03:00
Egor 55fbc65231 Update remnawave_service.py 2025-08-22 00:22:05 +03:00
Egor f508ac151b Update admin.py 2025-08-22 00:21:36 +03:00
Egor abec12eed1 Update subscription.py 2025-08-21 21:33:40 +03:00
Egor 9b50fbbe05 Update inline.py 2025-08-21 21:33:19 +03:00
Egor 9311e7c11d Update config.py 2025-08-21 06:11:53 +03:00
Egor 625972da87 Update inline.py 2025-08-21 06:11:08 +03:00
Egor d3293615b0 Update subscription.py 2025-08-21 06:09:28 +03:00
Egor eef1fcc705 Create app-config.json 2025-08-21 06:06:27 +03:00
Egor ffe8259025 Update README.md 2025-08-21 04:48:28 +03:00
Egor bac42b51e1 Update README.md 2025-08-21 02:46:20 +03:00
Egor 510ec2990d Update README.md 2025-08-21 02:40:21 +03:00
Egor dc06056cc7 Update README.md 2025-08-21 02:37:02 +03:00
Egor 103d6e8854 Update .env.example 2025-08-21 02:34:31 +03:00
Egor 466f1e66cc Update README.md 2025-08-21 02:34:15 +03:00
Egor 5de90b20f6 Merge pull request #11 from Fr1ngg/BEDOLAGANEW
Bedolaganew
2025-08-21 02:03:21 +03:00
Egor 28a26b61ac Update README.md
Update for v2.0.0
2025-08-21 01:45:31 +03:00
Egor 2182b47666 Create Dockerfile 2025-08-21 00:21:49 +03:00
Egor 347c62ec8b Create .env.example 2025-08-21 00:03:02 +03:00
Egor 736e4c6cae NEW VERSION
NEW VERSION
2025-08-20 23:57:04 +03:00
Egor 92a07e2acf UPDATE 2025-08-20 23:54:53 +03:00
Egor 83af139d01 Update README.md 2025-08-19 17:48:07 +03:00
Egor e76e297d22 Update keyboards.py 2025-08-17 05:32:45 +03:00
Egor b317368df2 Update admin_handlers.py 2025-08-17 05:32:30 +03:00
Egor 572170b1cc Update database.py 2025-08-17 05:32:14 +03:00
Egor 1adeb53a0d Update handlers.py 2025-08-17 05:31:58 +03:00
Egor 3e5811ec11 Update handlers.py 2025-08-17 05:25:04 +03:00
Egor e26a76c8cf Update handlers.py 2025-08-17 05:20:31 +03:00
Egor 1d5755e415 Update keyboards.py 2025-08-17 05:16:43 +03:00
Egor 32beec7515 Update database.py 2025-08-17 05:16:31 +03:00
Egor feb2250f37 Update admin_handlers.py 2025-08-17 05:16:06 +03:00
Egor da36db39c5 Update handlers.py 2025-08-17 05:15:50 +03:00
Egor fd0fa71f85 Update subscription_monitor.py 2025-08-17 04:41:07 +03:00
Egor 04250b38bb Update README.md 2025-08-17 00:47:11 +03:00
Egor deb6978572 Update README.md 2025-08-16 17:10:40 +03:00
Egor d2a26ead1e Update .env.example 2025-08-16 13:59:00 +03:00
Egor 9e40f406ee Update config.py 2025-08-16 13:57:26 +03:00
Egor 2cdfaf9356 Update subscription_monitor.py 2025-08-16 13:56:54 +03:00
Egor 66af399bc0 Update .env.example 2025-08-16 13:13:10 +03:00
Egor 5fa69dec3e Update README.md 2025-08-16 09:01:18 +03:00
Egor 3d5f3303d1 Update README.md 2025-08-16 08:58:16 +03:00
Egor 10883e92b1 Update handlers.py 2025-08-16 07:13:07 +03:00
Egor e2d28333ec Update handlers.py 2025-08-16 07:08:45 +03:00
Egor 6d55d4832f Update remnawave_api.py 2025-08-16 06:58:48 +03:00
Egor 46c563873f Update handlers.py 2025-08-16 06:58:29 +03:00
Egor 10c5d3c408 Update remnawave_api.py 2025-08-16 06:53:39 +03:00
Egor 942080c460 Update handlers.py 2025-08-16 06:53:15 +03:00
Egor 6a0869ca0a Update remnawave_api.py 2025-08-16 06:31:15 +03:00
Egor d9992dd2f2 Update remnawave_api.py 2025-08-16 06:30:01 +03:00
Egor c8c9631518 Update remnawave_api.py 2025-08-16 06:29:35 +03:00
Egor 943f29f227 Update handlers.py 2025-08-16 06:13:03 +03:00
Egor 80a069077c Update handlers.py 2025-08-16 06:09:01 +03:00
Egor e344e55e6a Create docker-hub.yml 2025-08-16 02:27:32 +03:00
Egor a69947b69a Delete .github/workflows/docker-build.yml 2025-08-16 02:27:12 +03:00
Egor 926f593f50 Create docker-registry.yml 2025-08-16 02:26:57 +03:00
Egor f13734a4e9 Update translations.py 2025-08-16 02:19:36 +03:00
Egor 2107166926 Update translations.py 2025-08-16 02:18:16 +03:00
Egor 60e5601690 Update README.md 2025-08-16 02:13:48 +03:00
Egor 0957d9ef88 Update bedolaga.sh 2025-08-16 02:10:19 +03:00
Egor eb481c57ec Update keyboards.py 2025-08-16 00:15:29 +03:00
Egor e0b1666f5d Update handlers.py 2025-08-16 00:14:37 +03:00
Egor f2afc088af Update README.md 2025-08-15 22:41:43 +03:00
Egor 971d59b2c6 Update README.md 2025-08-15 22:39:18 +03:00
Egor 83fe58056f Update README.md 2025-08-15 22:38:26 +03:00
Egor 5f107922d0 Update README.md 2025-08-15 22:26:56 +03:00
Egor a2ea6b7bf7 Update README.md 2025-08-15 22:20:23 +03:00
Egor 1e0e728840 Update README.md 2025-08-15 22:19:51 +03:00
Egor cea6ed3f95 Update README.md 2025-08-15 08:54:16 +03:00
Egor f15705ed47 Update README.md 2025-08-15 08:52:34 +03:00
Egor 0b9c13ae4b Update README.md 2025-08-15 08:49:35 +03:00
Egor d0c8ae02f0 Update README.md 2025-08-15 08:47:50 +03:00
Egor 4eda7401b9 Update README.md 2025-08-15 08:44:07 +03:00
Egor 0c2f3c9399 Update README.md 2025-08-15 08:43:01 +03:00
Egor 541ecae48c Update bedolaga.sh 2025-08-15 08:37:48 +03:00
Egor 82a551f1d4 Update README.md 2025-08-15 07:29:29 +03:00
Egor fd2253135d Update README.md 2025-08-15 07:27:51 +03:00
Egor d0820aa47e Update README.md 2025-08-15 07:27:24 +03:00
Egor 09c5800f49 Delete emergency_fix.py 2025-08-15 06:41:25 +03:00
Egor 52aa2dc6a8 Delete Makefile 2025-08-15 06:40:25 +03:00
Egor 6418ee7735 Delete docker-compose.yml 2025-08-15 06:39:39 +03:00
Egor 25aef89b4f Update README.md 2025-08-15 05:35:18 +03:00
Egor 77d17f97d3 Update admin_handlers.py 2025-08-15 04:56:43 +03:00
Egor d5c1ec3aea Update database.py 2025-08-15 04:54:53 +03:00
Egor 97a7384381 Update database.py 2025-08-15 04:49:48 +03:00
Egor 27b7bfccba Update admin_handlers.py 2025-08-15 04:49:18 +03:00
Egor ab15624a93 Update keyboards.py 2025-08-15 04:19:58 +03:00
Egor a9c5d8ed69 Update handlers.py 2025-08-15 04:19:40 +03:00
Egor 234b90796e Update handlers.py 2025-08-15 04:02:38 +03:00
Egor 5f018ba22f Update keyboards.py 2025-08-15 04:00:47 +03:00
Egor e11cbbcc18 Update handlers.py 2025-08-15 03:58:51 +03:00
Egor ba4d30d8db Update handlers.py 2025-08-15 03:46:22 +03:00
Egor d11431769c Update keyboards.py 2025-08-15 03:36:12 +03:00
Egor d1d1bbb3e4 Update handlers.py 2025-08-15 03:35:42 +03:00
Egor 823f30882a Update handlers.py 2025-08-15 03:33:09 +03:00
Egor d36f389469 Merge pull request #1 from maximovnikolay/feature/disable_stars_payment
disable stars payment button by config

Спасибо за помощь и вклад! Очень ценю!
2025-08-15 03:25:01 +03:00
Maximov Nikolay add686a4d5 disable stars payment button by config 2025-08-14 23:49:23 +03:00
Egor 59be2d8f02 Update README.md 2025-08-14 05:47:01 +03:00
Egor aa3db49f65 Update README.md 2025-08-14 05:39:19 +03:00
Egor c101cb3d3f Update README.md 2025-08-14 05:29:41 +03:00
Egor 63912af43b Update README.md 2025-08-14 05:26:12 +03:00
Egor d19263dd79 Update .env.example 2025-08-14 05:08:03 +03:00
Egor f8c9c59a03 Update database.py 2025-08-14 04:45:02 +03:00
Egor 5595f7a954 Update admin_handlers.py 2025-08-14 04:44:33 +03:00
Egor bac4527945 Update autopay_service.py 2025-08-14 04:44:09 +03:00
Egor 51da8bcb72 Update remnawave_api.py 2025-08-14 04:41:38 +03:00
Egor 8aa6e7e245 Update subscription_monitor.py 2025-08-14 04:40:29 +03:00
Egor 5029b35d14 Update subscription_monitor.py 2025-08-14 04:39:37 +03:00
Egor 2412027d9b Update handlers.py 2025-08-14 04:39:17 +03:00
Egor ddff5219e5 Create webhook_server.py 2025-08-14 04:38:58 +03:00
Egor 346bc18b58 Create tribute_service.py 2025-08-14 04:38:12 +03:00
Egor c256ae3664 Update keyboards.py 2025-08-14 04:37:33 +03:00
Egor 2b47358a9e Update config.py 2025-08-14 04:36:58 +03:00
Egor e0b041ce44 Update main.py 2025-08-14 04:36:41 +03:00
Egor 3f64dee927 Update database.py 2025-08-13 08:02:35 +03:00
Egor 67c5ebeabd Update admin_handlers.py 2025-08-13 08:02:18 +03:00
Egor 84bc8e96cc Update database.py 2025-08-13 07:58:18 +03:00
Egor 2d30cec7c6 Update lucky_game.py 2025-08-13 07:55:50 +03:00
Egor 5f5f66178b Update lucky_game.py 2025-08-13 07:50:17 +03:00
Egor e9fadec0ac Update database.py 2025-08-13 07:49:40 +03:00
Egor 10008e51ff Update admin_handlers.py 2025-08-13 07:39:20 +03:00
Egor 3098610d17 Update database.py 2025-08-13 07:31:29 +03:00
Egor 7a2566d320 Update admin_handlers.py 2025-08-13 07:31:09 +03:00
Egor 01ba8ca53e Update subscription_monitor.py 2025-08-13 07:09:12 +03:00
280 changed files with 99366 additions and 20975 deletions
+46
View File
@@ -0,0 +1,46 @@
# Git files
.git
.gitignore
.github/
# Documentation
README.md
*.md
docs/
# Development files
.vscode/
.idea/
*.pyc
__pycache__/
.pytest_cache/
.coverage
htmlcov/
# Environment files
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# Docker files (исключаем из копирования в образ)
Dockerfile*
.dockerignore
docker-compose*.yml
# Build artifacts
build/
dist/
*.egg-info/
# OS files
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
.cache/
+386 -60
View File
@@ -1,64 +1,390 @@
# Bot Configuration
# ===============================================
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
# ===============================================
# ===== TELEGRAM BOT =====
BOT_TOKEN=
# настройки реф программы юзернейм без @!
BOT_USERNAME=
REFERRAL_FIRST_REWARD=100.0
REFERRAL_REFERRED_BONUS=100.0
REFERRAL_THRESHOLD=200.0
REFERRAL_PERCENTAGE=0.2
# RemnaWave API Configuration
REMNAWAVE_URL=https://example.com
REMNAWAVE_TOKEN=
# Admin Configuration (comma-separated list of Telegram user IDs)
ADMIN_IDS=123445567,12342123
# Support Configuration
SUPPORT_USERNAME=support
TRIAL_ENABLED=true # Включить триал
TRIAL_DURATION_DAYS=3 # Дней триала
TRIAL_TRAFFIC_GB=2 # Лимит трафика у триал подписки
TRIAL_SQUAD_UUID= # UUID сквада из панели remnawave /dashboard/management/internal-squads
TRIAL_PRICE=0.0 # Оставить 0!
TRIAL_NOTIFICATION_ENABLED=true # Уведомление об истекшей триальной подписке
TRIAL_NOTIFICATION_HOURS_AFTER=1 # Через сколько отсылать сообщение
TRIAL_NOTIFICATION_HOURS_WINDOW=23 # Через сколько выслать повторно
# Monitor Service Settings (дополнительные настройки)
MONITOR_CHECK_INTERVAL=21600 # Промежуток проверики (3600 - будет раз в час проверять и слать уведомления)
MONITOR_DAILY_CHECK_HOUR=10 # Ежедневный чек в 10 утра
MONITOR_WARNING_DAYS=2 # За сколько начинать до окончания подписки слать уведомления
DELETE_EXPIRED_TRIAL_DAYS=1 # Через сколько дней после истечения удалять триал подписки
DELETE_EXPIRED_REGULAR_DAYS=7 # Через сколько дней после истечения удалять обычные подписки
AUTO_DELETE_ENABLED=true # Включить автоматическое удаление при ежедневной проверке
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
LUCKY_GAME_ENABLED=true # Включить/выключить игру удачи
LUCKY_GAME_REWARD=50.0 # Размер награды за выигрыш в рублях
LUCKY_GAME_NUMBERS=30 # Общее количество чисел для выбора (1-30)
LUCKY_GAME_WINNING_COUNT=25 # Количество выигрышных чисел из общего количества
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
# ================================
# TELEGRAM STARS НАСТРОЙКИ
# ================================
# Включить/выключить пополнение через Telegram Stars
STARS_ENABLED=true
# Курсы обмена звезд на рубли
# Формат: STARS_{количество_звезд}_RATE={рубли}
# 100 звёзд
STARS_100_RATE=110
# 150 звёзд
STARS_150_RATE=165
# 250 звёзд
STARS_250_RATE=280
# 350 звёзд
STARS_350_RATE=355
# 500 звёзд
STARS_500_RATE=550
# 750 звёзд
STARS_750_RATE=800
# 1000 звёзд
STARS_1000_RATE=1000
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
DATABASE_MODE=auto
# Основной URL (можно оставить пустым для автоматического выбора)
DATABASE_URL=
# PostgreSQL настройки (для Docker и кастомных установок)
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=remnawave_bot
POSTGRES_USER=remnawave_user
POSTGRES_PASSWORD=secure_password_123
# SQLite настройки (для локального запуска)
SQLITE_PATH=./data/bot.db
LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=your_api_key_here
# Тип авторизации: "api_key", "basic_auth"
REMNAWAVE_AUTH_TYPE=api_key
# Для панелей с Basic Auth (опционально)
REMNAWAVE_USERNAME=
REMNAWAVE_PASSWORD=
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
REMNAWAVE_SECRET_KEY=
# Шаблон описания пользователя в панели Remnawave
# Доступные плейсхолдеры:
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств достопных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
# Плюсовать дни триала при покупке платной подписке
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
# "fixed" - фиксированный лимит трафика для всех подписок
TRAFFIC_SELECTION_MODE=selectable
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 14,30,60,90,180,360
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== ЦЕНЫ (в копейках) =====
BASE_SUBSCRIPTION_PRICE=0
# Цены за периоды
PRICE_14_DAYS=7000
PRICE_30_DAYS=9900
PRICE_60_DAYS=25900
PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=5000
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
YOOKASSA_ENABLED=false
YOOKASSA_SHOP_ID=
YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Включить оплату через СБП (Систему быстрых платежей)
YOOKASSA_SBP_ENABLED=false
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
# full_payment - полная оплата
# partial_payment - частичная оплата
# advance - аванс
# full_prepayment - полная предоплата
# partial_prepayment - частичная предоплата
# credit - передача в кредит
# credit_payment - оплата кредита
YOOKASSA_PAYMENT_SUBJECT=service
# Предметы расчета:
# commodity - товар
# excise - подакцизный товар
# job - работа
# service - услуга
# gambling_bet - ставка в азартной игре
# gambling_prize - выигрыш в азартной игре
# lottery - лотерейный билет
# lottery_prize - выигрыш в лотерее
# intellectual_activity - результат интеллектуальной деятельности
# payment - платеж
# agent_commission - агентское вознаграждение
# composite - составной предмет расчета
# another - другое
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# CRYPTOBOT
CRYPTOBOT_ENABLED=false
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
MULENPAY_SECRET_KEY=
MULENPAY_SHOP_ID=<ID магазина>
# необязательно, есть дефолтные значения
MULENPAY_BASE_URL=https://mulenpay.ru/api
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
MULENPAY_DESCRIPTION="Пополнение баланса"
MULENPAY_LANGUAGE=ru
MULENPAY_VAT_CODE=0
MULENPAY_PAYMENT_SUBJECT=4
MULENPAY_PAYMENT_MODE=4
MULENPAY_MIN_AMOUNT_KOPEKS=10000
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
# PAYPALYCH / PAL24
PAL24_ENABLED=false
PAL24_API_TOKEN=
PAL24_SHOP_ID=
PAL24_SIGNATURE_TOKEN=
PAL24_BASE_URL=https://pal24.pro/api/v1/
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_WEBHOOK_PORT=8084
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
# Пропустить принятие правил использования бота
SKIP_RULES_ACCEPT=false
# Пропустить запрос реферального кода
SKIP_REFERRAL_CODE=false
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
MONITORING_INTERVAL=60
INACTIVE_USER_DELETE_MONTHS=3
# Уведомления
TRIAL_WARNING_HOURS=2
ENABLE_NOTIFICATIONS=true
NOTIFICATION_RETRY_ATTEMPTS=3
MONITORING_LOGS_RETENTION_DAYS=30
NOTIFICATION_CACHE_HOURS=24
# ===== СТАТУС СЕРВЕРОВ =====
# Режимы: disabled, external_link, external_link_miniapp, xray
SERVER_STATUS_MODE=disabled
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
SERVER_STATUS_EXTERNAL_URL=
# URL метрик XrayChecker (для режима xray)
SERVER_STATUS_METRICS_URL=
# Данные Basic Auth (опционально)
SERVER_STATUS_METRICS_USERNAME=
SERVER_STATUS_METRICS_PASSWORD=
# Проверять SSL сертификат при запросе метрик
SERVER_STATUS_METRICS_VERIFY_SSL=true
# Таймаут запроса к метрикам (в секундах)
SERVER_STATUS_REQUEST_TIMEOUT=10
# Количество серверов на странице в режиме интеграции
SERVER_STATUS_ITEMS_PER_PAGE=10
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
# ===== СИСТЕМА БЕКАПОВ =====
BACKUP_AUTO_ENABLED=true
BACKUP_INTERVAL_HOURS=24
BACKUP_TIME=03:00
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
BACKUP_MAX_KEEP=7
BACKUP_COMPRESSION=true
BACKUP_INCLUDE_LOGS=false
BACKUP_LOCATION=/app/data/backups
# Отправка бэкапов в телеграм
BACKUP_SEND_ENABLED=true
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
VERSION_CHECK_ENABLED=true
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
VERSION_CHECK_INTERVAL_HOURS=1
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# ===== РАЗРАБОТКА =====
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
+71
View File
@@ -0,0 +1,71 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 10
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "python"
allow:
- dependency-type: "all"
groups:
python-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "10:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 5
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "ci"
include: "scope"
labels:
- "dependencies"
- "github-actions"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "tuesday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 3
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "docker"
include: "scope"
labels:
- "dependencies"
- "docker"
-31
View File
@@ -1,31 +0,0 @@
name: BedolagaBot
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
push: true
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
+111
View File
@@ -0,0 +1,111 @@
name: BedolagaBot
on:
push:
branches:
- main
- dev
tags:
- 'v*'
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Определяем версию и теги
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
echo "=== Информация о сборке ==="
echo "Версия: $VERSION"
echo "Коммит: $(git rev-parse --short HEAD)"
echo "Теги: $TAGS"
echo "Push: ${{ github.event_name != 'pull_request' }}"
echo "==========================="
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ steps.version.outputs.should_push }}
tags: ${{ steps.version.outputs.tags }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
VCS_REF=${{ steps.version.outputs.short_sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ steps.version.outputs.build_date }}
org.opencontainers.image.revision=${{ steps.version.outputs.short_sha }}
org.opencontainers.image.title=Bedolaga RemnaWave Bot
org.opencontainers.image.description=Telegram bot for RemnaWave VPN service
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Build Summary
if: github.event_name != 'pull_request'
run: |
echo "## 🚀 Docker Hub Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | Docker Hub |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📋 Опубликованные теги:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.version.outputs.tags }}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🔗 Ссылки:" >> $GITHUB_STEP_SUMMARY
echo "- [Docker Hub Repository](https://hub.docker.com/r/fr1ngg/remnawave-bedolaga-telegram-bot)" >> $GITHUB_STEP_SUMMARY
+141
View File
@@ -0,0 +1,141 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- dev
env:
REGISTRY: ghcr.io
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR - only build without push"
else
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ Push/Tag - will push"
fi
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ steps.version.outputs.should_push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
VCS_REF=${{ steps.version.outputs.short_sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
to: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
ignore-unchanged: true
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Build Summary
if: steps.version.outputs.should_push == 'true'
run: |
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY
- name: Build Summary (No Push)
if: steps.version.outputs.should_push == 'false'
run: |
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY
+36 -2
View File
@@ -1,2 +1,36 @@
/screens
/assets
# Игнорируем все файлы и папки по умолчанию
*
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!install_bot.sh
!Dockerfile
!app-config.json
!main.py
!requirements.txt
!docs/
!docs/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!locales/
!locales/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
!LICENSE
# Разрешаем .gitignore чтобы он попал в репозиторий
!.gitignore
# Внутри разрешенных папок игнорируем служебные файлы
app/__pycache__/
app/**/__pycache__/
app/**/*.pyc
app/**/*.pyo
app/**/*.pyd
*.pyc
*.pyo
*.pyd
+480
View File
@@ -0,0 +1,480 @@
# 🤝 Contributing to Remnawave Bedolaga Bot
Спасибо за интерес к развитию проекта! Этот документ содержит правила и рекомендации для контрибьюторов.
## 📋 Содержание
- [Кодекс поведения](#-кодекс-поведения)
- [Как помочь проекту](#-как-помочь-проекту)
- [Настройка среды разработки](#-настройка-среды-разработки)
- [Стандарты кода](#-стандарты-кода)
- [Работа с Git](#-работа-с-git)
- [Тестирование](#-тестирование)
- [Процесс ревью](#-процесс-ревью)
- [Документация](#-документация)
## 📜 Кодекс поведения
### Основные принципы
- **Уважение** - относитесь к участникам проекта с уважением
- **Конструктивность** - предлагайте решения, а не только критику
- **Открытость** - будьте открыты к обратной связи и новым идеям
- **Профессионализм** - поддерживайте высокий уровень обсуждений
### Недопустимое поведение
- Оскорбления и личные атаки
- Спам и офф-топик сообщения
- Публикация приватной информации
- Любые формы дискриминации
## 🚀 Как помочь проекту
### 🐛 Сообщения о багах
Перед созданием issue проверьте:
- [ ] Аналогичная проблема не была описана ранее
- [ ] Вы используете актуальную версию бота
- [ ] Проблема воспроизводится стабильно
**Шаблон для багрепорта:**
```markdown
## 🐛 Описание бага
Краткое описание проблемы
## 🔄 Шаги воспроизведения
1. Перейти к '...'
2. Нажать на '...'
3. Увидеть ошибку
## ✅ Ожидаемое поведение
Что должно было произойти
## ❌ Фактическое поведение
Что произошло на самом деле
## 🌍 Окружение
- Версия бота: [например, 1.0.0]
- Python версия: [например, 3.11.7]
- ОС: [например, Ubuntu 22.04]
- Версия Docker: [если используется]
## 📋 Логи
```
Вставьте соответствующие логи
```
## 📷 Скриншоты
Если применимо, добавьте скриншоты
```
### 💡 Предложения функций
Используйте лейбл `enhancement` и опишите:
- **Проблему**, которую решает функция
- **Предлагаемое решение**
- **Альтернативы**, которые вы рассматривали
- **Дополнительную информацию** и контекст
### 📝 Улучшение документации
- Исправление опечаток
- Дополнение примеров
- Перевод на другие языки
- Улучшение структуры
## 🛠 Настройка среды разработки
### Требования
- Python 3.11+
- Docker и Docker Compose
- Git
- PostgreSQL 15+ (опционально для локальной разработки)
- Redis (опционально для локальной разработки)
### Установка
1. **Форкните и клонируйте репозиторий:**
```bash
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
cd remnawave-bedolaga-telegram-bot
```
2. **Создайте виртуальное окружение:**
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
# или
venv\Scripts\activate # Windows
```
3. **Установите зависимости:**
```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt # если есть dev зависимости
```
4. **Настройте окружение:**
```bash
cp .env.example .env
# Отредактируйте .env файл с вашими настройками
```
5. **Запустите через Docker (рекомендуется):**
```bash
docker compose up -d postgres redis
python main.py
```
### Структура проекта
```
bedolaga_bot/
├── app/ # Основной код приложения
│ ├── handlers/ # Обработчики сообщений
│ ├── services/ # Бизнес-логика
│ ├── database/ # Модели и CRUD операции
│ ├── utils/ # Утилиты
│ ├── middlewares/ # Middleware
│ └── external/ # Внешние API
├── migrations/ # Миграции БД (если нужны)
├── tests/ # Тесты (создать при необходимости)
├── docs/ # Документация
└── requirements.txt # Зависимости
```
## 🎨 Стандарты кода
### Python стиль
Мы следуем **PEP 8** с некоторыми исключениями:
```python
# ✅ Хорошо
async def get_user_subscription(user_id: int) -> Optional[Subscription]:
"""Получает активную подписку пользователя."""
async with get_session() as session:
result = await session.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.where(Subscription.is_active == True)
)
return result.scalar_one_or_none()
# ❌ Плохо
async def getUserSub(uid):
session = get_session()
sub = session.query(Subscription).filter(Subscription.user_id==uid,Subscription.is_active==True).first()
return sub
```
### Правила именования
- **Функции и переменные**: `snake_case`
- **Классы**: `PascalCase`
- **Константы**: `UPPER_CASE`
- **Приватные методы**: `_leading_underscore`
### Типизация
Обязательно используйте type hints:
```python
from typing import Optional, List, Dict, Any
async def create_subscription(
user_id: int,
duration_days: int,
traffic_limit_gb: Optional[int] = None
) -> Subscription:
"""Создает новую подписку."""
# implementation
```
### Документация кода
```python
async def calculate_subscription_price(
period_days: int,
traffic_gb: int,
devices_count: int,
servers_count: int
) -> int:
"""
Рассчитывает стоимость подписки.
Args:
period_days: Период подписки в днях
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
devices_count: Количество устройств
servers_count: Количество серверов
Returns:
Стоимость в копейках
Raises:
ValueError: Если переданы некорректные параметры
"""
# implementation
```
### Обработка ошибок
```python
# ✅ Хорошо
try:
subscription = await subscription_service.create_subscription(user_id, data)
await message.answer("✅ Подписка создана успешно!")
except RemnaWaveAPIError as e:
logger.error(f"RemnaWave API error: {e}")
await message.answer("❌ Ошибка при создании подписки. Попробуйте позже.")
except ValidationError as e:
logger.warning(f"Validation error: {e}")
await message.answer("❌ Некорректные данные для создания подписки.")
# ❌ Плохо
try:
subscription = await subscription_service.create_subscription(user_id, data)
await message.answer("✅ Подписка создана успешно!")
except:
await message.answer("Ошибка")
```
### Логирование
```python
import logging
logger = logging.getLogger(__name__)
# Уровни логирования
logger.debug("Детальная информация для отладки")
logger.info("Общая информация о работе")
logger.warning("Предупреждение о потенциальной проблеме")
logger.error("Ошибка, которая не прерывает работу")
logger.critical("Критическая ошибка")
```
## 🔄 Работа с Git
### Ветки
- `main` - стабильная версия
- `dev` - разработка новых функций
### Коммиты
Используйте [Conventional Commits](https://www.conventionalcommits.org/):
```bash
# Типы коммитов
feat: добавить поддержку CryptoBot платежей
fix: исправить ошибку с расчетом цены подписки
docs: обновить документацию по настройке
style: исправить форматирование кода
refactor: рефакторинг сервиса платежей
test: добавить тесты для subscription_service
chore: обновить зависимости
# Примеры
git commit -m "feat(payments): добавить поддержку YooKassa webhook"
git commit -m "fix(subscription): исправить расчет стоимости устройств"
git commit -m "docs(readme): обновить инструкцию по установке"
```
### Pull Request процесс
1. **Создайте ветку** от `dev`:
```bash
git checkout develop
git pull origin develop
git checkout -b feature/new-payment-method
```
2. **Разработайте функцию** с соблюдением стандартов
3. **Протестируйте** изменения локально
4. **Создайте Pull Request** с описанием:
```markdown
## 📝 Описание
Краткое описание изменений
## 🎯 Мотивация
Почему эти изменения нужны?
## 🔧 Тип изменений
- [ ] Bug fix (исправление)
- [ ] New feature (новая функция)
- [ ] Breaking change (ломающие изменения)
- [ ] Documentation update (обновление документации)
## ✅ Чеклист
- [ ] Код соответствует стандартам проекта
- [ ] Добавлены/обновлены тесты
- [ ] Документация обновлена
- [ ] Проверена работа в Docker
- [ ] Проверена совместимость с существующим API
## 🧪 Тестирование
Как тестировались изменения:
- [ ] Локальное тестирование
- [ ] Тестирование с реальным Remnawave API
- [ ] Тестирование платежных систем
```
## 🧪 Тестирование
### Локальное тестирование
```bash
# Запуск с тестовой базой
export DATABASE_URL="sqlite:///test.db"
export BOT_TOKEN="test_token"
python main.py
```
### Тестирование компонентов
```python
# tests/test_subscription_service.py
import pytest
from app.services.subscription_service import SubscriptionService
@pytest.mark.asyncio
async def test_calculate_price():
price = await SubscriptionService.calculate_subscription_price(
period_days=30,
traffic_gb=100,
devices_count=3,
servers_count=1
)
assert price > 0
assert isinstance(price, int)
```
### Integration тесты
Тестируйте интеграцию с:
- Remnawave API (с тестовыми данными)
- Базой данных
- Платежными системами (sandbox режим)
## 👀 Процесс ревью
### Требования для ревьюера
- Проверить соответствие стандартам кода
- Убедиться в работоспособности
- Проверить безопасность (особенно для платежных функций)
- Оценить производительность
- Проверить совместимость с существующим API
### Требования для автора
- Ответить на все комментарии
- Исправить замечания
- Обновить документацию при необходимости
- Убедиться в прохождении всех проверок
## 📚 Документация
### Обновление документации
При добавлении новых функций обновляйте:
- `README.md` - если изменился API или конфигурация
- Комментарии в коде
- Примеры использования
- Changelog (если есть)
### Стиль документации
- Используйте ясный и понятный язык
- Добавляйте примеры кода
- Указывайте возможные ошибки и их решения
- Используйте эмодзи для улучшения читаемости
## 🏷 Лейблы Issues и PR
### Приоритет
- `priority:high` - высокий приоритет
- `priority:medium` - средний приоритет
- `priority:low` - низкий приоритет
### Тип
- `bug` - ошибка
- `enhancement` - улучшение
- `feature` - новая функция
- `documentation` - документация
- `question` - вопрос
### Область
- `payments` - платежные системы
- `api` - Remnawave API
- `database` - база данных
- `ui/ux` - интерфейс пользователя
- `admin` - админ панель
## 🔐 Безопасность
### Сообщения о уязвимостях
Для сообщений о критических уязвимостях безопасности:
- Свяжитесь с [@fringg](https://t.me/fringg) напрямую
- Не создавайте публичные issues для уязвимостей
- Дайте время на исправление перед публичным раскрытием
### Рекомендации по безопасности
- Никогда не коммитьте API ключи и пароли
- Используйте переменные окружения для чувствительных данных
- Валидируйте все пользовательские данные
- Используйте HTTPS для всех внешних запросов
## 📞 Получение помощи
### Каналы связи
- **💬 Telegram Group:** [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) - общие вопросы
- **🐛 GitHub Issues:** Технические вопросы и баги
- **📧 Прямой контакт:** [@fringg](https://t.me/fringg) - только критические вопросы
### Часто задаваемые вопросы
**Q: Как настроить локальную разработку без Docker?**
A: Установите PostgreSQL и Redis локально, обновите DATABASE_URL и REDIS_URL в .env
**Q: Можно ли использовать SQLite для разработки?**
A: Да, установите DATABASE_MODE=sqlite в .env
**Q: Как тестировать платежные системы?**
A: Используйте тестовые/sandbox режимы платежных систем
**Q: Что делать, если тесты не проходят?**
A: Проверьте конфигурацию .env и убедитесь, что все сервисы запущены
## 📋 Чеклист для контрибьюторов
Перед отправкой PR убедитесь:
- [ ] ✅ Код соответствует стандартам проекта
- [ ] 📝 Добавлены комментарии и docstrings
- [ ] 🧪 Функциональность протестирована
- [ ] 📚 Документация обновлена
- [ ] 🔒 Нет чувствительных данных в коде
- [ ] 🐳 Изменения работают в Docker
- [ ] 📋 PR создан с подробным описанием
- [ ] 🏷 Проставлены соответствующие лейблы
## 🎉 Благодарности
Спасибо всем, кто вносит вклад в развитие проекта! Ваша помощь делает Bedolaga Bot лучше для всего сообщества.
---
**Made with ❤️ by community**
+49 -33
View File
@@ -1,46 +1,62 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
FROM python:3.13-slim AS builder
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
# Install system dependencies
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libpq-dev \
curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -r botuser && useradd -r -g botuser botuser
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements.txt --prefer-binary
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
FROM python:3.13-slim
# Create necessary directories
RUN mkdir -p /app/logs /app/data && \
chown -R botuser:botuser /app
ARG VERSION="v2.4.5"
ARG BUILD_DATE
ARG VCS_REF
# Switch to non-root user
USER botuser
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data && \
chown -R app:app /app logs data
USER app
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
VERSION=${VERSION} \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8080 8081 8082
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.source="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.vendor="fr1ngg"
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import asyncio; import sys; sys.exit(0)"
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
# Default command
CMD ["python3", "main.py"]
CMD ["python", "main.py"]
-96
View File
@@ -1,96 +0,0 @@
# RemnaWave Bot Docker Management
.PHONY: help build up down restart logs clean db-backup db-restore
# Default target
help:
@echo "Available commands:"
@echo " build - Build all Docker images"
@echo " up - Start all services"
@echo " up-min - Start only bot and database (minimal setup)"
@echo " up-full - Start all services including nginx and redis"
@echo " down - Stop all services"
@echo " restart - Restart all services"
@echo " logs - Show logs for all services"
@echo " logs-bot - Show logs for bot service only"
@echo " logs-db - Show logs for database service only"
@echo " clean - Remove all containers, networks, and volumes"
@echo " db-backup - Create database backup"
@echo " db-restore - Restore database from backup"
@echo " shell-bot - Open shell in bot container"
@echo " shell-db - Open shell in database container"
# Build all images
build:
docker compose build
# Start minimal services (bot + database)
up-min: setup-dirs
docker compose up -d postgres bot
# Start all services including optional ones
up-full: setup-dirs
docker compose --profile with-nginx up -d
# Start main services (default)
up: setup-dirs
docker compose up -d postgres redis bot
# Setup required directories
setup-dirs:
mkdir -p logs data backups
# Stop all services
down:
docker compose down
# Restart all services
restart:
docker compose restart
# Show logs for all services
logs:
docker compose logs -f
# Show logs for bot only
logs-bot:
docker compose logs -f bot
# Show logs for database only
logs-db:
docker compose logs -f postgres
# Clean up everything (DANGEROUS - removes all data)
clean:
@echo "This will remove all containers, networks, and volumes. Are you sure? [y/N]"
@read answer && [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]
docker compose down -v --remove-orphans
docker system prune -f
# Database backup
db-backup:
@mkdir -p backups
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backups/backup_$(shell date +%Y%m%d_%H%M%S).sql
@echo "Backup created in backups/ directory"
# Database restore (use: make db-restore BACKUP=backup_20231201_120000.sql)
db-restore:
@if [ -z "$(BACKUP)" ]; then echo "Usage: make db-restore BACKUP=backup_file.sql"; exit 1; fi
docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < backups/$(BACKUP)
@echo "Database restored from $(BACKUP)"
# Open shell in bot container
shell-bot:
docker compose exec bot /bin/bash
# Open shell in database container
shell-db:
docker compose exec postgres psql -U remnawave_user -d remnawave_bot
# Check services status
status:
docker compose ps
# View service resource usage
stats:
docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
+1687 -416
View File
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
# 🔐 Security Policy
## Supported Versions
Мы обеспечиваем поддержку безопасности для следующих версий:
| Version | Supported |
| ------- | ------------------ |
| 2.x.x | ✅ Yes |
| 1.x.x | ❌ No |
| < 1.0 | ❌ No |
## Reporting a Vulnerability
### 🚨 Критические уязвимости
Для сообщений о критических уязвимостях безопасности:
**НЕ создавайте публичные Issues или Pull Requests**
Вместо этого:
1. Свяжитесь с [@fringg](https://t.me/fringg) напрямую в Telegram
2. Укажите в сообщении "SECURITY VULNERABILITY" в начале
3. Опишите уязвимость детально
4. Дайте время на исправление (обычно 90 дней)
### 📧 Контактная информация
- **Telegram**: [@fringg](https://t.me/fringg)
- **Response Time**: 24-48 часов
- **Fix Timeline**: 7-30 дней (в зависимости от критичности)
### 🔍 Что считается уязвимостью безопасности
**Критические уязвимости:**
- SQL инъекции
- Remote Code Execution (RCE)
- Обход аутентификации
- Несанкционированный доступ к данным пользователей
- Утечка API ключей или токенов
- Bypass платежных проверок
**Средние уязвимости:**
- Cross-Site Scripting (XSS) в веб-компонентах
- Небезопасная десериализация
- Недостаточная валидация входных данных
- Rate limiting bypass
**Низкие уязвимости:**
- Information disclosure
- DoS атаки
- Проблемы с логированием чувствительных данных
### 📝 Информация для включения в отчет
1. **Описание уязвимости**
- Тип уязвимости
- Затронутые компоненты
- Потенциальное воздействие
2. **Шаги воспроизведения**
- Детальные инструкции
- Скриншоты или видео (если применимо)
- Тестовые данные
3. **Proof of Concept**
- Работающий эксплойт (если безопасно)
- Логи или вывод команд
4. **Предлагаемые исправления**
- Возможные решения
- Альтернативные подходы
## Security Measures
### 🛡️ Текущие меры защиты
**Аутентификация и авторизация:**
- Проверка админ ID через ADMIN_IDS
- Валидация всех пользовательских данных
- Защита от несанкционированного доступа к админ функциям
**Защита API:**
- Поддержка различных методов аутентификации Remnawave
- Безопасная передача куки для защищенных панелей
- Rate limiting для предотвращения злоупотреблений
- Timeout для внешних запросов
**Защита данных:**
- Хеширование чувствительной информации
- Безопасное хранение API ключей
- Логирование без записи чувствительных данных
- Регулярные бекапы с возможностью восстановления
**Платежная безопасность:**
- Валидация webhook подписей
- Проверка сумм платежей
- Защита от duplicate payments
- Безопасная обработка платежных данных
**Инфраструктурная безопасность:**
- Docker контейнеризация
- Изоляция сетевого трафика
- Минимальные права доступа
- Регулярные обновления зависимостей
### 🔒 Конфиденциальные данные
**Что НЕ логируется:**
- API ключи и токены
- Пароли и секретные ключи
- Персональные данные пользователей
- Детали платежных транзакций
- Webhook секреты
**Защита в коде:**
```python
# ✅ Правильно
logger.info(f"User {user_id} created subscription")
logger.debug(f"Payment webhook received for order {order_id}")
# ❌ Неправильно
logger.info(f"API key: {api_key}")
logger.debug(f"Webhook payload: {webhook_data}")
```
### 🌐 Сетевая безопасность
**HTTPS обязателен:**
- Все внешние API вызовы через HTTPS
- Webhook endpoints защищены TLS
- Проверка SSL сертификатов
**Firewall конфигурация:**
```bash
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
```
### 📊 Мониторинг безопасности
**Автоматические проверки:**
- Мониторинг неудачных попыток аутентификации
- Отслеживание подозрительной активности
- Проверка целостности платежей
- Мониторинг состояния внешних сервисов
**Алерты:**
- Множественные неудачные попытки входа
- Неожиданные изменения в конфигурации
- Критические ошибки в платежных системах
- Недоступность Remnawave API
## Best Practices for Developers
### 🔐 Безопасное программирование
**Input Validation:**
```python
# ✅ Правильно
def validate_user_id(user_id: str) -> int:
if not user_id.isdigit():
raise ValueError("Invalid user ID format")
uid = int(user_id)
if uid <= 0:
raise ValueError("User ID must be positive")
return uid
# ❌ Неправильно
def get_user(user_id):
return session.execute(f"SELECT * FROM users WHERE id = {user_id}")
```
**SQL Injection Prevention:**
```python
# ✅ Правильно - используйте SQLAlchemy ORM
async def get_user_subscriptions(user_id: int):
result = await session.execute(
select(Subscription).where(Subscription.user_id == user_id)
)
return result.scalars().all()
# ❌ Неправильно - raw SQL без параметров
async def get_user_subscriptions(user_id: int):
result = await session.execute(
f"SELECT * FROM subscriptions WHERE user_id = {user_id}"
)
```
**API Keys Management:**
```python
# ✅ Правильно
class Config:
REMNAWAVE_API_KEY: str = os.getenv("REMNAWAVE_API_KEY", "")
def validate_required_env(self):
if not self.REMNAWAVE_API_KEY:
raise ValueError("REMNAWAVE_API_KEY is required")
# ❌ Неправильно
API_KEY = "your_api_key_here" # Никогда не хардкодьте ключи
```
**Error Handling:**
```python
# ✅ Правильно - не раскрывайте внутреннюю информацию
try:
result = await remnawave_api.create_subscription(data)
except RemnaWaveAPIError as e:
logger.error(f"RemnaWave API error: {e}")
await message.answer("Временные проблемы с сервисом. Попробуйте позже.")
# ❌ Неправильно - раскрытие внутренних деталей
except Exception as e:
await message.answer(f"Error: {str(e)}")
```
### 🔍 Code Review Security Checklist
- [ ] Нет хардкода API ключей или паролей
- [ ] Все пользовательские данные валидируются
- [ ] SQL запросы используют параметризированные запросы
- [ ] Ошибки логируются без раскрытия чувствительной информации
- [ ] Внешние запросы используют таймауты
- [ ] Webhook подписи проверяются
- [ ] Rate limiting применяется где необходимо
- [ ] Авторизация проверяется для защищенных операций
### 📋 Environment Variables Security
**Обязательные переменные:**
```env
# Критически важные - должны быть установлены
BOT_TOKEN=your_bot_token
REMNAWAVE_API_KEY=your_api_key
ADMIN_IDS=123456789,987654321
# Webhook секреты - генерируйте случайные значения
YOOKASSA_WEBHOOK_SECRET=random_secret_here
TRIBUTE_WEBHOOK_SECRET=another_random_secret
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
```
**Генерация безопасных секретов:**
```bash
# Генерация случайного секрета
openssl rand -hex 32
# Или используйте Python
python -c "import secrets; print(secrets.token_hex(32))"
```
### 🐳 Docker Security
**Dockerfile best practices:**
```dockerfile
# Не запускайтесь под root
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
# Минимальные права на файлы
COPY --chown=appuser:appuser . /app
# Удаляйте ненужные пакеты
RUN apt-get remove --purge -y build-essential && \
apt-get autoremove -y && \
apt-get clean
```
**docker-compose.yml security:**
```yaml
services:
bot:
# Ограничьте ресурсы
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8081:8081" # Только localhost
# Read-only root filesystem
read_only: true
tmpfs:
- /tmp
```
## Security Updates
### 📅 График обновлений
- **Критические уязвимости**: Исправляются в течение 24-48 часов
- **Высокий приоритет**: Исправляются в течение 7 дней
- **Средний приоритет**: Исправляются в течение 30 дней
- **Низкий приоритет**: Исправляются в следующем планируемом релизе
### 🔄 Процесс обновления
1. **Уведомление**: Администраторы получают уведомление о доступных обновлениях
2. **Тестирование**: Обновления тестируются в изолированной среде
3. **Развертывание**: Постепенное развертывание с возможностью отката
4. **Мониторинг**: Наблюдение за стабильностью после обновления
### 📢 Уведомления о безопасности
Подпишитесь на уведомления:
- **Telegram**: [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)
- **GitHub**: Watch repository для получения уведомлений о релизах
- **RSS**: Следите за GitHub Releases
## Compliance and Standards
### 📜 Соответствие стандартам
**GDPR Compliance:**
- Минимизация сбора данных
- Право на удаление данных
- Уведомление о нарушениях безопасности
- Согласие на обработку данных
**PCI DSS (для платежей):**
- Не храним данные банковских карт
- Используем сертифицированные платежные провайдеры
- Шифрование чувствительных данных
- Регулярные аудиты безопасности
### 🛡️ Third-party Security
**Используемые сервисы:**
- **Telegram API**: Официальный API с высоким уровнем безопасности
- **Remnawave**: VPN панель с собственными мерами безопасности
- **YooKassa**: PCI DSS сертифицированный платежный провайдер
- **CryptoBot**: Криптовалютные платежи с блокчейн безопасностью
**Регулярные проверки:**
- Мониторинг безопасности dependencies
- Обновление библиотек и зависимостей
- Проверка CVE для используемых компонентов
## Incident Response
### 🚨 В случае инцидента безопасности
1. **Немедленно**:
- Изолируйте затронутую систему
- Сохраните логи и доказательства
- Уведомите команду разработки
2. **В течение часа**:
- Оцените масштаб инцидента
- Определите затронутых пользователей
- Начните процедуры восстановления
3. **В течение дня**:
- Устраните уязвимость
- Восстановите нормальную работу
- Уведомите пользователей (если необходимо)
4. **После инцидента**:
- Проведите post-mortem анализ
- Обновите процедуры безопасности
- Внедрите дополнительные защитные меры
### 📊 Отчетность
**Внутренние отчеты:**
- Еженедельные отчеты о безопасности
- Ежемесячные аудиты логов
- Квартальные обзоры политик безопасности
**Внешние уведомления:**
- Уведомление пользователей о критических обновлениях
- Публикация Security Advisories при необходимости
- Сотрудничество с исследователями безопасности
---
**Помните**: Безопасность - это постоянный процесс, а не одноразовая настройка. Регулярно обновляйте зависимости, следите за новостями безопасности и не стесняйтесь сообщать о подозрительной активности.
-8305
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-201
View File
@@ -1,201 +0,0 @@
import logging
from typing import Optional, Dict, Any, Callable
from aiogram.types import CallbackQuery
from aiogram import Router
from remnawave_api import RemnaWaveAPI
from translations import t
logger = logging.getLogger(__name__)
class APIErrorHandler:
@staticmethod
async def handle_api_error(callback: CallbackQuery, error: Exception,
operation: str, user_language: str = 'ru',
fallback_keyboard=None) -> bool:
error_message = str(error).lower()
if "timeout" in error_message or "connection" in error_message:
text = "⏱ Таймаут подключения к API\n\n"
text += "Возможные причины:\n"
text += "• Медленный интернет\n"
text += "• Перегрузка сервера RemnaWave\n"
text += "• Временные проблемы с сетью\n\n"
text += "🔄 Попробуйте повторить операцию через несколько секунд"
elif "401" in error_message or "unauthorized" in error_message:
text = "🔐 Ошибка авторизации API\n\n"
text += "Токен доступа недействителен или истек.\n"
text += "Обратитесь к администратору для обновления токена."
elif "404" in error_message or "not found" in error_message:
text = f"❌ Ресурс не найден\n\n"
text += f"Операция: {operation}\n"
text += "Возможно, запрашиваемый объект был удален или не существует."
elif "500" in error_message or "internal server error" in error_message:
text = "🔥 Внутренняя ошибка сервера RemnaWave\n\n"
text += "Сервер временно недоступен.\n"
text += "Попробуйте повторить операцию позже."
else:
text = f"❌ Ошибка API операции: {operation}\n\n"
text += f"Детали: {str(error)[:100]}{'...' if len(str(error)) > 100 else ''}\n\n"
text += "Обратитесь к администратору если проблема повторяется."
try:
await callback.message.edit_text(
text,
reply_markup=fallback_keyboard or error_recovery_keyboard(operation, user_language)
)
return True
except Exception as edit_error:
logger.error(f"Failed to edit message with error info: {edit_error}")
try:
await callback.answer(f"❌ Ошибка: {operation}", show_alert=True)
return True
except:
return False
@staticmethod
async def safe_api_call(api_method: Callable, *args, **kwargs) -> tuple[bool, Any]:
try:
result = await api_method(*args, **kwargs)
return True, result
except Exception as e:
logger.error(f"API call failed: {api_method.__name__} - {e}")
return False, str(e)
def create_error_recovery_keyboard(error_context: str, language: str = 'ru'):
from keyboards import error_recovery_keyboard
return error_recovery_keyboard(error_context, language)
async def safe_get_nodes(api: RemnaWaveAPI) -> tuple[bool, list]:
try:
logger.info("Attempting to fetch nodes from API...")
nodes = await api.get_all_nodes()
if nodes is None:
logger.warning("API returned None for nodes")
return False, []
if not isinstance(nodes, list):
logger.warning(f"API returned non-list for nodes: {type(nodes)}")
return False, []
logger.info(f"Successfully fetched {len(nodes)} nodes")
return True, nodes
except Exception as e:
logger.error(f"Error fetching nodes: {e}")
return False, []
async def safe_get_system_users(api: RemnaWaveAPI) -> tuple[bool, list]:
try:
logger.info("Attempting to fetch system users from API...")
users = await api.get_all_system_users_full()
if users is None:
logger.warning("API returned None for users")
return False, []
if not isinstance(users, list):
logger.warning(f"API returned non-list for users: {type(users)}")
return False, []
logger.info(f"Successfully fetched {len(users)} users")
return True, users
except Exception as e:
logger.error(f"Error fetching system users: {e}")
return False, []
async def safe_restart_nodes(api: RemnaWaveAPI, all_nodes: bool = True, node_id: str = None) -> tuple[bool, str]:
try:
if all_nodes:
logger.info("Attempting to restart all nodes...")
result = await api.restart_all_nodes()
else:
logger.info(f"Attempting to restart node {node_id}...")
result = await api.restart_node(node_id)
if result:
message = "Команда перезагрузки отправлена успешно"
logger.info(f"Restart command sent successfully")
return True, message
else:
message = "API вернул отрицательный результат"
logger.warning("API returned negative result for restart")
return False, message
except Exception as e:
logger.error(f"Error restarting nodes: {e}")
return False, str(e)
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
health_info = {
'api_available': False,
'nodes_accessible': False,
'users_accessible': False,
'system_stats_accessible': False,
'errors': []
}
if api is None:
health_info['errors'].append("API instance is None")
return health_info
try:
success, nodes = await safe_get_nodes(api)
if success:
health_info['api_available'] = True
health_info['nodes_accessible'] = True
else:
health_info['errors'].append("Cannot fetch nodes")
except Exception as e:
health_info['errors'].append(f"Nodes check failed: {e}")
try:
success, users = await safe_get_system_users(api)
if success:
health_info['users_accessible'] = True
else:
health_info['errors'].append("Cannot fetch users")
except Exception as e:
health_info['errors'].append(f"Users check failed: {e}")
try:
stats = await api.get_system_stats()
if stats:
health_info['system_stats_accessible'] = True
else:
health_info['errors'].append("Cannot fetch system stats")
except Exception as e:
health_info['errors'].append(f"System stats check failed: {e}")
return health_info
def handle_api_errors(operation_name: str):
def decorator(func):
async def wrapper(callback: CallbackQuery, user, *args, **kwargs):
try:
return await func(callback, user, *args, **kwargs)
except Exception as e:
logger.error(f"Error in {func.__name__}: {e}")
api = kwargs.get('api')
fallback_keyboard = None
if 'nodes' in operation_name.lower():
from keyboards import admin_system_keyboard
fallback_keyboard = admin_system_keyboard(user.language)
elif 'users' in operation_name.lower():
from keyboards import system_users_keyboard
fallback_keyboard = system_users_keyboard(user.language)
await APIErrorHandler.handle_api_error(
callback, e, operation_name, user.language, fallback_keyboard
)
return wrapper
return decorator
+233
View File
@@ -0,0 +1,233 @@
{
"config": {},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"fa": "App Store [EU]",
"ru": "App Store [EU]",
"zh": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"fa": "App Store [RU]",
"ru": "App Store [RU]",
"zh": "App Store [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Google Play",
"fa": "Google Play",
"ru": "Google Play",
"zh": "Google Play"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
}
],
"macos": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"fa": "App Store [EU]",
"ru": "App Store [EU]",
"zh": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"fa": "App Store [RU]",
"ru": "App Store [RU]",
"zh": "App Store [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"windows": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Download",
"ru": "Скачать"
}
}
],
"description": {
"en": "Download and install Happ.",
"ru": "Скачайте и установите Happ"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
}
}
}
],
"androidTV": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
],
"linux": [],
"appleTV": []
}
}
+185
View File
@@ -0,0 +1,185 @@
import logging
from aiogram import Bot, Dispatcher, types
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.storage.memory import MemoryStorage
import redis.asyncio as redis
from app.config import settings
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.auth import AuthMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
)
from app.handlers.admin import (
main as admin_main,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
messages as admin_messages,
monitoring as admin_monitoring,
referrals as admin_referrals,
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
backup as admin_backup,
system_logs as admin_system_logs,
welcome_text as admin_welcome_text,
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
)
from app.handlers.stars_payments import register_stars_handlers
from app.utils.message_patch import patch_message_methods
patch_message_methods()
logger = logging.getLogger(__name__)
async def debug_callback_handler(callback: types.CallbackQuery):
logger.info(f"🔍 DEBUG CALLBACK:")
logger.info(f" - Data: {callback.data}")
logger.info(f" - User: {callback.from_user.id}")
logger.info(f" - Username: {callback.from_user.username}")
async def setup_bot() -> tuple[Bot, Dispatcher]:
try:
await cache.connect()
logger.info("Кеш инициализирован")
except Exception as e:
logger.warning(f"Кеш не инициализирован: {e}")
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
maintenance_service.set_bot(bot)
logger.info("Бот установлен в maintenance_service")
try:
redis_client = redis.from_url(settings.REDIS_URL)
await redis_client.ping()
storage = RedisStorage(redis_client)
logger.info("Подключено к Redis для FSM storage")
except Exception as e:
logger.warning(f"Не удалось подключиться к Redis: {e}")
logger.info("Используется MemoryStorage для FSM")
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован")
else:
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
balance.register_handlers(dp)
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
server_status.register_handlers(dp)
tickets.register_handlers(dp)
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
admin_servers.register_handlers(dp)
admin_promocodes.register_handlers(dp)
admin_messages.register_handlers(dp)
admin_monitoring.register_handlers(dp)
admin_referrals.register_handlers(dp)
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
admin_backup.register_handlers(dp)
admin_system_logs.register_handlers(dp)
admin_welcome_text.register_welcome_text_handlers(dp)
admin_tickets.register_handlers(dp)
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
admin_pricing.register_handlers(dp)
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
return bot, dp
async def shutdown_bot():
try:
await maintenance_service.stop_monitoring()
logger.info("Мониторинг техработ остановлен")
except Exception as e:
logger.error(f"Ошибка остановки мониторинга: {e}")
try:
await cache.close()
logger.info("Соединения с кешем закрыты")
except Exception as e:
logger.error(f"Ошибка закрытия кеша: {e}")
+1104
View File
File diff suppressed because it is too large Load Diff
+491
View File
@@ -0,0 +1,491 @@
import logging
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy import and_, func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
Subscription,
SubscriptionConversion,
SubscriptionStatus,
Transaction,
TransactionType,
User,
)
logger = logging.getLogger(__name__)
async def create_campaign(
db: AsyncSession,
*,
name: str,
start_parameter: str,
bonus_type: str,
created_by: Optional[int] = None,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
is_active: bool = True,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
start_parameter=start_parameter,
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
subscription_traffic_gb=subscription_traffic_gb,
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
created_by=created_by,
is_active=is_active,
)
db.add(campaign)
await db.commit()
await db.refresh(campaign)
logger.info(
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
)
return campaign
async def get_campaign_by_id(
db: AsyncSession, campaign_id: int
) -> Optional[AdvertisingCampaign]:
result = await db.execute(
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.where(AdvertisingCampaign.id == campaign_id)
)
return result.scalar_one_or_none()
async def get_campaign_by_start_parameter(
db: AsyncSession,
start_parameter: str,
*,
only_active: bool = False,
) -> Optional[AdvertisingCampaign]:
stmt = select(AdvertisingCampaign).where(
AdvertisingCampaign.start_parameter == start_parameter
)
if only_active:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_campaigns_list(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 20,
include_inactive: bool = True,
) -> List[AdvertisingCampaign]:
stmt = (
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
.limit(limit)
)
if not include_inactive:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
result = await db.execute(stmt)
return result.scalars().all()
async def get_campaigns_count(
db: AsyncSession, *, is_active: Optional[bool] = None
) -> int:
stmt = select(func.count(AdvertisingCampaign.id))
if is_active is not None:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
result = await db.execute(stmt)
return result.scalar_one() or 0
async def update_campaign(
db: AsyncSession,
campaign: AdvertisingCampaign,
**kwargs,
) -> AdvertisingCampaign:
allowed_fields = {
"name",
"start_parameter",
"bonus_type",
"balance_bonus_kopeks",
"subscription_duration_days",
"subscription_traffic_gb",
"subscription_device_limit",
"subscription_squads",
"is_active",
}
update_data = {}
for key, value in kwargs.items():
if key in allowed_fields and value is not None:
update_data[key] = value
if not update_data:
return campaign
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.id == campaign.id)
.values(**update_data)
)
await db.commit()
await db.refresh(campaign)
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
return campaign
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
await db.execute(
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
)
await db.commit()
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
return True
async def get_campaign_registration_by_user(
db: AsyncSession,
user_id: int,
) -> Optional[AdvertisingCampaignRegistration]:
result = await db.execute(
select(AdvertisingCampaignRegistration)
.options(selectinload(AdvertisingCampaignRegistration.campaign))
.where(AdvertisingCampaignRegistration.user_id == user_id)
.limit(1)
)
return result.scalar_one_or_none()
async def record_campaign_registration(
db: AsyncSession,
*,
campaign_id: int,
user_id: int,
bonus_type: str,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
) -> AdvertisingCampaignRegistration:
existing = await db.execute(
select(AdvertisingCampaignRegistration).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.user_id == user_id,
)
)
)
registration = existing.scalar_one_or_none()
if registration:
return registration
registration = AdvertisingCampaignRegistration(
campaign_id=campaign_id,
user_id=user_id,
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
)
db.add(registration)
await db.commit()
await db.refresh(registration)
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
return registration
async def get_campaign_statistics(
db: AsyncSession,
campaign_id: int,
) -> Dict[str, Optional[int]]:
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
registrations_subquery = registrations_query.subquery()
result = await db.execute(
select(
func.count(AdvertisingCampaignRegistration.id),
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
),
func.max(AdvertisingCampaignRegistration.created_at),
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
)
count, total_balance, last_registration = result.one()
count = count or 0
total_balance = total_balance or 0
subscription_count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.bonus_type == "subscription",
)
)
)
subscription_bonuses_issued = subscription_count_result.scalar() or 0
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
)
)
deposits_total = deposits_result.scalar() or 0
trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
)
)
trial_users_count = trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
active_trials_count = active_trials_result.scalar() or 0
conversions_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
conversion_count = conversions_result.scalar() or 0
paid_users_result = await db.execute(
select(func.count(User.id)).where(
User.id.in_(select(registrations_subquery.c.user_id)),
User.has_had_paid_subscription.is_(True),
)
)
paid_users_from_flag = paid_users_result.scalar() or 0
conversions_rows = await db.execute(
select(
SubscriptionConversion.user_id,
SubscriptionConversion.first_payment_amount_kopeks,
SubscriptionConversion.converted_at,
)
.where(
SubscriptionConversion.user_id.in_(
select(registrations_subquery.c.user_id)
)
)
.order_by(SubscriptionConversion.converted_at)
)
conversion_entries = conversions_rows.all()
subscription_payments_rows = await db.execute(
select(
Transaction.user_id,
Transaction.amount_kopeks,
Transaction.created_at,
)
.where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed.is_(True),
)
.order_by(Transaction.user_id, Transaction.created_at)
)
subscription_payments = subscription_payments_rows.all()
subscription_payments_total = 0
paid_users_from_transactions = set()
conversion_user_ids = set()
first_payment_amount_by_user: Dict[int, int] = {}
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
for user_id, amount_kopeks, converted_at in conversion_entries:
conversion_user_ids.add(user_id)
amount_value = int(amount_kopeks or 0)
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = converted_at
for user_id, amount_kopeks, created_at in subscription_payments:
amount_value = int(amount_kopeks or 0)
subscription_payments_total += amount_value
paid_users_from_transactions.add(user_id)
if user_id not in first_payment_amount_by_user:
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
else:
existing_time = first_payment_time_by_user.get(user_id)
if existing_time is None and created_at is not None:
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
elif (
existing_time is not None
and created_at is not None
and created_at < existing_time
):
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
total_revenue = deposits_total + subscription_payments_total
paid_user_ids = set(paid_users_from_transactions)
paid_user_ids.update(conversion_user_ids)
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
conversion_count = conversion_count or len(paid_user_ids)
if conversion_count < len(paid_user_ids):
conversion_count = len(paid_user_ids)
avg_first_payment = 0
if first_payment_amount_by_user:
avg_first_payment = int(
sum(first_payment_amount_by_user.values())
/ len(first_payment_amount_by_user)
)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
trial_conversion_rate = 0.0
if trial_users_count:
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
avg_revenue_per_user = 0
if count:
avg_revenue_per_user = int(total_revenue / count)
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
)
)
total_revenue = deposits_result.scalar() or 0
trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
)
)
trial_users_count = trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
active_trials_count = active_trials_result.scalar() or 0
conversions_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
conversion_count = conversions_result.scalar() or 0
paid_users_result = await db.execute(
select(func.count(User.id)).where(
User.id.in_(select(registrations_subquery.c.user_id)),
User.has_had_paid_subscription.is_(True),
)
)
paid_users_count = paid_users_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
trial_conversion_rate = 0.0
if trial_users_count:
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
avg_revenue_per_user = 0
if count:
avg_revenue_per_user = int(total_revenue / count)
return {
"registrations": count,
"balance_issued": total_balance,
"subscription_issued": subscription_bonuses_issued,
"last_registration": last_registration,
"total_revenue_kopeks": total_revenue,
"trial_users_count": trial_users_count,
"active_trials_count": active_trials_count,
"conversion_count": conversion_count,
"paid_users_count": paid_users_count,
"conversion_rate": conversion_rate,
"trial_conversion_rate": trial_conversion_rate,
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
"avg_first_payment_kopeks": avg_first_payment,
}
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
total = await get_campaigns_count(db)
active = await get_campaigns_count(db, is_active=True)
inactive = await get_campaigns_count(db, is_active=False)
registrations_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id))
)
balance_result = await db.execute(
select(
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
)
)
)
subscription_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == "subscription"
)
)
return {
"total": total,
"active": active,
"inactive": inactive,
"registrations": registrations_result.scalar() or 0,
"balance_total": balance_result.scalar() or 0,
"subscription_total": subscription_result.scalar() or 0,
}
+156
View File
@@ -0,0 +1,156 @@
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import CryptoBotPayment
logger = logging.getLogger(__name__)
async def create_cryptobot_payment(
db: AsyncSession,
user_id: int,
invoice_id: str,
amount: str,
asset: str,
status: str = "active",
description: Optional[str] = None,
payload: Optional[str] = None,
bot_invoice_url: Optional[str] = None,
mini_app_invoice_url: Optional[str] = None,
web_app_invoice_url: Optional[str] = None
) -> CryptoBotPayment:
payment = CryptoBotPayment(
user_id=user_id,
invoice_id=invoice_id,
amount=amount,
asset=asset,
status=status,
description=description,
payload=payload,
bot_invoice_url=bot_invoice_url,
mini_app_invoice_url=mini_app_invoice_url,
web_app_invoice_url=web_app_invoice_url
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}")
return payment
async def get_cryptobot_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str
) -> Optional[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.invoice_id == invoice_id)
)
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_id(
db: AsyncSession,
payment_id: int
) -> Optional[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_cryptobot_payment_status(
db: AsyncSession,
invoice_id: str,
status: str,
paid_at: Optional[datetime] = None
) -> Optional[CryptoBotPayment]:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.status = status
payment.updated_at = datetime.utcnow()
if status == "paid" and paid_at:
payment.paid_at = paid_at
await db.commit()
await db.refresh(payment)
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
return payment
async def link_cryptobot_payment_to_transaction(
db: AsyncSession,
invoice_id: str,
transaction_id: int
) -> Optional[CryptoBotPayment]:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
return payment
async def get_user_cryptobot_payments(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.where(CryptoBotPayment.user_id == user_id)
.order_by(CryptoBotPayment.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_pending_cryptobot_payments(
db: AsyncSession,
older_than_hours: int = 24
) -> List[CryptoBotPayment]:
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(
and_(
CryptoBotPayment.status == "active",
CryptoBotPayment.created_at < cutoff_time
)
)
.order_by(CryptoBotPayment.created_at)
)
return result.scalars().all()
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
async def upsert_discount_offer(
db: AsyncSession,
*,
user_id: int,
subscription_id: Optional[int],
notification_type: str,
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
result = await db.execute(
select(DiscountOffer)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.notification_type == notification_type,
DiscountOffer.is_active == True, # noqa: E712
)
.order_by(DiscountOffer.created_at.desc())
)
offer = result.scalars().first()
if offer and offer.claimed_at is None:
offer.discount_percent = discount_percent
offer.bonus_amount_kopeks = bonus_amount_kopeks
offer.expires_at = expires_at
offer.subscription_id = subscription_id
offer.effect_type = effect_type
offer.extra_data = extra_data
else:
offer = DiscountOffer(
user_id=user_id,
subscription_id=subscription_id,
notification_type=notification_type,
discount_percent=discount_percent,
bonus_amount_kopeks=bonus_amount_kopeks,
expires_at=expires_at,
is_active=True,
effect_type=effect_type,
extra_data=extra_data,
)
db.add(offer)
await db.commit()
await db.refresh(offer)
return offer
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
result = await db.execute(
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(DiscountOffer.id == offer_id)
)
return result.scalar_one_or_none()
async def list_discount_offers(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 50,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> List[DiscountOffer]:
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.order_by(DiscountOffer.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def count_discount_offers(
db: AsyncSession,
*,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> int:
stmt = select(func.count(DiscountOffer.id))
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return int(result.scalar() or 0)
async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: Optional[dict] = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
await db.commit()
await db.refresh(offer)
try:
await log_promo_offer_action(
db,
user_id=offer.user_id,
offer_id=offer.id,
action="claimed",
source=offer.notification_type,
percent=offer.discount_percent,
effect_type=offer.effect_type,
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer claim log for offer %s: %s",
offer.id,
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer claim log failure: %s",
rollback_error,
)
return offer
async def deactivate_expired_offers(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer).where(
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.expires_at < now,
)
)
offers = result.scalars().all()
if not offers:
return 0
count = 0
log_payloads = []
for offer in offers:
offer.is_active = False
count += 1
log_payloads.append(
{
"user_id": offer.user_id,
"offer_id": offer.id,
"source": offer.notification_type,
"percent": offer.discount_percent,
"effect_type": offer.effect_type,
}
)
await db.commit()
for payload in log_payloads:
if not payload.get("user_id"):
continue
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload["offer_id"],
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer disable log for offer %s: %s",
payload.get("offer_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer disable log failure: %s",
rollback_error,
)
return count
async def get_latest_claimed_offer_for_user(
db: AsyncSession,
user_id: int,
source: Optional[str] = None,
) -> Optional[DiscountOffer]:
stmt = (
select(DiscountOffer)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.claimed_at.isnot(None),
)
.order_by(DiscountOffer.claimed_at.desc())
)
if source:
stmt = stmt.where(DiscountOffer.notification_type == source)
result = await db.execute(stmt)
return result.scalars().first()
+150
View File
@@ -0,0 +1,150 @@
import logging
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
result = await db.execute(
select(FaqSetting).where(FaqSetting.language == language)
)
return result.scalar_one_or_none()
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
setting = await get_faq_setting(db, language)
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.utcnow()
else:
setting = FaqSetting(
language=language,
is_enabled=bool(enabled),
)
db.add(setting)
await db.commit()
await db.refresh(setting)
logger.info(
"✅ Статус FAQ для языка %s обновлен: %s",
language,
"enabled" if setting.is_enabled else "disabled",
)
return setting
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
return await set_faq_enabled(db, language, enabled)
async def get_faq_pages(
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
) -> list[FaqPage]:
query = select(FaqPage).where(FaqPage.language == language)
if not include_inactive:
query = query.where(FaqPage.is_active.is_(True))
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
result = await db.execute(query)
pages = list(result.scalars().all())
return pages
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
return result.scalar_one_or_none()
async def create_faq_page(
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: bool = True,
) -> FaqPage:
if display_order is None:
result = await db.execute(
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
)
max_order = result.scalar() or 0
display_order = max_order + 1
page = FaqPage(
language=language,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
db.add(page)
await db.commit()
await db.refresh(page)
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
return page
async def update_faq_page(
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
if title is not None:
page.title = title
if content is not None:
page.content = content
if display_order is not None:
page.display_order = display_order
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(page)
logger.info("✅ Страница FAQ %s обновлена", page.id)
return page
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info("🗑️ Страница FAQ %s удалена", page_id)
async def bulk_update_order(
db: AsyncSession,
pages: Iterable[tuple[int, int]],
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage)
.where(FaqPage.id == page_id)
.values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
+118
View File
@@ -0,0 +1,118 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
async def create_mulenpay_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
uuid: str,
description: str,
payment_url: Optional[str],
mulen_payment_id: Optional[int],
currency: str,
status: str,
metadata: Optional[dict] = None,
) -> MulenPayPayment:
payment = MulenPayPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
uuid=uuid,
description=description,
payment_url=payment_url,
mulen_payment_id=mulen_payment_id,
currency=currency,
status=status,
metadata_json=metadata or {},
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
payment.mulen_payment_id,
uuid,
amount_kopeks,
user_id,
)
return payment
async def get_mulenpay_payment_by_local_id(
db: AsyncSession, payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_uuid(
db: AsyncSession, uuid: str
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_mulen_id(
db: AsyncSession, mulen_payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(
MulenPayPayment.mulen_payment_id == mulen_payment_id
)
)
return result.scalar_one_or_none()
async def update_mulenpay_payment_status(
db: AsyncSession,
*,
payment: MulenPayPayment,
status: str,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None,
) -> MulenPayPayment:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at:
payment.paid_at = paid_at
if callback_payload is not None:
payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_mulenpay_payment_to_transaction(
db: AsyncSession,
*,
payment: MulenPayPayment,
transaction_id: int,
) -> MulenPayPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+66
View File
@@ -0,0 +1,66 @@
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database.models import SentNotification
logger = logging.getLogger(__name__)
async def notification_sent(
db: AsyncSession,
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
) -> bool:
result = await db.execute(
select(SentNotification).where(
SentNotification.user_id == user_id,
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
SentNotification.days_before == days_before,
)
)
return result.scalar_one_or_none() is not None
async def record_notification(
db: AsyncSession,
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
) -> None:
notification = SentNotification(
user_id=user_id,
subscription_id=subscription_id,
notification_type=notification_type,
days_before=days_before,
)
db.add(notification)
await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id
)
)
await db.commit()
async def clear_notification_by_type(
db: AsyncSession,
subscription_id: int,
notification_type: str,
) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
)
)
await db.commit()
+160
View File
@@ -0,0 +1,160 @@
"""CRUD helpers for PayPalych (Pal24) payments."""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Pal24Payment
logger = logging.getLogger(__name__)
async def create_pal24_payment(
db: AsyncSession,
*,
user_id: int,
bill_id: str,
amount_kopeks: int,
description: Optional[str],
status: str,
type_: str,
currency: str,
link_url: Optional[str],
link_page_url: Optional[str],
order_id: Optional[str] = None,
ttl: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
payment = Pal24Payment(
user_id=user_id,
bill_id=bill_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
type=type_,
link_url=link_url,
link_page_url=link_page_url,
metadata_json=metadata or {},
ttl=ttl,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
status,
)
return payment
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
)
return result.scalar_one_or_none()
async def update_pal24_payment_status(
db: AsyncSession,
payment: Pal24Payment,
*,
status: str,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
balance_amount: Optional[str] = None,
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
update_values: Dict[str, Any] = {
"status": status,
}
if is_active is not None:
update_values["is_active"] = is_active
if is_paid is not None:
update_values["is_paid"] = is_paid
if payment_id is not None:
update_values["payment_id"] = payment_id
if payment_status is not None:
update_values["payment_status"] = payment_status
if payment_method is not None:
update_values["payment_method"] = payment_method
if balance_amount is not None:
update_values["balance_amount"] = balance_amount
if balance_currency is not None:
update_values["balance_currency"] = balance_currency
if payer_account is not None:
update_values["payer_account"] = payer_account
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
update_values["last_status"] = status
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
payment.bill_id,
payment.status,
payment.is_paid,
)
return payment
async def link_pal24_payment_to_transaction(
db: AsyncSession,
payment: Pal24Payment,
transaction_id: int,
) -> Pal24Payment:
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Pal24 платеж %s привязан к транзакции %s",
payment.bill_id,
transaction_id,
)
return payment
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
result = await db.execute(
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
)
return result.scalar_one_or_none()
async def upsert_privacy_policy(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.content = content or ""
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
language,
policy.id,
)
return policy
async def set_privacy_policy_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.is_enabled = bool(enabled)
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
language,
"enabled" if policy.is_enabled else "disabled",
)
return policy
+251
View File
@@ -0,0 +1,251 @@
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
if not period_discounts:
return {}
normalized: Dict[int, int] = {}
for key, value in period_discounts.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized[period] = max(0, min(100, percent))
return normalized
logger = logging.getLogger(__name__)
async def get_promo_groups_with_counts(
db: AsyncSession,
*,
offset: int = 0,
limit: Optional[int] = None,
) -> List[Tuple[PromoGroup, int]]:
query = (
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
)
if offset:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
result = await db.execute(query)
return result.all()
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
return await db.get(PromoGroup, group_id)
async def count_promo_groups(db: AsyncSession) -> int:
result = await db.execute(select(func.count(PromoGroup.id)))
return int(result.scalar_one())
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
result = await db.execute(
select(PromoGroup).where(PromoGroup.is_default.is_(True))
)
return result.scalars().first()
async def create_promo_group(
db: AsyncSession,
name: str,
*,
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: bool = True,
is_default: bool = False,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
auto_assign_total_spent_kopeks = (
max(0, auto_assign_total_spent_kopeks)
if auto_assign_total_spent_kopeks is not None
else None
)
existing_default = await get_default_promo_group(db)
should_be_default = existing_default is None or is_default
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
period_discounts=normalized_period_discounts or None,
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
apply_discounts_to_addons=bool(apply_discounts_to_addons),
is_default=should_be_default,
)
db.add(promo_group)
await db.flush()
if should_be_default and existing_default and existing_default.id != promo_group.id:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != promo_group.id)
.values(is_default=False)
)
await db.commit()
await db.refresh(promo_group)
logger.info(
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
promo_group.name,
promo_group.is_default,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
normalized_period_discounts,
(auto_assign_total_spent_kopeks or 0) / 100,
"on" if promo_group.apply_discounts_to_addons else "off",
)
return promo_group
async def update_promo_group(
db: AsyncSession,
group: PromoGroup,
*,
name: Optional[str] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: Optional[bool] = None,
is_default: Optional[bool] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
if server_discount_percent is not None:
group.server_discount_percent = max(0, min(100, server_discount_percent))
if traffic_discount_percent is not None:
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
if device_discount_percent is not None:
group.device_discount_percent = max(0, min(100, device_discount_percent))
if period_discounts is not None:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
group.period_discounts = normalized_period_discounts or None
if auto_assign_total_spent_kopeks is not None:
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
if apply_discounts_to_addons is not None:
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
if is_default is not None:
if is_default:
group.is_default = True
await db.flush()
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != group.id)
.values(is_default=False)
)
else:
if group.is_default:
group.is_default = False
await db.flush()
replacement = await db.execute(
select(PromoGroup)
.where(PromoGroup.id != group.id)
.order_by(PromoGroup.id)
.limit(1)
)
new_default = replacement.scalars().first()
if new_default:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id == new_default.id)
.values(is_default=True)
)
else:
# Не допускаем состояния без базовой промогруппы
group.is_default = True
await db.commit()
await db.refresh(group)
logger.info(
"Обновлена промогруппа '%s' (id=%s)",
group.name,
group.id,
)
return group
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
if group.is_default:
logger.warning("Попытка удалить базовую промогруппу запрещена")
return False
default_group = await get_default_promo_group(db)
if not default_group:
logger.error("Не найдена базовая промогруппа для reassignment")
return False
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
await db.delete(group)
await db.commit()
logger.info(
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
group.name,
group.id,
default_group.name,
)
return True
async def get_promo_group_members(
db: AsyncSession,
group_id: int,
*,
offset: int = 0,
limit: int = 20,
) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.where(User.promo_group_id == group_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
result = await db.execute(
select(func.count(User.id)).where(User.promo_group_id == group_id)
)
return result.scalar_one()
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
async def log_promo_offer_action(
db: AsyncSession,
*,
user_id: Optional[int],
offer_id: Optional[int],
action: str,
source: Optional[str] = None,
percent: Optional[int] = None,
effect_type: Optional[str] = None,
details: Optional[Dict[str, object]] = None,
commit: bool = True,
) -> PromoOfferLog:
"""Persist a promo offer log entry."""
entry = PromoOfferLog(
user_id=user_id,
offer_id=offer_id,
action=action,
source=source,
percent=percent,
effect_type=effect_type,
details=(details or {}).copy(),
)
db.add(entry)
if commit:
try:
await db.commit()
await db.refresh(entry)
except Exception:
logger.exception("Failed to commit promo offer log entry")
raise
return entry
async def list_promo_offer_logs(
db: AsyncSession,
offset: int = 0,
limit: int = 20,
*,
user_id: Optional[int] = None,
offer_id: Optional[int] = None,
action: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[PromoOfferLog], int]:
stmt = (
select(PromoOfferLog)
.options(
selectinload(PromoOfferLog.user),
selectinload(PromoOfferLog.offer),
)
.order_by(PromoOfferLog.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
stmt = stmt.where(PromoOfferLog.action == action)
if source:
stmt = stmt.where(PromoOfferLog.source == source)
result = await db.execute(stmt)
logs = result.scalars().all()
count_stmt = select(func.count(PromoOfferLog.id))
if user_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
count_stmt = count_stmt.where(PromoOfferLog.action == action)
if source:
count_stmt = count_stmt.where(PromoOfferLog.source == source)
total = (await db.execute(count_stmt)).scalar() or 0
return logs, total
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PromoOfferTemplate
UPDATED_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 Экономия {discount_percent}% при продлении\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
LEGACY_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
DEFAULT_TEMPLATES: tuple[dict, ...] = (
{
"offer_type": "test_access",
"name": "Тестовые сервера",
"message_text": (
"🔥 <b>Испытайте новые сервера</b>\n\n"
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
"Предложение действительно {valid_hours} ч."
),
"button_text": "🚀 Попробовать серверы",
"valid_hours": 24,
"discount_percent": 0,
"bonus_amount_kopeks": 0,
"active_discount_hours": None,
"test_duration_hours": 24,
"test_squad_uuids": [],
},
{
"offer_type": "extend_discount",
"name": "Скидка на продление",
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
"button_text": "🎁 Получить скидку",
"valid_hours": 24,
"discount_percent": 20,
"bonus_amount_kopeks": 0,
"active_discount_hours": 24,
"test_duration_hours": None,
"test_squad_uuids": [],
},
{
"offer_type": "purchase_discount",
"name": "Скидка на покупку",
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
"button_text": "🎁 Забрать скидку",
"valid_hours": 48,
"discount_percent": 25,
"bonus_amount_kopeks": 0,
"active_discount_hours": 48,
"test_duration_hours": None,
"test_squad_uuids": [],
},
)
def _format_template_fields(payload: dict) -> dict:
data = dict(payload)
data.setdefault("valid_hours", 24)
data.setdefault("discount_percent", 0)
data.setdefault("bonus_amount_kopeks", 0)
data.setdefault("active_discount_hours", None)
data.setdefault("test_duration_hours", None)
data.setdefault("test_squad_uuids", [])
return data
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
templates: List[PromoOfferTemplate] = []
for template_data in DEFAULT_TEMPLATES:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
)
existing = result.scalars().first()
if existing:
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
should_update = False
if new_message and legacy_message and existing.message_text == legacy_message:
should_update = True
elif new_message and (
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
):
should_update = True
if should_update and new_message:
existing.message_text = new_message
existing.updated_at = datetime.utcnow()
await db.flush()
target_active_hours = template_data.get("active_discount_hours")
if (
target_active_hours is not None
and target_active_hours > 0
and not existing.active_discount_hours
):
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.utcnow()
await db.flush()
templates.append(existing)
continue
payload = _format_template_fields(template_data)
template = PromoOfferTemplate(
name=payload["name"],
offer_type=payload["offer_type"],
message_text=payload["message_text"],
button_text=payload["button_text"],
valid_hours=payload["valid_hours"],
discount_percent=payload["discount_percent"],
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
active_discount_hours=payload["active_discount_hours"],
test_duration_hours=payload["test_duration_hours"],
test_squad_uuids=payload["test_squad_uuids"],
is_active=True,
created_by=created_by,
)
db.add(template)
await db.flush()
templates.append(template)
await db.commit()
return templates
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
)
return result.scalars().all()
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
)
return result.scalar_one_or_none()
async def update_promo_offer_template(
db: AsyncSession,
template: PromoOfferTemplate,
*,
name: Optional[str] = None,
message_text: Optional[str] = None,
button_text: Optional[str] = None,
valid_hours: Optional[int] = None,
discount_percent: Optional[int] = None,
bonus_amount_kopeks: Optional[int] = None,
active_discount_hours: Optional[int] = None,
test_duration_hours: Optional[int] = None,
test_squad_uuids: Optional[Iterable[str]] = None,
is_active: Optional[bool] = None,
) -> PromoOfferTemplate:
if name is not None:
template.name = name
if message_text is not None:
template.message_text = message_text
if button_text is not None:
template.button_text = button_text
if valid_hours is not None:
template.valid_hours = valid_hours
if discount_percent is not None:
template.discount_percent = discount_percent
if bonus_amount_kopeks is not None:
template.bonus_amount_kopeks = bonus_amount_kopeks
if active_discount_hours is not None:
template.active_discount_hours = active_discount_hours
if test_duration_hours is not None:
template.test_duration_hours = test_duration_hours
if test_squad_uuids is not None:
template.test_squad_uuids = list(test_squad_uuids)
if is_active is not None:
template.is_active = is_active
template.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(template)
return template
+250
View File
@@ -0,0 +1,250 @@
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
logger = logging.getLogger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.uses))
.where(PromoCode.code == code.upper())
)
return result.scalar_one_or_none()
async def create_promocode(
db: AsyncSession,
code: str,
type: PromoCodeType,
balance_bonus_kopeks: int = 0,
subscription_days: int = 0,
max_uses: int = 1,
valid_until: Optional[datetime] = None,
created_by: Optional[int] = None
) -> PromoCode:
promocode = PromoCode(
code=code.upper(),
type=type.value,
balance_bonus_kopeks=balance_bonus_kopeks,
subscription_days=subscription_days,
max_uses=max_uses,
valid_until=valid_until,
created_by=created_by
)
db.add(promocode)
await db.commit()
await db.refresh(promocode)
logger.info(f"✅ Создан промокод: {code}")
return promocode
async def use_promocode(
db: AsyncSession,
promocode_id: int,
user_id: int
) -> bool:
try:
promocode = await db.get(PromoCode, promocode_id)
if not promocode:
return False
usage = PromoCodeUse(
promocode_id=promocode_id,
user_id=user_id
)
db.add(usage)
promocode.current_uses += 1
await db.commit()
logger.info(f"✅ Промокод {promocode.code} использован пользователем {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка использования промокода: {e}")
await db.rollback()
return False
async def check_user_promocode_usage(
db: AsyncSession,
user_id: int,
promocode_id: int
) -> bool:
result = await db.execute(
select(PromoCodeUse).where(
and_(
PromoCodeUse.user_id == user_id,
PromoCodeUse.promocode_id == promocode_id
)
)
)
return result.scalar_one_or_none() is not None
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
promocode_use = PromoCodeUse(
promocode_id=promocode_id,
user_id=user_id,
used_at=datetime.utcnow()
)
db.add(promocode_use)
await db.commit()
await db.refresh(promocode_use)
logger.info(f"📝 Записано использование промокода {promocode_id} пользователем {user_id}")
return promocode_use
async def get_promocode_use_by_user_and_code(
db: AsyncSession,
user_id: int,
promocode_id: int
) -> Optional[PromoCodeUse]:
result = await db.execute(
select(PromoCodeUse).where(
and_(
PromoCodeUse.user_id == user_id,
PromoCodeUse.promocode_id == promocode_id
)
)
)
return result.scalar_one_or_none()
async def get_user_promocodes(db: AsyncSession, user_id: int) -> List[PromoCodeUse]:
result = await db.execute(
select(PromoCodeUse)
.where(PromoCodeUse.user_id == user_id)
.order_by(PromoCodeUse.used_at.desc())
)
return result.scalars().all()
async def get_promocodes_list(
db: AsyncSession,
offset: int = 0,
limit: int = 50,
is_active: Optional[bool] = None
) -> List[PromoCode]:
query = select(PromoCode).options(selectinload(PromoCode.uses))
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_promocodes_count(
db: AsyncSession,
is_active: Optional[bool] = None
) -> int:
query = select(func.count(PromoCode.id))
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
result = await db.execute(query)
return result.scalar()
async def update_promocode(
db: AsyncSession,
promocode: PromoCode,
**kwargs
) -> PromoCode:
for field, value in kwargs.items():
if hasattr(promocode, field):
setattr(promocode, field, value)
promocode.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(promocode)
return promocode
async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
try:
from sqlalchemy import delete as sql_delete
await db.execute(
sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id)
)
await db.delete(promocode)
await db.commit()
logger.info(f"🗑️ Удален промокод: {promocode.code}")
return True
except Exception as e:
logger.error(f"Ошибка удаления промокода: {e}")
await db.rollback()
return False
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
total_uses_result = await db.execute(
select(func.count(PromoCodeUse.id))
.where(PromoCodeUse.promocode_id == promocode_id)
)
total_uses = total_uses_result.scalar()
today = datetime.utcnow().date()
today_uses_result = await db.execute(
select(func.count(PromoCodeUse.id))
.where(
and_(
PromoCodeUse.promocode_id == promocode_id,
PromoCodeUse.used_at >= today
)
)
)
today_uses = today_uses_result.scalar()
recent_uses_result = await db.execute(
select(PromoCodeUse, User)
.join(User, PromoCodeUse.user_id == User.id)
.where(PromoCodeUse.promocode_id == promocode_id)
.order_by(PromoCodeUse.used_at.desc())
.limit(10)
)
recent_uses_data = recent_uses_result.all()
recent_uses = []
for use, user in recent_uses_data:
use.user_username = user.username
use.user_full_name = user.full_name
use.user_telegram_id = user.telegram_id
recent_uses.append(use)
return {
"total_uses": total_uses,
"today_uses": today_uses,
"recent_uses": recent_uses
}
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
result = await db.execute(
select(PublicOffer).where(PublicOffer.language == language)
)
return result.scalar_one_or_none()
async def upsert_public_offer(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.content = content or ""
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
language,
offer.id,
)
return offer
async def set_public_offer_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.is_enabled = bool(enabled)
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Статус публичной оферты для языка %s обновлен: %s",
language,
"enabled" if offer.is_enabled else "disabled",
)
return offer
+305
View File
@@ -0,0 +1,305 @@
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ReferralEarning, User
logger = logging.getLogger(__name__)
async def create_referral_earning(
db: AsyncSession,
user_id: int,
referral_id: int,
amount_kopeks: int,
reason: str,
referral_transaction_id: Optional[int] = None
) -> ReferralEarning:
earning = ReferralEarning(
user_id=user_id,
referral_id=referral_id,
amount_kopeks=amount_kopeks,
reason=reason,
referral_transaction_id=referral_transaction_id
)
db.add(earning)
await db.commit()
await db.refresh(earning)
logger.info(f"💰 Создан реферальный заработок: {amount_kopeks/100}₽ для пользователя {user_id}")
return earning
async def get_referral_earnings_by_user(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[ReferralEarning]:
result = await db.execute(
select(ReferralEarning)
.options(
selectinload(ReferralEarning.referral),
selectinload(ReferralEarning.referral_transaction)
)
.where(ReferralEarning.user_id == user_id)
.order_by(ReferralEarning.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_referral_earnings_by_referral(
db: AsyncSession,
referral_id: int
) -> List[ReferralEarning]:
result = await db.execute(
select(ReferralEarning)
.where(ReferralEarning.referral_id == referral_id)
.order_by(ReferralEarning.created_at.desc())
)
return result.scalars().all()
async def get_referral_earnings_sum(
db: AsyncSession,
user_id: int,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> int:
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user_id
)
if start_date:
query = query.where(ReferralEarning.created_at >= start_date)
if end_date:
query = query.where(ReferralEarning.created_at <= end_date)
result = await db.execute(query)
return result.scalar()
async def get_referral_statistics(db: AsyncSession) -> dict:
users_with_referrals_result = await db.execute(
select(func.count(func.distinct(User.id)))
.where(User.referred_by_id.isnot(None))
)
users_with_referrals = users_with_referrals_result.scalar()
active_referrers_result = await db.execute(
select(func.count(func.distinct(User.referred_by_id)))
.where(User.referred_by_id.isnot(None))
)
active_referrers = active_referrers_result.scalar()
referral_paid_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
)
referral_paid = referral_paid_result.scalar()
from app.database.models import Transaction, TransactionType
transaction_paid_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
)
transaction_paid = transaction_paid_result.scalar()
total_paid = referral_paid + transaction_paid
referrals_stats_result = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('referrals_count')
)
.where(User.referred_by_id.isnot(None))
.group_by(User.referred_by_id)
)
referrals_stats = {row.referrer_id: row.referrals_count for row in referrals_stats_result.all()}
referral_earnings_result = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('referral_earnings')
)
.group_by(ReferralEarning.user_id)
)
referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()}
transaction_earnings_result = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('transaction_earnings')
)
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()}
top_referrers_data = {}
for referrer_id, count in referrals_stats.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['referrals_count'] = count
for referrer_id, earnings in referral_earnings.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
for referrer_id, earnings in transaction_earnings.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
sorted_referrers = sorted(
top_referrers_data.items(),
key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']),
reverse=True
)
top_referrers = []
for referrer_id, stats in sorted_referrers[:5]:
user_result = await db.execute(
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
.where(User.id == referrer_id)
)
user = user_result.first()
if user:
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
top_referrers.append({
"user_id": user.telegram_id,
"display_name": display_name,
"username": user.username,
"telegram_id": user.telegram_id,
"total_earned_kopeks": stats['total_earned'],
"referrals_count": stats['referrals_count']
})
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= today)
)
today_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= today
)
)
)
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= week_ago)
)
week_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= week_ago
)
)
)
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= month_ago)
)
month_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= month_ago
)
)
)
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
logger.info(f"Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек")
return {
"users_with_referrals": users_with_referrals,
"active_referrers": active_referrers,
"total_paid_kopeks": total_paid,
"today_earnings_kopeks": today_earnings,
"week_earnings_kopeks": week_earnings,
"month_earnings_kopeks": month_earnings,
"top_referrers": top_referrers
}
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
invited_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
invited_count = invited_count_result.scalar()
total_earned = await get_referral_earnings_sum(db, user_id)
month_ago = datetime.utcnow() - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
from app.database.models import Subscription, SubscriptionStatus
current_time = datetime.utcnow()
active_referrals_result = await db.execute(
select(func.count(User.id))
.join(Subscription, User.id == Subscription.user_id)
.where(
and_(
User.referred_by_id == user_id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > current_time
)
)
)
active_referrals = active_referrals_result.scalar()
return {
"invited_count": invited_count,
"active_referrals": active_referrals,
"total_earned_kopeks": total_earned,
"month_earned_kopeks": month_earned
}
+218
View File
@@ -0,0 +1,218 @@
import logging
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from app.database.models import ServiceRule
logger = logging.getLogger(__name__)
async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optional[ServiceRule]:
result = await db.execute(
select(ServiceRule)
.where(
ServiceRule.language == language,
ServiceRule.is_active == True
)
.order_by(ServiceRule.order, ServiceRule.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def create_or_update_rules(
db: AsyncSession,
content: str,
language: str = "ru",
title: str = "Правила сервиса"
) -> ServiceRule:
existing_rules_result = await db.execute(
select(ServiceRule).where(
ServiceRule.language == language,
ServiceRule.is_active == True
)
)
existing_rules = existing_rules_result.scalars().all()
for rule in existing_rules:
rule.is_active = False
rule.updated_at = datetime.utcnow()
new_rules = ServiceRule(
title=title,
content=content,
language=language,
is_active=True,
order=0
)
db.add(new_rules)
await db.commit()
await db.refresh(new_rules)
logger.info(f"✅ Правила для языка {language} обновлены (ID: {new_rules.id})")
return new_rules
async def clear_all_rules(db: AsyncSession, language: str = "ru") -> bool:
try:
result = await db.execute(
update(ServiceRule)
.where(
ServiceRule.language == language,
ServiceRule.is_active == True
)
.values(
is_active=False,
updated_at=datetime.utcnow()
)
)
await db.commit()
rows_affected = result.rowcount
logger.info(f"✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}")
return rows_affected > 0
except Exception as e:
logger.error(f"❌ Ошибка при очистке правил для языка {language}: {e}")
await db.rollback()
raise
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
rules = await get_rules_by_language(db, language)
if rules:
return rules.content
else:
return """
🔒 <b>Правила использования сервиса</b>
1. Сервис предоставляется "как есть" без каких-либо гарантий.
2. Запрещается использование сервиса для незаконных действий.
3. Администрация оставляет за собой право заблокировать доступ пользователя при нарушении правил.
4. Возврат средств осуществляется в соответствии с политикой возврата.
5. Пользователь несет полную ответственность за безопасность своего аккаунта.
6. При возникновении вопросов обращайтесь в техническую поддержку.
Используя сервис, вы соглашаетесь с данными правилами.
"""
async def get_all_rules_versions(
db: AsyncSession,
language: str = "ru",
limit: int = 10
) -> list[ServiceRule]:
result = await db.execute(
select(ServiceRule)
.where(ServiceRule.language == language)
.order_by(ServiceRule.created_at.desc())
.limit(limit)
)
return result.scalars().all()
async def restore_rules_version(
db: AsyncSession,
rule_id: int,
language: str = "ru"
) -> Optional[ServiceRule]:
try:
result = await db.execute(
select(ServiceRule).where(
ServiceRule.id == rule_id,
ServiceRule.language == language
)
)
rule_to_restore = result.scalar_one_or_none()
if not rule_to_restore:
logger.warning(f"Правило с ID {rule_id} не найдено для языка {language}")
return None
await db.execute(
update(ServiceRule)
.where(
ServiceRule.language == language,
ServiceRule.is_active == True
)
.values(
is_active=False,
updated_at=datetime.utcnow()
)
)
restored_rule = ServiceRule(
title=rule_to_restore.title,
content=rule_to_restore.content,
language=language,
is_active=True,
order=0
)
db.add(restored_rule)
await db.commit()
await db.refresh(restored_rule)
logger.info(f"✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}")
return restored_rule
except Exception as e:
logger.error(f"❌ Ошибка при восстановлении правил ID {rule_id}: {e}")
await db.rollback()
raise
async def get_rules_statistics(db: AsyncSession) -> dict:
try:
active_result = await db.execute(
select(ServiceRule).where(ServiceRule.is_active == True)
)
active_rules = active_result.scalars().all()
all_result = await db.execute(select(ServiceRule))
all_rules = all_result.scalars().all()
languages_stats = {}
for rule in active_rules:
lang = rule.language
if lang not in languages_stats:
languages_stats[lang] = {
'active_count': 0,
'last_updated': None,
'content_length': 0
}
languages_stats[lang]['active_count'] += 1
languages_stats[lang]['content_length'] = len(rule.content)
if not languages_stats[lang]['last_updated'] or rule.updated_at > languages_stats[lang]['last_updated']:
languages_stats[lang]['last_updated'] = rule.updated_at
return {
'total_active': len(active_rules),
'total_all_time': len(all_rules),
'languages': languages_stats,
'total_languages': len(languages_stats)
}
except Exception as e:
logger.error(f"❌ Ошибка при получении статистики правил: {e}")
return {
'total_active': 0,
'total_all_time': 0,
'languages': {},
'total_languages': 0,
'error': str(e)
}
+570
View File
@@ -0,0 +1,570 @@
import logging
from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
from sqlalchemy import (
select,
and_,
func,
update,
delete,
text,
or_,
cast,
String,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
logger = logging.getLogger(__name__)
async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]:
result = await db.execute(
select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1)
)
return result.scalar_one_or_none()
async def create_server_squad(
db: AsyncSession,
squad_uuid: str,
display_name: str,
original_name: str = None,
country_code: str = None,
price_kopeks: int = 0,
description: str = None,
max_users: int = None,
is_available: bool = True,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
normalized_group_ids: Sequence[int]
if promo_group_ids is None:
default_id = await _get_default_promo_group_id(db)
normalized_group_ids = [default_id] if default_id is not None else []
else:
normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
if not normalized_group_ids:
raise ValueError("Server squad must be linked to at least one promo group")
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
if len(promo_groups) != len(normalized_group_ids):
logger.warning(
"Не все промогруппы найдены при создании сервера %s", display_name
)
server_squad = ServerSquad(
squad_uuid=squad_uuid,
display_name=display_name,
original_name=original_name,
country_code=country_code,
price_kopeks=price_kopeks,
description=description,
max_users=max_users,
is_available=is_available,
allowed_promo_groups=promo_groups,
)
db.add(server_squad)
await db.commit()
await db.refresh(server_squad)
logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})")
return server_squad
async def get_server_squad_by_uuid(
db: AsyncSession,
squad_uuid: str
) -> Optional[ServerSquad]:
result = await db.execute(
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.squad_uuid == squad_uuid)
)
return result.scalars().unique().one_or_none()
async def get_server_squad_by_id(
db: AsyncSession,
server_id: int
) -> Optional[ServerSquad]:
result = await db.execute(
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.id == server_id)
)
return result.scalars().unique().one_or_none()
async def get_all_server_squads(
db: AsyncSession,
available_only: bool = False,
page: int = 1,
limit: int = 50
) -> Tuple[List[ServerSquad], int]:
query = select(ServerSquad)
if available_only:
query = query.where(ServerSquad.is_available == True)
count_query = select(func.count(ServerSquad.id))
if available_only:
count_query = count_query.where(ServerSquad.is_available == True)
count_result = await db.execute(count_query)
total_count = count_result.scalar()
offset = (page - 1) * limit
query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
servers = result.scalars().all()
return servers, total_count
async def get_available_server_squads(
db: AsyncSession,
promo_group_id: Optional[int] = None,
) -> List[ServerSquad]:
query = (
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.is_available.is_(True))
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
)
if promo_group_id is not None:
query = query.join(ServerSquad.allowed_promo_groups).where(
PromoGroup.id == promo_group_id
)
result = await db.execute(query)
return result.scalars().unique().all()
async def update_server_squad_promo_groups(
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
) -> Optional[ServerSquad]:
unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
if not unique_ids:
raise ValueError("Нужно выбрать хотя бы одну промогруппу")
server = await get_server_squad_by_id(db, server_id)
if not server:
return None
result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(unique_ids))
)
promo_groups = result.scalars().all()
if not promo_groups:
raise ValueError("Не найдены промогруппы для обновления сервера")
server.allowed_promo_groups = promo_groups
await db.commit()
await db.refresh(server)
logger.info(
"Обновлены промогруппы сервера %s (ID: %s): %s",
server.display_name,
server.id,
", ".join(pg.name for pg in promo_groups),
)
return server
async def update_server_squad(
db: AsyncSession,
server_id: int,
**updates
) -> Optional[ServerSquad]:
valid_fields = {
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order'
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
if not filtered_updates:
return None
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(**filtered_updates)
)
await db.commit()
return await get_server_squad_by_id(db, server_id)
async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
connections_result = await db.execute(
select(func.count(SubscriptionServer.id))
.where(SubscriptionServer.server_squad_id == server_id)
)
connections_count = connections_result.scalar()
if connections_count > 0:
logger.warning(f"⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
return False
await db.execute(
delete(ServerSquad).where(ServerSquad.id == server_id)
)
await db.commit()
logger.info(f"🗑️ Удален сервер (ID: {server_id})")
return True
async def sync_with_remnawave(
db: AsyncSession,
remnawave_squads: List[dict]
) -> Tuple[int, int, int]:
created = 0
updated = 0
removed = 0
existing_servers = {}
result = await db.execute(select(ServerSquad))
for server in result.scalars().all():
existing_servers[server.squad_uuid] = server
remnawave_uuids = {squad['uuid'] for squad in remnawave_squads}
for squad in remnawave_squads:
squad_uuid = squad['uuid']
original_name = squad.get('name', f'Squad {squad_uuid[:8]}')
if squad_uuid in existing_servers:
server = existing_servers[squad_uuid]
if server.original_name != original_name:
server.original_name = original_name
updated += 1
else:
await create_server_squad(
db=db,
squad_uuid=squad_uuid,
display_name=_generate_display_name(original_name),
original_name=original_name,
country_code=_extract_country_code(original_name),
price_kopeks=1000,
is_available=False
)
created += 1
removed_servers = [
server for uuid, server in existing_servers.items()
if uuid not in remnawave_uuids
]
if removed_servers:
removed_ids = [server.id for server in removed_servers]
removed_uuids = {server.squad_uuid for server in removed_servers}
subscription_ids_result = await db.execute(
select(SubscriptionServer.subscription_id)
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
for server in removed_servers:
logger.info(
"🗑️ Удаляется сервер %s (UUID: %s)",
server.display_name,
server.squad_uuid,
)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscriptions_to_update: dict[int, Subscription] = {}
if subscription_ids:
subscriptions_result = await db.execute(
select(Subscription).where(Subscription.id.in_(subscription_ids))
)
for subscription in subscriptions_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
for squad_uuid in removed_uuids:
if not squad_uuid:
continue
extra_result = await db.execute(
select(Subscription).where(
text("connected_squads::text LIKE :uuid_pattern")
),
{"uuid_pattern": f'%"{squad_uuid}"%'}
)
for subscription in extra_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
cleaned_subscriptions = 0
for subscription in subscriptions_to_update.values():
current_squads = list(subscription.connected_squads or [])
if not current_squads:
continue
filtered_squads = [
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
]
if len(filtered_squads) != len(current_squads):
subscription.connected_squads = filtered_squads
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
removed = len(removed_servers)
if cleaned_subscriptions:
logger.info(
"🧹 Обновлены подписки после удаления серверов: %s",
cleaned_subscriptions,
)
await db.commit()
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
return created, updated, removed
async def get_server_connected_users(
db: AsyncSession,
server_id: int
) -> List[User]:
server_uuid_result = await db.execute(
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
)
server_uuid = server_uuid_result.scalar_one_or_none()
connection_filters = [SubscriptionServer.id.isnot(None)]
if server_uuid:
connection_filters.append(
cast(Subscription.connected_squads, String).like(
f'%"{server_uuid}"%'
)
)
result = await db.execute(
select(User)
.join(Subscription, Subscription.user_id == User.id)
.outerjoin(
SubscriptionServer,
and_(
SubscriptionServer.subscription_id == Subscription.id,
SubscriptionServer.server_squad_id == server_id,
),
)
.where(or_(*connection_filters))
.options(selectinload(User.subscription))
.order_by(User.id)
)
return result.scalars().unique().all()
def _generate_display_name(original_name: str) -> str:
country_names = {
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
'US': '🇺🇸 США',
'FR': '🇫🇷 Франция',
'GB': '🇬🇧 Великобритания',
'IT': '🇮🇹 Италия',
'ES': '🇪🇸 Испания',
'CA': '🇨🇦 Канада',
'JP': '🇯🇵 Япония',
'SG': '🇸🇬 Сингапур',
'AU': '🇦🇺 Австралия',
}
name_upper = original_name.upper()
for code, display_name in country_names.items():
if code in name_upper:
return display_name
return f"🌍 {original_name}"
def _extract_country_code(original_name: str) -> Optional[str]:
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
name_upper = original_name.upper()
for code in codes:
if code in name_upper:
return code
return None
async def get_server_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
available_result = await db.execute(
select(func.count(ServerSquad.id))
.where(ServerSquad.is_available == True)
)
available_servers = available_result.scalar()
servers_with_connections = 0
all_servers_result = await db.execute(select(ServerSquad.squad_uuid))
all_server_uuids = [row[0] for row in all_servers_result.fetchall()]
for squad_uuid in all_server_uuids:
count_result = await db.execute(
text("""
SELECT COUNT(s.id)
FROM subscriptions s
WHERE s.status IN ('active', 'trial')
AND s.connected_squads::text LIKE :uuid_pattern
"""),
{"uuid_pattern": f'%"{squad_uuid}"%'}
)
user_count = count_result.scalar() or 0
if user_count > 0:
servers_with_connections += 1
revenue_result = await db.execute(
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
)
total_revenue_kopeks = revenue_result.scalar()
return {
'total_servers': total_servers,
'available_servers': available_servers,
'unavailable_servers': total_servers - available_servers,
'servers_with_connections': servers_with_connections,
'total_revenue_kopeks': total_revenue_kopeks,
'total_revenue_rubles': total_revenue_kopeks / 100
}
async def add_user_to_servers(
db: AsyncSession,
server_squad_ids: List[int]
) -> bool:
try:
for server_id in server_squad_ids:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=ServerSquad.current_users + 1)
)
await db.commit()
logger.info(f"✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}")
return True
except Exception as e:
logger.error(f"Ошибка увеличения счетчика пользователей: {e}")
await db.rollback()
return False
async def remove_user_from_servers(
db: AsyncSession,
server_squad_ids: List[int]
) -> bool:
try:
for server_id in server_squad_ids:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
)
await db.commit()
logger.info(f"✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}")
return True
except Exception as e:
logger.error(f"Ошибка уменьшения счетчика пользователей: {e}")
await db.rollback()
return False
async def get_server_ids_by_uuids(
db: AsyncSession,
squad_uuids: List[str]
) -> List[int]:
result = await db.execute(
select(ServerSquad.id)
.where(ServerSquad.squad_uuid.in_(squad_uuids))
)
return [row[0] for row in result.fetchall()]
async def sync_server_user_counts(db: AsyncSession) -> int:
try:
all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid))
all_servers = all_servers_result.fetchall()
logger.info(f"🔍 Найдено серверов для синхронизации: {len(all_servers)}")
updated_count = 0
for server_id, squad_uuid in all_servers:
count_result = await db.execute(
text("""
SELECT COUNT(s.id)
FROM subscriptions s
WHERE s.status IN ('active', 'trial')
AND s.connected_squads::text LIKE :uuid_pattern
"""),
{"uuid_pattern": f'%"{squad_uuid}"%'}
)
actual_users = count_result.scalar() or 0
logger.info(f"📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей")
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=actual_users)
)
updated_count += 1
await db.commit()
logger.info(f"✅ Синхронизированы счетчики для {updated_count} серверов")
return updated_count
except Exception as e:
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
await db.rollback()
return 0
+61
View File
@@ -0,0 +1,61 @@
import logging
from typing import Optional, List
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Squad
logger = logging.getLogger(__name__)
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Optional[Squad]:
result = await db.execute(
select(Squad).where(Squad.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_available_squads(db: AsyncSession) -> List[Squad]:
result = await db.execute(
select(Squad).where(Squad.is_available == True)
)
return result.scalars().all()
async def create_squad(
db: AsyncSession,
uuid: str,
name: str,
country_code: str = None,
price_kopeks: int = 0,
description: str = None
) -> Squad:
squad = Squad(
uuid=uuid,
name=name,
country_code=country_code,
price_kopeks=price_kopeks,
description=description
)
db.add(squad)
await db.commit()
await db.refresh(squad)
logger.info(f"✅ Создан сквад: {name}")
return squad
async def update_squad(
db: AsyncSession,
squad: Squad,
**kwargs
) -> Squad:
for field, value in kwargs.items():
if hasattr(squad, field):
setattr(squad, field, value)
await db.commit()
await db.refresh(squad)
return squad
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SubscriptionConversion, User
logger = logging.getLogger(__name__)
async def create_subscription_conversion(
db: AsyncSession,
user_id: int,
trial_duration_days: int,
payment_method: str,
first_payment_amount_kopeks: int,
first_paid_period_days: int
) -> SubscriptionConversion:
conversion = SubscriptionConversion(
user_id=user_id,
converted_at=datetime.utcnow(),
trial_duration_days=trial_duration_days,
payment_method=payment_method,
first_payment_amount_kopeks=first_payment_amount_kopeks,
first_paid_period_days=first_paid_period_days
)
db.add(conversion)
await db.commit()
await db.refresh(conversion)
logger.info(f"✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks/100}")
return conversion
async def get_conversion_by_user_id(
db: AsyncSession,
user_id: int
) -> Optional[SubscriptionConversion]:
result = await db.execute(
select(SubscriptionConversion)
.where(SubscriptionConversion.user_id == user_id)
.order_by(SubscriptionConversion.converted_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_conversion_statistics(db: AsyncSession) -> dict:
total_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id))
)
total_conversions = total_conversions_result.scalar()
users_with_paid_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
users_with_paid = users_with_paid_result.scalar()
if total_conversions > 0:
conversion_rate = round((total_conversions / max(total_conversions, users_with_paid)) * 100, 1)
elif users_with_paid > 0:
conversion_rate = 100.0
else:
conversion_rate = 0.0
avg_trial_duration_result = await db.execute(
select(func.avg(SubscriptionConversion.trial_duration_days))
)
avg_trial_duration = avg_trial_duration_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.avg(SubscriptionConversion.first_payment_amount_kopeks))
)
avg_first_payment = avg_first_payment_result.scalar() or 0
month_ago = datetime.utcnow() - timedelta(days=30)
month_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id))
.where(SubscriptionConversion.converted_at >= month_ago)
)
month_conversions = month_conversions_result.scalar()
logger.info(f"📊 Статистика конверсий:")
logger.info(f" Всего записей о конверсиях: {total_conversions}")
logger.info(f" Пользователей с платными подписками: {users_with_paid}")
logger.info(f" Рассчитанная конверсия: {conversion_rate}%")
return {
"total_conversions": total_conversions,
"conversion_rate": conversion_rate,
"avg_trial_duration_days": round(avg_trial_duration, 1),
"avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2),
"month_conversions": month_conversions
}
async def get_users_had_trial_count(db: AsyncSession) -> int:
conversions_count_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id)))
)
conversions_count = conversions_count_result.scalar()
paid_users_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
paid_users_count = paid_users_result.scalar()
return max(conversions_count, paid_users_count)
+40
View File
@@ -0,0 +1,40 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SystemSetting
async def upsert_system_setting(
db: AsyncSession,
key: str,
value: Optional[str],
description: Optional[str] = None,
) -> SystemSetting:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting is None:
setting = SystemSetting(key=key, value=value, description=description)
db.add(setting)
else:
setting.value = value
if description is not None:
setting.description = description
await db.flush()
return setting
async def delete_system_setting(db: AsyncSession, key: str) -> None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting is not None:
await db.delete(setting)
await db.flush()
+424
View File
@@ -0,0 +1,424 @@
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_, or_, update, func
from sqlalchemy.orm import selectinload
from datetime import datetime
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
class TicketCRUD:
"""CRUD операции для работы с тикетами"""
@staticmethod
async def create_ticket(
db: AsyncSession,
user_id: int,
title: str,
message_text: str,
priority: str = "normal",
*,
media_type: Optional[str] = None,
media_file_id: Optional[str] = None,
media_caption: Optional[str] = None,
) -> Ticket:
"""Создать новый тикет с первым сообщением"""
ticket = Ticket(
user_id=user_id,
title=title,
status=TicketStatus.OPEN.value,
priority=priority
)
db.add(ticket)
await db.flush() # Получаем ID тикета
# Создаем первое сообщение
message = TicketMessage(
ticket_id=ticket.id,
user_id=user_id,
message_text=message_text,
is_from_admin=False,
has_media=bool(media_type and media_file_id),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
)
db.add(message)
await db.commit()
await db.refresh(ticket)
return ticket
@staticmethod
async def get_ticket_by_id(
db: AsyncSession,
ticket_id: int,
load_messages: bool = True,
load_user: bool = False
) -> Optional[Ticket]:
"""Получить тикет по ID"""
query = select(Ticket).where(Ticket.id == ticket_id)
if load_user:
query = query.options(selectinload(Ticket.user))
if load_messages:
query = query.options(selectinload(Ticket.messages))
result = await db.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def get_user_tickets(
db: AsyncSession,
user_id: int,
status: Optional[str] = None,
limit: int = 20,
offset: int = 0
) -> List[Ticket]:
"""Получить тикеты пользователя"""
query = select(Ticket).where(Ticket.user_id == user_id)
if status:
query = query.where(Ticket.status == status)
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def count_user_tickets_by_statuses(
db: AsyncSession,
user_id: int,
statuses: List[str]
) -> int:
"""Подсчитать количество тикетов пользователя по списку статусов"""
query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def get_user_tickets_by_statuses(
db: AsyncSession,
user_id: int,
statuses: List[str],
limit: int = 20,
offset: int = 0
) -> List[Ticket]:
"""Получить тикеты пользователя по списку статусов с пагинацией"""
query = (
select(Ticket)
.where(Ticket.user_id == user_id)
.order_by(desc(Ticket.updated_at))
.offset(offset)
.limit(limit)
)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def user_has_active_ticket(
db: AsyncSession,
user_id: int
) -> bool:
"""Проверить, есть ли у пользователя активный (не закрытый) тикет"""
query = (
select(Ticket.id)
.where(
Ticket.user_id == user_id,
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
)
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none() is not None
@staticmethod
async def is_user_globally_blocked(
db: AsyncSession,
user_id: int
) -> Optional[datetime]:
"""Проверить, заблокирован ли пользователь для создания/ответов по любому тикету.
Возвращает дату окончания блокировки, если активна, или None.
"""
query = select(Ticket).where(
Ticket.user_id == user_id,
or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None))
).order_by(desc(Ticket.updated_at)).limit(10)
result = await db.execute(query)
tickets = result.scalars().all()
if not tickets:
return None
from datetime import datetime
# Если есть вечная блокировка в любом тикете — блок активен без срока
for t in tickets:
if t.user_reply_block_permanent:
return datetime.max
# Иначе ищем максимальный срок блокировки, если он в будущем
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
if not future_until:
return None
max_until = max(future_until)
return max_until if max_until > datetime.utcnow() else None
@staticmethod
async def get_all_tickets(
db: AsyncSession,
status: Optional[str] = None,
priority: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Ticket]:
"""Получить все тикеты (для админов)"""
query = select(Ticket).options(selectinload(Ticket.user))
conditions = []
if status:
conditions.append(Ticket.status == status)
if priority:
conditions.append(Ticket.priority == priority)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def get_tickets_by_statuses(
db: AsyncSession,
statuses: List[str],
limit: int = 50,
offset: int = 0
) -> List[Ticket]:
query = select(Ticket).options(selectinload(Ticket.user))
if statuses:
query = query.where(Ticket.status.in_(statuses))
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def count_tickets(
db: AsyncSession,
status: Optional[str] = None
) -> int:
query = select(func.count()).select_from(Ticket)
if status:
query = query.where(Ticket.status == status)
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def count_tickets_by_statuses(
db: AsyncSession,
statuses: List[str]
) -> int:
query = select(func.count()).select_from(Ticket)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def update_ticket_status(
db: AsyncSession,
ticket_id: int,
status: str,
closed_at: Optional[datetime] = None
) -> bool:
"""Обновить статус тикета"""
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
if not ticket:
return False
ticket.status = status
ticket.updated_at = datetime.utcnow()
if status == TicketStatus.CLOSED.value and closed_at:
ticket.closed_at = closed_at
await db.commit()
return True
@staticmethod
async def set_user_reply_block(
db: AsyncSession,
ticket_id: int,
permanent: bool,
until: Optional[datetime]
) -> bool:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
if not ticket:
return False
ticket.user_reply_block_permanent = bool(permanent)
ticket.user_reply_block_until = until
ticket.updated_at = datetime.utcnow()
await db.commit()
return True
@staticmethod
async def close_ticket(
db: AsyncSession,
ticket_id: int
) -> bool:
"""Закрыть тикет"""
return await TicketCRUD.update_ticket_status(
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
)
@staticmethod
async def add_support_audit(
db: AsyncSession,
*,
actor_user_id: Optional[int],
actor_telegram_id: int,
is_moderator: bool,
action: str,
ticket_id: Optional[int] = None,
target_user_id: Optional[int] = None,
details: Optional[dict] = None,
) -> None:
try:
log = SupportAuditLog(
actor_user_id=actor_user_id,
actor_telegram_id=actor_telegram_id,
is_moderator=bool(is_moderator),
action=action,
ticket_id=ticket_id,
target_user_id=target_user_id,
details=details or {},
)
db.add(log)
await db.commit()
except Exception:
await db.rollback()
# не мешаем основной логике
pass
@staticmethod
async def list_support_audit(
db: AsyncSession,
*,
limit: int = 50,
offset: int = 0,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
return int(result.scalar() or 0)
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:
"""Получить количество открытых тикетов"""
query = select(Ticket).where(Ticket.status.in_([
TicketStatus.OPEN.value,
TicketStatus.ANSWERED.value
]))
result = await db.execute(query)
return len(result.scalars().all())
class TicketMessageCRUD:
"""CRUD операции для работы с сообщениями тикетов"""
@staticmethod
async def add_message(
db: AsyncSession,
ticket_id: int,
user_id: int,
message_text: str,
is_from_admin: bool = False,
media_type: Optional[str] = None,
media_file_id: Optional[str] = None,
media_caption: Optional[str] = None
) -> TicketMessage:
"""Добавить сообщение в тикет"""
message = TicketMessage(
ticket_id=ticket_id,
user_id=user_id,
message_text=message_text,
is_from_admin=is_from_admin,
has_media=bool(media_type and media_file_id),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption
)
db.add(message)
# Обновляем статус тикета
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
if ticket:
# Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя
if not is_from_admin and ticket.status == TicketStatus.CLOSED.value:
return message
if is_from_admin:
# Админ ответил - тикет отвечен
ticket.status = TicketStatus.ANSWERED.value
else:
# Пользователь ответил - тикет открыт
ticket.status = TicketStatus.OPEN.value
# Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения
try:
from sqlalchemy import inspect as sa_inspect
# если колонка существует в модели
if hasattr(ticket, 'last_sla_reminder_at'):
ticket.last_sla_reminder_at = None
except Exception:
pass
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
return message
@staticmethod
async def get_ticket_messages(
db: AsyncSession,
ticket_id: int,
limit: int = 50,
offset: int = 0
) -> List[TicketMessage]:
"""Получить сообщения тикета"""
query = (
select(TicketMessage)
.where(TicketMessage.ticket_id == ticket_id)
.order_by(TicketMessage.created_at)
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def get_last_message(
db: AsyncSession,
ticket_id: int
) -> Optional[TicketMessage]:
"""Получить последнее сообщение в тикете"""
query = (
select(TicketMessage)
.where(TicketMessage.ticket_id == ticket_id)
.order_by(desc(TicketMessage.created_at))
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none()
+407
View File
@@ -0,0 +1,407 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, and_, or_, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import Transaction, TransactionType, PaymentMethod, User
logger = logging.getLogger(__name__)
async def create_transaction(
db: AsyncSession,
user_id: int,
type: TransactionType,
amount_kopeks: int,
description: str,
payment_method: Optional[PaymentMethod] = None,
external_id: Optional[str] = None,
is_completed: bool = True
) -> Transaction:
transaction = Transaction(
user_id=user_id,
type=type.value,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method.value if payment_method else None,
external_id=external_id,
is_completed=is_completed,
completed_at=datetime.utcnow() if is_completed else None
)
db.add(transaction)
await db.commit()
await db.refresh(transaction)
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
try:
from app.services.promo_group_assignment import (
maybe_assign_promo_group_by_total_spent,
)
await maybe_assign_promo_group_by_total_spent(db, user_id)
except Exception as exc:
logger.debug(
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
user_id,
exc,
)
return transaction
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Optional[Transaction]:
result = await db.execute(
select(Transaction)
.options(selectinload(Transaction.user))
.where(Transaction.id == transaction_id)
)
return result.scalar_one_or_none()
async def get_transaction_by_external_id(
db: AsyncSession,
external_id: str,
payment_method: PaymentMethod
) -> Optional[Transaction]:
result = await db.execute(
select(Transaction)
.where(
and_(
Transaction.external_id == external_id,
Transaction.payment_method == payment_method.value
)
)
)
return result.scalar_one_or_none()
async def get_user_transactions(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[Transaction]:
result = await db.execute(
select(Transaction)
.where(Transaction.user_id == user_id)
.order_by(Transaction.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_user_transactions_count(
db: AsyncSession,
user_id: int,
transaction_type: Optional[TransactionType] = None
) -> int:
query = select(func.count(Transaction.id)).where(Transaction.user_id == user_id)
if transaction_type:
query = query.where(Transaction.type == transaction_type.value)
result = await db.execute(query)
return result.scalar()
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
)
return int(result.scalar_one())
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
transaction.is_completed = True
transaction.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(transaction)
logger.info(f"✅ Транзакция {transaction.id} завершена")
try:
from app.services.promo_group_assignment import (
maybe_assign_promo_group_by_total_spent,
)
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
except Exception as exc:
logger.debug(
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
transaction.user_id,
exc,
)
return transaction
async def get_pending_transactions(db: AsyncSession) -> List[Transaction]:
result = await db.execute(
select(Transaction)
.options(selectinload(Transaction.user))
.where(Transaction.is_completed == False)
.order_by(Transaction.created_at)
)
return result.scalars().all()
async def get_transactions_statistics(
db: AsyncSession,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> dict:
if not start_date:
start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if not end_date:
end_date = datetime.utcnow()
income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
)
total_income = income_result.scalar()
expenses_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.WITHDRAWAL.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
)
total_expenses = expenses_result.scalar()
subscription_income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
)
subscription_income = subscription_income_result.scalar()
transactions_count_result = await db.execute(
select(
Transaction.type,
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
)
.where(
and_(
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
.group_by(Transaction.type)
)
transactions_by_type = {row.type: {"count": row.count, "amount": row.total_amount}
for row in transactions_count_result}
payment_methods_result = await db.execute(
select(
Transaction.payment_method,
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
.group_by(Transaction.payment_method)
)
payment_methods = {row.payment_method: {"count": row.count, "amount": row.total_amount}
for row in payment_methods_result}
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(Transaction.id))
.where(
and_(
Transaction.is_completed == True,
Transaction.created_at >= today
)
)
)
transactions_today = today_result.scalar()
today_income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= today
)
)
)
income_today = today_income_result.scalar()
return {
"period": {
"start_date": start_date,
"end_date": end_date
},
"totals": {
"income_kopeks": total_income,
"expenses_kopeks": total_expenses,
"profit_kopeks": total_income - total_expenses,
"subscription_income_kopeks": subscription_income
},
"today": {
"transactions_count": transactions_today,
"income_kopeks": income_today
},
"by_type": transactions_by_type,
"by_payment_method": payment_methods
}
async def get_revenue_by_period(
db: AsyncSession,
days: int = 30
) -> List[dict]:
start_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(
func.date(Transaction.created_at).label('date'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount')
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date
)
)
.group_by(func.date(Transaction.created_at))
.order_by(func.date(Transaction.created_at))
)
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
async def find_tribute_transactions_by_payment_id(
db: AsyncSession,
payment_id: str,
user_telegram_id: Optional[int] = None
) -> List[Transaction]:
query = select(Transaction).options(selectinload(Transaction.user))
conditions = [
Transaction.external_id == f"donation_{payment_id}",
Transaction.external_id == payment_id,
Transaction.external_id.like(f"%{payment_id}%")
]
query = query.where(
and_(
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
or_(*conditions)
)
)
if user_telegram_id:
from app.database.models import User
query = query.join(User).where(User.telegram_id == user_telegram_id)
result = await db.execute(query.order_by(Transaction.created_at.desc()))
return result.scalars().all()
async def check_tribute_payment_duplicate(
db: AsyncSession,
payment_id: str,
amount_kopeks: int,
user_telegram_id: int
) -> Optional[Transaction]:
cutoff_time = datetime.utcnow() - timedelta(hours=24)
exact_external_id = f"donation_{payment_id}"
query = select(Transaction).options(selectinload(Transaction.user)).where(
and_(
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
Transaction.external_id == exact_external_id,
Transaction.amount_kopeks == amount_kopeks,
Transaction.is_completed == True,
Transaction.created_at >= cutoff_time
)
).join(User).where(User.telegram_id == user_telegram_id)
result = await db.execute(query)
transaction = result.scalar_one_or_none()
if transaction:
logger.info(f"🔍 Найден дубликат платежа в течение 24ч: {transaction.id}")
return transaction
async def create_unique_tribute_transaction(
db: AsyncSession,
user_id: int,
payment_id: str,
amount_kopeks: int,
description: str
) -> Transaction:
external_id = f"donation_{payment_id}"
existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE)
if existing:
timestamp = int(datetime.utcnow().timestamp())
external_id = f"donation_{payment_id}_{amount_kopeks}_{timestamp}"
logger.info(f"Создан уникальный external_id для избежания дубликатов: {external_id}")
return await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description,
payment_method=PaymentMethod.TRIBUTE,
external_id=external_id,
is_completed=True
)
+813
View File
@@ -0,0 +1,813 @@
import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import IntegrityError
from app.database.models import (
User,
UserStatus,
Subscription,
SubscriptionStatus,
Transaction,
PromoGroup,
PaymentMethod,
TransactionType,
)
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
def generate_referral_code() -> str:
alphabet = string.ascii_letters + string.digits
code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8))
return f"ref{code_suffix}"
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
_ = user.subscription.is_active
return user
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
_ = user.subscription.is_active
return user
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.promo_group))
.where(User.referral_code == referral_code)
)
return result.scalar_one_or_none()
async def create_unique_referral_code(db: AsyncSession) -> str:
max_attempts = 10
for _ in range(max_attempts):
code = generate_referral_code()
existing_user = await get_user_by_referral_code(db, code)
if not existing_user:
return code
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
return f"ref{timestamp}"
async def _sync_users_sequence(db: AsyncSession) -> None:
"""Ensure the users.id sequence matches the current max ID."""
await db.execute(
text(
"SELECT setval('users_id_seq', "
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
)
)
await db.commit()
logger.warning(
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
)
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
default_group = await get_default_promo_group(db)
if default_group:
return default_group
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
)
db.add(default_group)
await db.flush()
return default_group
async def create_user(
db: AsyncSession,
telegram_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
) -> User:
if not referral_code:
referral_code = await create_unique_referral_code(db)
attempts = 3
for attempt in range(1, attempts + 1):
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
try:
await db.commit()
await db.refresh(user)
user.promo_group = default_group
logger.info(
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
)
return user
except IntegrityError as exc:
await db.rollback()
if (
isinstance(getattr(exc, "orig", None), Exception)
and "users_pkey" in str(exc.orig)
and attempt < attempts
):
logger.warning(
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
"Выполняем повторную синхронизацию (попытка %s/%s)",
telegram_id,
attempt,
attempts,
)
await _sync_users_sequence(db)
continue
raise
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
async def update_user(
db: AsyncSession,
user: User,
**kwargs
) -> User:
from app.utils.validators import sanitize_telegram_name
for field, value in kwargs.items():
if field in ("first_name", "last_name"):
value = sanitize_telegram_name(value)
if hasattr(user, field):
setattr(user, field, value)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return user
async def add_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str = "Пополнение баланса",
create_transaction: bool = True,
bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
from app.database.models import TransactionType
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description
)
await db.commit()
await db.refresh(user)
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
return True
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
await db.rollback()
return False
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса"
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(db, user, amount_kopeks, description)
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
return False
async def subtract_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str,
create_transaction: bool = False,
payment_method: Optional[PaymentMethod] = None,
*,
consume_promo_offer: bool = False,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None
if consume_promo_offer:
try:
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
current_percent = 0
if current_percent > 0:
source = getattr(user, "promo_offer_discount_source", None)
log_context = {
"offer_id": None,
"percent": current_percent,
"source": source,
"effect_type": None,
"details": {
"reason": "manual_charge",
"description": description,
"amount_kopeks": amount_kopeks,
},
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_context["offer_id"] = offer.id
log_context["effect_type"] = offer.effect_type
if not log_context["percent"] and offer.discount_percent:
log_context["percent"] = offer.discount_percent
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
if create_transaction:
from app.database.crud.transaction import (
create_transaction as create_trans,
)
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
)
if consume_promo_offer and log_context:
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get("offer_id"),
action="consumed",
source=log_context.get("source"),
percent=log_context.get("percent"),
effect_type=log_context.get("effect_type"),
details=log_context.get("details"),
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer consumption log for user %s: %s",
user.id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer consumption log failure: %s",
rollback_error,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
except Exception as e:
logger.error(f" ❌ ОШИБКА СПИСАНИЯ: {e}")
await db.rollback()
return False
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(User).where(
User.promo_offer_discount_percent > 0,
User.promo_offer_discount_expires_at.isnot(None),
User.promo_offer_discount_expires_at <= now,
)
)
users = result.scalars().all()
if not users:
return 0
log_payloads: List[Dict[str, object]] = []
for user in users:
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
percent = 0
source = getattr(user, "promo_offer_discount_source", None)
offer_id = None
effect_type = None
if source:
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
user.id,
lookup_error,
)
offer = None
if offer:
offer_id = offer.id
effect_type = offer.effect_type
if not percent and offer.discount_percent:
percent = offer.discount_percent
log_payloads.append(
{
"user_id": user.id,
"offer_id": offer_id,
"source": source,
"percent": percent,
"effect_type": effect_type,
}
)
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = now
await db.commit()
for payload in log_payloads:
user_id = payload.get("user_id")
if not user_id:
continue
try:
await log_promo_offer_action(
db,
user_id=user_id,
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to log promo offer expiration for user %s: %s",
user_id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer expiration log failure: %s",
rollback_error,
)
return len(users)
async def get_users_list(
db: AsyncSession,
offset: int = 0,
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None,
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
order_by_total_spent: bool = False,
order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
if status:
query = query.where(User.status == status.value)
if search:
search_term = f"%{search}%"
conditions = [
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
User.username.ilike(search_term)
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
sort_flags = [
order_by_balance,
order_by_traffic,
order_by_last_activity,
order_by_total_spent,
order_by_purchase_count,
]
if sum(int(flag) for flag in sort_flags) > 1:
logger.debug(
"Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
)
transactions_stats = None
if order_by_total_spent or order_by_purchase_count:
from app.database.models import Transaction
transactions_stats = (
select(
Transaction.user_id.label("user_id"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(Transaction.is_completed.is_(True))
.group_by(Transaction.user_id)
.subquery()
)
query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
if order_by_traffic:
traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
query = query.outerjoin(Subscription, Subscription.user_id == User.id)
query = query.order_by(traffic_sort.desc(), User.created_at.desc())
elif order_by_total_spent:
order_column = func.coalesce(transactions_stats.c.total_spent, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_purchase_count:
order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_balance:
query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
elif order_by_last_activity:
query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
else:
query = query.order_by(User.created_at.desc())
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_users_count(
db: AsyncSession,
status: Optional[UserStatus] = None,
search: Optional[str] = None
) -> int:
query = select(func.count(User.id))
if status:
query = query.where(User.status == status.value)
if search:
search_term = f"%{search}%"
conditions = [
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
User.username.ilike(search_term)
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
result = await db.execute(query)
return result.scalar()
async def get_users_spending_stats(
db: AsyncSession,
user_ids: List[int]
) -> Dict[int, Dict[str, int]]:
if not user_ids:
return {}
from app.database.models import Transaction
stats_query = (
select(
Transaction.user_id,
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
)
.group_by(Transaction.user_id)
)
result = await db.execute(stats_query)
rows = result.all()
return {
row.user_id: {
"total_spent": int(row.total_spent or 0),
"purchase_count": int(row.purchase_count or 0),
}
for row in rows
}
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
return result.scalars().all()
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
now = datetime.utcnow()
base_query = (
select(User)
.options(selectinload(User.subscription))
.where(User.status == UserStatus.ACTIVE.value)
)
if segment == "no_subscription":
query = (
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
.where(Subscription.id.is_(None))
)
else:
query = base_query.join(Subscription)
if segment == "paid_active":
query = query.where(
Subscription.is_trial == False, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "paid_expired":
query = query.where(
Subscription.is_trial == False, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
elif segment == "trial_active":
query = query.where(
Subscription.is_trial == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "trial_expired":
query = query.where(
Subscription.is_trial == True, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
else:
logger.warning("Неизвестный сегмент для промо: %s", segment)
return []
result = await db.execute(query.order_by(User.id))
return result.scalars().unique().all()
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(
and_(
User.last_activity < threshold_date,
User.status == UserStatus.ACTIVE.value
)
)
)
return result.scalars().all()
async def delete_user(db: AsyncSession, user: User) -> bool:
user.status = UserStatus.DELETED.value
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"🗑️ Пользователь {user.telegram_id} помечен как удаленный")
return True
async def get_users_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(User.id)))
total_users = total_result.scalar()
active_result = await db.execute(
select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)
)
active_users = active_result.scalar()
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= today,
User.status == UserStatus.ACTIVE.value
)
)
)
new_today = today_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= week_ago,
User.status == UserStatus.ACTIVE.value
)
)
)
new_week = week_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= month_ago,
User.status == UserStatus.ACTIVE.value
)
)
)
new_month = month_result.scalar()
return {
"total_users": total_users,
"active_users": active_users,
"blocked_users": total_users - active_users,
"new_today": new_today,
"new_week": new_week,
"new_month": new_month
}
+157
View File
@@ -0,0 +1,157 @@
import logging
import random
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import UserMessage
logger = logging.getLogger(__name__)
async def create_user_message(
db: AsyncSession,
message_text: str,
created_by: int,
is_active: bool = True,
sort_order: int = 0
) -> UserMessage:
message = UserMessage(
message_text=message_text,
is_active=is_active,
sort_order=sort_order,
created_by=created_by
)
db.add(message)
await db.commit()
await db.refresh(message)
logger.info(f"✅ Создано сообщение ID {message.id} пользователем {created_by}")
return message
async def get_user_message_by_id(db: AsyncSession, message_id: int) -> Optional[UserMessage]:
result = await db.execute(
select(UserMessage).where(UserMessage.id == message_id)
)
return result.scalar_one_or_none()
async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]:
result = await db.execute(
select(UserMessage)
.where(UserMessage.is_active == True)
.order_by(UserMessage.sort_order.asc(), UserMessage.created_at.desc())
)
return result.scalars().all()
async def get_random_active_message(db: AsyncSession) -> Optional[str]:
active_messages = await get_active_user_messages(db)
if not active_messages:
return None
random_message = random.choice(active_messages)
return random_message.message_text
async def get_all_user_messages(
db: AsyncSession,
offset: int = 0,
limit: int = 50
) -> List[UserMessage]:
result = await db.execute(
select(UserMessage)
.order_by(UserMessage.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_user_messages_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count(UserMessage.id)))
return result.scalar()
async def update_user_message(
db: AsyncSession,
message_id: int,
message_text: Optional[str] = None,
is_active: Optional[bool] = None,
sort_order: Optional[int] = None
) -> Optional[UserMessage]:
message = await get_user_message_by_id(db, message_id)
if not message:
return None
if message_text is not None:
message.message_text = message_text
if is_active is not None:
message.is_active = is_active
if sort_order is not None:
message.sort_order = sort_order
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
logger.info(f"📝 Обновлено сообщение ID {message_id}")
return message
async def toggle_user_message_status(
db: AsyncSession,
message_id: int
) -> Optional[UserMessage]:
message = await get_user_message_by_id(db, message_id)
if not message:
return None
message.is_active = not message.is_active
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
status_text = "активировано" if message.is_active else "деактивировано"
logger.info(f"🔄 Сообщение ID {message_id} {status_text}")
return message
async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
message = await get_user_message_by_id(db, message_id)
if not message:
return False
await db.delete(message)
await db.commit()
logger.info(f"🗑️ Удалено сообщение ID {message_id}")
return True
async def get_user_messages_stats(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(UserMessage.id)))
total_messages = total_result.scalar()
active_result = await db.execute(
select(func.count(UserMessage.id)).where(UserMessage.is_active == True)
)
active_messages = active_result.scalar()
return {
"total_messages": total_messages,
"active_messages": active_messages,
"inactive_messages": total_messages - active_messages
}
+106
View File
@@ -0,0 +1,106 @@
"""CRUD операции для токенов административного веб-API."""
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WebApiToken
async def list_tokens(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> List[WebApiToken]:
query = select(WebApiToken)
if not include_inactive:
query = query.where(WebApiToken.is_active.is_(True))
query = query.order_by(WebApiToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
return await db.get(WebApiToken, token_id)
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
query = select(WebApiToken).where(
WebApiToken.token_hash == token_hash
)
result = await db.execute(query)
return result.scalar_one_or_none()
async def create_token(
db: AsyncSession,
*,
name: str,
token_hash: str,
token_prefix: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
) -> WebApiToken:
token = WebApiToken(
name=name,
token_hash=token_hash,
token_prefix=token_prefix,
description=description,
expires_at=expires_at,
created_by=created_by,
is_active=True,
)
db.add(token)
await db.flush()
await db.refresh(token)
return token
async def update_token(
db: AsyncSession,
token: WebApiToken,
**kwargs,
) -> WebApiToken:
for key, value in kwargs.items():
if hasattr(token, key):
setattr(token, key, value)
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def set_tokens_active_status(
db: AsyncSession,
token_ids: Iterable[int],
*,
is_active: bool,
) -> None:
await db.execute(
update(WebApiToken)
.where(WebApiToken.id.in_(list(token_ids)))
.values(is_active=is_active, updated_at=datetime.utcnow())
)
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
await db.delete(token)
__all__ = [
"list_tokens",
"get_token_by_id",
"get_token_by_hash",
"create_token",
"update_token",
"set_tokens_active_status",
"delete_token",
]
+174
View File
@@ -0,0 +1,174 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WelcomeText
logger = logging.getLogger(__name__)
WELCOME_TEXT_KEY = "welcome_text"
async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
result = await db.execute(
select(WelcomeText)
.where(WelcomeText.is_active == True)
.where(WelcomeText.is_enabled == True)
.order_by(WelcomeText.updated_at.desc())
)
welcome_text = result.scalar_one_or_none()
if welcome_text:
return welcome_text.text_content
return None
async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
result = await db.execute(
select(WelcomeText)
.where(WelcomeText.is_active == True)
.order_by(WelcomeText.updated_at.desc())
)
welcome_text = result.scalar_one_or_none()
if welcome_text:
return {
'text': welcome_text.text_content,
'is_enabled': welcome_text.is_enabled,
'id': welcome_text.id
}
return {
'text': await get_current_welcome_text_or_default(),
'is_enabled': True,
'id': None
}
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
try:
result = await db.execute(
select(WelcomeText)
.where(WelcomeText.is_active == True)
.order_by(WelcomeText.updated_at.desc())
)
welcome_text = result.scalar_one_or_none()
if welcome_text:
welcome_text.is_enabled = not welcome_text.is_enabled
welcome_text.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(welcome_text)
status = "включен" if welcome_text.is_enabled else "отключен"
logger.info(f"Приветственный текст {status} администратором {admin_id}")
return welcome_text.is_enabled
else:
default_text = await get_current_welcome_text_or_default()
new_welcome_text = WelcomeText(
text_content=default_text,
is_active=True,
is_enabled=True,
created_by=admin_id
)
db.add(new_welcome_text)
await db.commit()
await db.refresh(new_welcome_text)
logger.info(f"Создан и включен дефолтный приветственный текст администратором {admin_id}")
return True
except Exception as e:
logger.error(f"Ошибка при переключении статуса приветственного текста: {e}")
await db.rollback()
return False
async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
try:
current_settings = await get_current_welcome_text_settings(db)
current_enabled_status = current_settings.get('is_enabled', True)
await db.execute(
update(WelcomeText).values(is_active=False)
)
new_welcome_text = WelcomeText(
text_content=text_content,
is_active=True,
is_enabled=current_enabled_status,
created_by=admin_id
)
db.add(new_welcome_text)
await db.commit()
await db.refresh(new_welcome_text)
logger.info(f"Установлен новый приветственный текст администратором {admin_id}")
return True
except Exception as e:
logger.error(f"Ошибка при установке приветственного текста: {e}")
await db.rollback()
return False
async def get_current_welcome_text_or_default() -> str:
return (
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
f"Подключайтесь за минуту и забудьте о блокировках. "
f"✅ До 1 Гбит/с скорость "
f"✅ Умный VPN — можно не отключать для большинства российских сервисов "
f"✅ Современные протоколы — максимум защиты и анонимности "
f"💉 Всего 99₽/мес за 1 устройство "
f"👇 Жмите кнопку и подключайтесь!"
)
def replace_placeholders(text: str, user) -> str:
first_name = getattr(user, 'first_name', None)
username = getattr(user, 'username', None)
first_name = first_name.strip() if first_name else None
username = username.strip() if username else None
user_name = first_name or username or "друг"
display_first_name = first_name or "друг"
display_username = f"@{username}" if username else (first_name or "друг")
clean_username = username or first_name or "друг"
replacements = {
'{user_name}': user_name,
'{first_name}': display_first_name,
'{username}': display_username,
'{username_clean}': clean_username,
'Egor': user_name
}
result = text
for placeholder, value in replacements.items():
result = result.replace(placeholder, value)
return result
async def get_welcome_text_for_user(db: AsyncSession, user) -> str:
welcome_text = await get_active_welcome_text(db)
if not welcome_text:
return None
if isinstance(user, str):
class SimpleUser:
def __init__(self, name):
self.first_name = name
self.username = None
user = SimpleUser(user)
return replace_placeholders(welcome_text, user)
def get_available_placeholders() -> dict:
return {
'{user_name}': 'Имя или username пользователя (приоритет: имя → username → "друг")',
'{first_name}': 'Только имя пользователя (или "друг" если не указано)',
'{username}': 'Username с символом @ (или имя если username не указан)',
'{username_clean}': 'Username без символа @ (или имя если username не указан)'
}
+276
View File
@@ -0,0 +1,276 @@
import logging
from typing import Optional, List
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, and_
from sqlalchemy.orm import selectinload
from app.database.models import YooKassaPayment, User, Transaction
logger = logging.getLogger(__name__)
async def create_yookassa_payment(
db: AsyncSession,
user_id: int,
yookassa_payment_id: str,
amount_kopeks: int,
currency: str,
description: str,
status: str,
confirmation_url: Optional[str] = None,
metadata_json: Optional[dict] = None,
payment_method_type: Optional[str] = None,
yookassa_created_at: Optional[datetime] = None,
test_mode: bool = False
) -> YooKassaPayment:
payment = YooKassaPayment(
user_id=user_id,
yookassa_payment_id=yookassa_payment_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
confirmation_url=confirmation_url,
metadata_json=metadata_json,
payment_method_type=payment_method_type,
yookassa_created_at=yookassa_created_at,
test_mode=test_mode
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks/100}₽ для пользователя {user_id}")
return payment
async def get_yookassa_payment_by_id(
db: AsyncSession,
yookassa_payment_id: str
) -> Optional[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
return result.scalar_one_or_none()
async def get_yookassa_payment_by_local_id(
db: AsyncSession,
local_id: int
) -> Optional[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.id == local_id)
)
return result.scalar_one_or_none()
async def update_yookassa_payment_status(
db: AsyncSession,
yookassa_payment_id: str,
status: str,
is_paid: bool = False,
is_captured: bool = False,
captured_at: Optional[datetime] = None,
payment_method_type: Optional[str] = None
) -> Optional[YooKassaPayment]:
update_data = {
"status": status,
"is_paid": is_paid,
"is_captured": is_captured,
"updated_at": datetime.utcnow()
}
if captured_at:
update_data["captured_at"] = captured_at
if payment_method_type:
update_data["payment_method_type"] = payment_method_type
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(**update_data)
)
await db.commit()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}")
return payment
async def link_yookassa_payment_to_transaction(
db: AsyncSession,
yookassa_payment_id: str,
transaction_id: int
) -> Optional[YooKassaPayment]:
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(transaction_id=transaction_id, updated_at=datetime.utcnow())
)
await db.commit()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user), selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}")
return payment
async def get_user_yookassa_payments(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.user_id == user_id)
.order_by(YooKassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return result.scalars().all()
async def get_pending_yookassa_payments(
db: AsyncSession,
user_id: Optional[int] = None,
limit: int = 100
) -> List[YooKassaPayment]:
query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user))
conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])]
if user_id:
conditions.append(YooKassaPayment.user_id == user_id)
result = await db.execute(
query.where(and_(*conditions))
.order_by(YooKassaPayment.created_at.desc())
.limit(limit)
)
return result.scalars().all()
async def get_succeeded_yookassa_payments_without_transaction(
db: AsyncSession,
limit: int = 50
) -> List[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(
and_(
YooKassaPayment.status == "succeeded",
YooKassaPayment.is_paid == True,
YooKassaPayment.transaction_id == None
)
)
.order_by(YooKassaPayment.captured_at.desc())
.limit(limit)
)
return result.scalars().all()
async def delete_yookassa_payment(
db: AsyncSession,
yookassa_payment_id: str
) -> bool:
result = await db.execute(
select(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
await db.delete(payment)
await db.commit()
logger.info(f"Удален платеж YooKassa: {yookassa_payment_id}")
return True
return False
async def get_yookassa_payments_stats(
db: AsyncSession,
user_id: Optional[int] = None
) -> dict:
from sqlalchemy import func, case
query = select(
func.count(YooKassaPayment.id).label('total_payments'),
func.sum(YooKassaPayment.amount_kopeks).label('total_amount_kopeks'),
func.sum(
case(
(YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks),
else_=0
)
).label('succeeded_amount_kopeks'),
func.count(
case(
(YooKassaPayment.status == 'succeeded', 1),
else_=None
)
).label('succeeded_count'),
func.count(
case(
(YooKassaPayment.status == 'pending', 1),
else_=None
)
).label('pending_count'),
func.count(
case(
(YooKassaPayment.status.in_(['canceled', 'failed']), 1),
else_=None
)
).label('failed_count')
).select_from(YooKassaPayment)
if user_id:
query = query.where(YooKassaPayment.user_id == user_id)
result = await db.execute(query)
stats = result.first()
return {
'total_payments': stats.total_payments or 0,
'total_amount_kopeks': stats.total_amount_kopeks or 0,
'total_amount_rubles': (stats.total_amount_kopeks or 0) / 100,
'succeeded_amount_kopeks': stats.succeeded_amount_kopeks or 0,
'succeeded_amount_rubles': (stats.succeeded_amount_kopeks or 0) / 100,
'succeeded_count': stats.succeeded_count or 0,
'pending_count': stats.pending_count or 0,
'failed_count': stats.failed_count or 0,
'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0
}
+51
View File
@@ -0,0 +1,51 @@
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
engine = create_async_engine(
settings.get_database_url(),
poolclass=NullPool,
echo=settings.DEBUG,
future=True
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
logger.info("Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("✅ База данных успешно инициализирована")
async def close_db():
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
import logging
import hashlib
import hmac
import json
import aiohttp
from typing import Optional, Dict, Any
from datetime import datetime
from app.config import settings
logger = logging.getLogger(__name__)
class CryptoBotService:
def __init__(self):
self.api_token = settings.CRYPTOBOT_API_TOKEN
self.base_url = settings.get_cryptobot_base_url()
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None
) -> Optional[Dict[str, Any]]:
if not self.api_token:
logger.error("CryptoBot API token не настроен")
return None
url = f"{self.base_url}/api/{endpoint}"
headers = {
'Crypto-Pay-API-Token': self.api_token,
'Content-Type': 'application/json'
}
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method,
url,
headers=headers,
json=data if data else None
) as response:
response_data = await response.json()
if response.status == 200 and response_data.get('ok'):
return response_data.get('result')
else:
logger.error(f"CryptoBot API ошибка: {response_data}")
return None
except Exception as e:
logger.error(f"Ошибка запроса к CryptoBot API: {e}")
return None
async def get_me(self) -> Optional[Dict[str, Any]]:
return await self._make_request('GET', 'getMe')
async def create_invoice(
self,
amount: str,
asset: str = "USDT",
description: Optional[str] = None,
payload: Optional[str] = None,
expires_in: Optional[int] = None
) -> Optional[Dict[str, Any]]:
data = {
'currency_type': 'crypto',
'asset': asset,
'amount': amount
}
if description:
data['description'] = description
if payload:
data['payload'] = payload
if expires_in:
data['expires_in'] = expires_in
result = await self._make_request('POST', 'createInvoice', data)
if result:
logger.info(f"Создан CryptoBot invoice {result.get('invoice_id')} на {amount} {asset}")
return result
async def get_invoices(
self,
asset: Optional[str] = None,
status: Optional[str] = None,
offset: int = 0,
count: int = 100
) -> Optional[list]:
data = {
'offset': offset,
'count': count
}
if asset:
data['asset'] = asset
if status:
data['status'] = status
return await self._make_request('GET', 'getInvoices', data)
async def get_balance(self) -> Optional[list]:
return await self._make_request('GET', 'getBalance')
async def get_exchange_rates(self) -> Optional[list]:
return await self._make_request('GET', 'getExchangeRates')
def verify_webhook_signature(self, body: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning("CryptoBot webhook secret не настроен")
return True
try:
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
is_valid = hmac.compare_digest(signature, expected_signature)
if is_valid:
logger.info("✅ CryptoBot webhook подпись валидна")
else:
logger.error("❌ Неверная подпись CryptoBot webhook")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи CryptoBot webhook: {e}")
return False
async def process_webhook(self, webhook_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
try:
update_type = webhook_data.get('update_type')
if update_type == 'invoice_paid':
invoice_data = webhook_data.get('payload', {})
return {
'event_type': 'payment',
'payment_id': str(invoice_data.get('invoice_id')),
'amount': invoice_data.get('amount'),
'asset': invoice_data.get('asset'),
'status': 'paid',
'user_payload': invoice_data.get('payload'),
'paid_at': invoice_data.get('paid_at'),
'payment_system': 'cryptobot'
}
logger.warning(f"Неизвестный тип CryptoBot webhook: {update_type}")
return None
except Exception as e:
logger.error(f"Ошибка обработки CryptoBot webhook: {e}")
return None
+216
View File
@@ -0,0 +1,216 @@
"""Async client for PayPalych (Pal24) API."""
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class Pal24APIError(Exception):
"""Base error for Pal24 API operations."""
@dataclass(slots=True)
class Pal24Response:
"""Wrapper for Pal24 API responses."""
success: bool
data: Dict[str, Any]
status: int
@classmethod
def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response":
success = bool(payload.get("success", status < 400))
return cls(success=success, data=payload, status=status)
def raise_for_status(self, endpoint: str) -> None:
if not self.success:
detail = self.data.get("message") or self.data.get("error")
raise Pal24APIError(
f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}"
)
class Pal24Client:
"""Async client implementing PayPalych API methods."""
def __init__(
self,
*,
api_token: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
) -> None:
self.api_token = api_token or settings.PAL24_API_TOKEN
self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/"
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
if not self.api_token:
logger.warning("Pal24Client initialized without API token")
@property
def is_configured(self) -> bool:
return bool(self.api_token and self.base_url)
async def _request(
self,
method: str,
endpoint: str,
*,
json_payload: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Pal24Response:
if not self.is_configured:
raise Pal24APIError("Pal24 client is not configured")
url = f"{self.base_url}{endpoint.lstrip('/')}"
headers = {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
timeout = aiohttp.ClientTimeout(total=self.timeout)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
method,
url,
headers=headers,
json=json_payload,
params=params,
) as response:
status = response.status
try:
payload = await response.json(content_type=None)
except aiohttp.ContentTypeError:
text_body = await response.text()
logger.error(
"Pal24 API returned non-JSON response for %s: %s",
endpoint,
text_body,
)
raise Pal24APIError(
f"Pal24 API returned non-JSON response: {text_body}"
) from None
result = Pal24Response.from_payload(payload, status)
if status >= 400 or not result.success:
logger.error(
"Pal24 API error %s %s: %s",
status,
endpoint,
payload,
)
result.raise_for_status(endpoint)
return result
except asyncio.TimeoutError as error:
logger.error("Pal24 API request timeout for %s: %s", endpoint, error)
raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error
except aiohttp.ClientError as error:
logger.error("Pal24 API client error for %s: %s", endpoint, error)
raise Pal24APIError(str(error)) from error
# API methods -----------------------------------------------------------------
async def create_bill(
self,
*,
amount: Decimal,
shop_id: str,
order_id: Optional[str] = None,
description: Optional[str] = None,
currency_in: str = "RUB",
type_: str = "normal",
**kwargs: Any,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"amount": str(amount),
"shop_id": shop_id,
"currency_in": currency_in,
"type": type_,
}
if order_id:
payload["order_id"] = order_id
if description:
payload["description"] = description
payload.update({k: v for k, v in kwargs.items() if v is not None})
response = await self._request("POST", "bill/create", json_payload=payload)
return response.data
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
response = await self._request("GET", "bill/status", params={"id": bill_id})
return response.data
async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]:
payload = {"id": bill_id, "active": 1 if active else 0}
response = await self._request("POST", "bill/toggle_activity", json_payload=payload)
return response.data
async def search_payments(self, **params: Any) -> Dict[str, Any]:
response = await self._request("GET", "payment/search", params=params)
return response.data
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
response = await self._request("GET", "payment/status", params={"id": payment_id})
return response.data
async def get_balance(self) -> Dict[str, Any]:
response = await self._request("GET", "merchant/balance")
return response.data
async def search_bills(self, **params: Any) -> Dict[str, Any]:
response = await self._request("GET", "bill/search", params=params)
return response.data
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
response = await self._request("GET", "bill/payments", params={"id": bill_id})
return response.data
# Helpers ---------------------------------------------------------------------
@staticmethod
def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str:
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
if not token:
raise Pal24APIError("Pal24 signature token is not configured")
raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8")
return hashlib.md5(raw).hexdigest().upper()
@staticmethod
def verify_signature(
out_sum: str,
inv_id: str,
signature: str,
api_token: Optional[str] = None,
) -> bool:
try:
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
except Pal24APIError:
logger.error("Pal24 signature verification failed: missing token")
return False
return expected == signature.upper()
@staticmethod
def normalize_amount(amount_kopeks: int) -> Decimal:
try:
return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01"))
except (InvalidOperation, TypeError) as error:
raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error
+155
View File
@@ -0,0 +1,155 @@
"""Flask webhook server for PayPalych postbacks."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from asyncio import AbstractEventLoop
from typing import Any, Dict, Optional
from flask import Flask, jsonify, request
from werkzeug.serving import make_server
from app.config import settings
from app.database.database import get_db
from app.services.pal24_service import Pal24Service, Pal24APIError
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
def _normalize_payload() -> Dict[str, str]:
if request.is_json:
payload = request.get_json(silent=True) or {}
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload)
return {}
if request.form:
return {k: v for k, v in request.form.items()}
try:
raw_body = request.data.decode("utf-8")
if raw_body:
payload = json.loads(raw_body)
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
except json.JSONDecodeError:
logger.debug("Pal24 webhook body не удалось распарсить как JSON")
return {}
def create_pal24_flask_app(
payment_service: PaymentService,
loop: AbstractEventLoop,
) -> Flask:
pal24_service = Pal24Service()
app = Flask(__name__)
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"])
def pal24_webhook() -> tuple:
if not pal24_service.is_configured:
logger.error("Pal24 webhook получен, но сервис не настроен")
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
payload = _normalize_payload()
if not payload:
logger.warning("Пустой Pal24 webhook")
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
async def process() -> bool:
async for db in get_db():
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
finally:
await db.close()
try:
future = asyncio.run_coroutine_threadsafe(process(), loop)
processed = future.result()
except Exception as error: # pragma: no cover - defensive
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": "internal_error"}), 500
if processed:
return jsonify({"status": "ok"}), 200
return jsonify({"status": "error", "reason": "not_processed"}), 400
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"])
def pal24_health() -> tuple:
return jsonify({
"status": "ok",
"service": "pal24_webhook",
"enabled": settings.is_pal24_enabled(),
}), 200
@app.route("/pal24/health", methods=["GET"])
def pal24_additional_health() -> tuple:
return jsonify({
"status": "ok",
"service": "pal24_webhook",
"path": settings.PAL24_WEBHOOK_PATH,
}), 200
return app
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
self._server: Optional[Any] = None
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if self._server:
logger.warning("Pal24 webhook server уже запущен")
return
self._server = make_server(
host="0.0.0.0",
port=settings.PAL24_WEBHOOK_PORT,
app=self.app,
threaded=True,
)
def _serve() -> None:
logger.info(
"Pal24 webhook сервер запущен на %s:%s%s",
"0.0.0.0",
settings.PAL24_WEBHOOK_PORT,
settings.PAL24_WEBHOOK_PATH,
)
self._server.serve_forever()
self._thread = threading.Thread(target=_serve, daemon=True)
self._thread.start()
def stop(self) -> None:
if self._server:
logger.info("Останавливаем Pal24 webhook сервер")
self._server.shutdown()
self._server = None
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
self._thread = None
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
loop = asyncio.get_running_loop()
server = Pal24WebhookServer(payment_service, loop)
await loop.run_in_executor(None, server.start)
return server
+716
View File
@@ -0,0 +1,716 @@
import asyncio
import json
import ssl
import base64
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
import aiohttp
import logging
from dataclasses import dataclass
from enum import Enum
from urllib.parse import urlparse, urljoin
logger = logging.getLogger(__name__)
class UserStatus(Enum):
ACTIVE = "ACTIVE"
DISABLED = "DISABLED"
LIMITED = "LIMITED"
EXPIRED = "EXPIRED"
class TrafficLimitStrategy(Enum):
NO_RESET = "NO_RESET"
DAY = "DAY"
WEEK = "WEEK"
MONTH = "MONTH"
@dataclass
class RemnaWaveUser:
uuid: str
short_uuid: str
username: str
status: UserStatus
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
telegram_id: Optional[int]
email: Optional[str]
hwid_device_limit: Optional[int]
description: Optional[str]
tag: Optional[str]
subscription_url: str
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
online_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
@dataclass
class RemnaWaveInternalSquad:
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: List[Dict[str, Any]]
@dataclass
class RemnaWaveNode:
uuid: str
name: str
address: str
country_code: str
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: Optional[int]
traffic_used_bytes: Optional[int]
traffic_limit_bytes: Optional[int]
@dataclass
class SubscriptionInfo:
is_found: bool
user: Optional[Dict[str, Any]]
links: List[str]
ss_conf_links: Dict[str, str]
subscription_url: str
happ: Optional[Dict[str, str]]
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
class RemnaWaveAPIError(Exception):
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class RemnaWaveAPI:
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None,
username: Optional[str] = None, password: Optional[str] = None):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.secret_key = secret_key
self.username = username
self.password = password
self.session: Optional[aiohttp.ClientSession] = None
self.authenticated = False
def _detect_connection_type(self) -> str:
parsed = urlparse(self.base_url)
local_hosts = [
'localhost', '127.0.0.1', 'remnawave',
'remnawave-backend', 'app', 'api'
]
if parsed.hostname in local_hosts:
return "local"
if parsed.hostname:
if (parsed.hostname.startswith('192.168.') or
parsed.hostname.startswith('10.') or
parsed.hostname.startswith('172.') or
parsed.hostname.endswith('.local')):
return "local"
return "external"
def _prepare_auth_headers(self) -> Dict[str, str]:
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Forwarded-Proto': 'https',
'X-Forwarded-For': '127.0.0.1',
'X-Real-IP': '127.0.0.1'
}
if self.username and self.password:
import base64
credentials = f"{self.username}:{self.password}"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
else:
headers['X-Api-Key'] = self.api_key
logger.debug("Используем API ключ в X-Api-Key заголовке")
headers['Authorization'] = f'Bearer {self.api_key}'
return headers
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = self._prepare_auth_headers()
cookies = None
if self.secret_key:
if ':' in self.secret_key:
key_name, key_value = self.secret_key.split(':', 1)
cookies = {key_name: key_value}
logger.debug(f"Используем куки: {key_name}=***")
else:
cookies = {self.secret_key: self.secret_key}
logger.debug(f"Используем куки: {self.secret_key}=***")
connector_kwargs = {}
if conn_type == "local":
logger.debug("Используют локальные заголовки proxy")
headers.update({
'X-Forwarded-Host': 'localhost',
'Host': 'localhost'
})
if self.base_url.startswith('https://'):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector_kwargs['ssl'] = ssl_context
logger.debug("SSL проверка отключена для локального HTTPS")
elif conn_type == "external":
logger.debug("Используют внешнее подключение с полной SSL проверкой")
pass
connector = aiohttp.TCPConnector(**connector_kwargs)
session_kwargs = {
'timeout': aiohttp.ClientTimeout(total=30),
'headers': headers,
'connector': connector
}
if cookies:
session_kwargs['cookies'] = cookies
self.session = aiohttp.ClientSession(**session_kwargs)
self.authenticated = True
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.session:
await self.session.close()
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None
) -> Dict:
if not self.session:
raise RemnaWaveAPIError("Session not initialized. Use async context manager.")
url = f"{self.base_url}{endpoint}"
try:
kwargs = {
'url': url,
'params': params
}
if data:
kwargs['json'] = data
async with self.session.request(method, **kwargs) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
if response.status >= 400:
error_message = response_data.get('message', f'HTTP {response.status}')
logger.error(f"API Error {response.status}: {error_message}")
logger.error(f"Response: {response_text[:500]}")
raise RemnaWaveAPIError(
error_message,
response.status,
response_data
)
return response_data
except aiohttp.ClientError as e:
logger.error(f"Request failed: {e}")
raise RemnaWaveAPIError(f"Request failed: {str(e)}")
async def create_user(
self,
username: str,
expire_at: datetime,
status: UserStatus = UserStatus.ACTIVE,
traffic_limit_bytes: int = 0,
traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
active_internal_squads: Optional[List[str]] = None
) -> RemnaWaveUser:
data = {
'username': username,
'status': status.value,
'expireAt': expire_at.isoformat(),
'trafficLimitBytes': traffic_limit_bytes,
'trafficLimitStrategy': traffic_limit_strategy.value
}
if telegram_id:
data['telegramId'] = telegram_id
if email:
data['email'] = email
if hwid_device_limit:
data['hwidDeviceLimit'] = hwid_device_limit
if description:
data['description'] = description
if tag:
data['tag'] = tag
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('POST', '/api/users', data)
return self._parse_user(response['response'])
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/{uuid}')
return self._parse_user(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
users_data = response.get('response', [])
if not users_data:
return []
return [self._parse_user(user) for user in users_data]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
raise
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/by-username/{username}')
return self._parse_user(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def update_user(
self,
uuid: str,
status: Optional[UserStatus] = None,
traffic_limit_bytes: Optional[int] = None,
traffic_limit_strategy: Optional[TrafficLimitStrategy] = None,
expire_at: Optional[datetime] = None,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
active_internal_squads: Optional[List[str]] = None
) -> RemnaWaveUser:
data = {'uuid': uuid}
if status:
data['status'] = status.value
if traffic_limit_bytes is not None:
data['trafficLimitBytes'] = traffic_limit_bytes
if traffic_limit_strategy:
data['trafficLimitStrategy'] = traffic_limit_strategy.value
if expire_at:
data['expireAt'] = expire_at.isoformat()
if telegram_id is not None:
data['telegramId'] = telegram_id
if email is not None:
data['email'] = email
if hwid_device_limit is not None:
data['hwidDeviceLimit'] = hwid_device_limit
if description is not None:
data['description'] = description
if tag is not None:
data['tag'] = tag
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('PATCH', '/api/users', data)
return self._parse_user(response['response'])
async def delete_user(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/users/{uuid}')
return response['response']['isDeleted']
async def enable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
return self._parse_user(response['response'])
async def disable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
return self._parse_user(response['response'])
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
return self._parse_user(response['response'])
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
data = {}
if new_short_uuid:
data['shortUuid'] = new_short_uuid
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
return self._parse_user(response['response'])
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
params = {'start': start, 'size': size}
response = await self._make_request('GET', '/api/users', params=params)
return {
'users': [self._parse_user(user) for user in response['response']['users']],
'total': response['response']['total']
}
async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]:
response = await self._make_request('GET', '/api/internal-squads')
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]:
try:
response = await self._make_request('GET', f'/api/internal-squads/{uuid}')
return self._parse_internal_squad(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad:
data = {
'name': name,
'inbounds': inbounds
}
response = await self._make_request('POST', '/api/internal-squads', data)
return self._parse_internal_squad(response['response'])
async def update_internal_squad(
self,
uuid: str,
name: Optional[str] = None,
inbounds: Optional[List[str]] = None
) -> RemnaWaveInternalSquad:
data = {'uuid': uuid}
if name:
data['name'] = name
if inbounds is not None:
data['inbounds'] = inbounds
response = await self._make_request('PATCH', '/api/internal-squads', data)
return self._parse_internal_squad(response['response'])
async def delete_internal_squad(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
return response['response']['isDeleted']
async def get_all_nodes(self) -> List[RemnaWaveNode]:
response = await self._make_request('GET', '/api/nodes')
return [self._parse_node(node) for node in response['response']]
async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]:
try:
response = await self._make_request('GET', f'/api/nodes/{uuid}')
return self._parse_node(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def enable_node(self, uuid: str) -> RemnaWaveNode:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable')
return self._parse_node(response['response'])
async def disable_node(self, uuid: str) -> RemnaWaveNode:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable')
return self._parse_node(response['response'])
async def restart_node(self, uuid: str) -> bool:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
return response['response']['eventSent']
async def restart_all_nodes(self) -> bool:
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
return response['response']['eventSent']
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
return self._parse_subscription_info(response['response'])
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
if response.status >= 400:
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
return await response.text()
async def get_subscription_by_client_type(self, short_uuid: str, client_type: str) -> str:
valid_types = ["stash", "singbox", "singbox-legacy", "mihomo", "json", "v2ray-json", "clash"]
if client_type not in valid_types:
raise ValueError(f"Invalid client type. Must be one of: {valid_types}")
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}/{client_type}") as response:
if response.status >= 400:
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
return await response.text()
async def get_subscription_links(self, short_uuid: str) -> Dict[str, str]:
base_url = f"{self.base_url}/api/sub/{short_uuid}"
links = {
"base": base_url,
"stash": f"{base_url}/stash",
"singbox": f"{base_url}/singbox",
"singbox_legacy": f"{base_url}/singbox-legacy",
"mihomo": f"{base_url}/mihomo",
"json": f"{base_url}/json",
"v2ray_json": f"{base_url}/v2ray-json",
"clash": f"{base_url}/clash"
}
return links
async def get_outline_subscription(self, short_uuid: str, encoded_tag: str) -> str:
async with self.session.get(f"{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}") as response:
if response.status >= 400:
raise RemnaWaveAPIError(f"Failed to get outline subscription: {response.status}")
return await response.text()
async def get_system_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats')
return response['response']
async def get_bandwidth_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats/bandwidth')
return response['response']
async def get_nodes_statistics(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats/nodes')
return response['response']
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/nodes/usage/realtime')
return response['response']
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
return response['response']
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
return True
failed_count = 0
for device in devices:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f"Ошибка при сбросе устройств: {e}")
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
happ_data = user_data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
return RemnaWaveUser(
uuid=user_data['uuid'],
short_uuid=user_data['shortUuid'],
username=user_data['username'],
status=UserStatus(user_data['status']),
used_traffic_bytes=int(user_data['usedTrafficBytes']),
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
traffic_limit_bytes=user_data['trafficLimitBytes'],
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
telegram_id=user_data.get('telegramId'),
email=user_data.get('email'),
hwid_device_limit=user_data.get('hwidDeviceLimit'),
description=user_data.get('description'),
tag=user_data.get('tag'),
subscription_url=user_data['subscriptionUrl'],
active_internal_squads=user_data['activeInternalSquads'],
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
if date_str:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
return RemnaWaveInternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
members_count=squad_data['info']['membersCount'],
inbounds_count=squad_data['info']['inboundsCount'],
inbounds=squad_data['inbounds']
)
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
return RemnaWaveNode(
uuid=node_data['uuid'],
name=node_data['name'],
address=node_data['address'],
country_code=node_data['countryCode'],
is_connected=node_data['isConnected'],
is_disabled=node_data['isDisabled'],
is_node_online=node_data['isNodeOnline'],
is_xray_running=node_data['isXrayRunning'],
users_online=node_data.get('usersOnline'),
traffic_used_bytes=node_data.get('trafficUsedBytes'),
traffic_limit_bytes=node_data.get('trafficLimitBytes')
)
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
happ_data = data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
return SubscriptionInfo(
is_found=data['isFound'],
user=data.get('user'),
links=data.get('links', []),
ss_conf_links=data.get('ssConfLinks', {}),
subscription_url=data.get('subscriptionUrl', ''),
happ=data.get('happ'),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
)
def format_bytes(bytes_value: int) -> str:
if bytes_value == 0:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = bytes_value
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
return f"{size:.1f} {units[unit_index]}"
def parse_bytes(size_str: str) -> int:
size_str = size_str.upper().strip()
units = {
'B': 1,
'KB': 1024,
'MB': 1024 ** 2,
'GB': 1024 ** 3,
'TB': 1024 ** 4
}
for unit, multiplier in units.items():
if size_str.endswith(unit):
try:
value = float(size_str[:-len(unit)].strip())
return int(value * multiplier)
except ValueError:
break
return 0
async def test_api_connection(api: RemnaWaveAPI) -> bool:
try:
await api.get_system_stats()
return True
except Exception as e:
logger.error(f"API connection test failed: {e}")
return False
+114
View File
@@ -0,0 +1,114 @@
import logging
from typing import Optional, Dict, Any
from aiogram import Bot
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
from app.config import settings
logger = logging.getLogger(__name__)
class TelegramStarsService:
def __init__(self, bot: Bot):
self.bot = bot
@staticmethod
def calculate_stars_from_rubles(rubles: float) -> int:
return settings.rubles_to_stars(rubles)
@staticmethod
def calculate_rubles_from_stars(stars: int) -> float:
return settings.stars_to_rubles(stars)
async def create_invoice(
self,
chat_id: int,
title: str,
description: str,
amount_kopeks: int,
payload: str,
start_parameter: Optional[str] = None
) -> Optional[str]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
invoice_link = await self.bot.create_invoice_link(
title=title,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=[LabeledPrice(label=title, amount=stars_amount)],
start_parameter=start_parameter
)
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
f"для {chat_id}, курс: {stars_rate}₽/⭐"
)
return invoice_link
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
return None
async def send_invoice(
self,
chat_id: int,
title: str,
description: str,
amount_kopeks: int,
payload: str,
keyboard: Optional[InlineKeyboardMarkup] = None
) -> Optional[Dict[str, Any]]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
message = await self.bot.send_invoice(
chat_id=chat_id,
title=title,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=[LabeledPrice(label=title, amount=stars_amount)],
reply_markup=keyboard
)
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{int(amount_rubles)}₽), курс: {stars_rate}₽/⭐"
)
return {
"message_id": message.message_id,
"stars_amount": stars_amount,
"rubles_amount": amount_rubles,
"payload": payload
}
except Exception as e:
logger.error(f"Ошибка отправки Stars invoice: {e}")
return None
async def answer_pre_checkout_query(
self,
pre_checkout_query_id: str,
ok: bool = True,
error_message: Optional[str] = None
) -> bool:
try:
await self.bot.answer_pre_checkout_query(
pre_checkout_query_id=pre_checkout_query_id,
ok=ok,
error_message=error_message
)
logger.info(f"Ответ на pre_checkout_query: ok={ok}")
return True
except Exception as e:
logger.error(f"Ошибка ответа на pre_checkout_query: {e}")
return False
+161
View File
@@ -0,0 +1,161 @@
import logging
import hashlib
import hmac
import json
from typing import Optional, Dict, Any
from app.config import settings
logger = logging.getLogger(__name__)
class TributeService:
def __init__(self):
self.api_key = settings.TRIBUTE_API_KEY
self.donate_link = settings.TRIBUTE_DONATE_LINK
async def create_payment_link(
self,
user_id: int,
amount_kopeks: int = 0,
description: str = "Пополнение баланса"
) -> Optional[str]:
if not settings.TRIBUTE_ENABLED:
logger.warning("Tribute платежи отключены")
return None
try:
payment_url = f"{self.donate_link}&user_id={user_id}"
logger.info(f"Создана ссылка Tribute для пользователя {user_id}")
return payment_url
except Exception as e:
logger.error(f"Ошибка создания Tribute ссылки: {e}")
return None
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
if not self.api_key:
logger.warning("API key не настроен, пропускаем проверку")
return True
try:
expected_signature = hmac.new(
self.api_key.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
is_valid = hmac.compare_digest(signature, expected_signature)
if is_valid:
logger.info("✅ Подпись Tribute webhook проверена успешно")
else:
logger.error("❌ Неверная подпись Tribute webhook")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи webhook: {e}")
return False
async def process_webhook(self, payload_or_data) -> Optional[Dict[str, Any]]:
try:
logger.info(f"🔄 Начинаем обработку Tribute webhook")
if isinstance(payload_or_data, str):
try:
webhook_data = json.loads(payload_or_data)
logger.info(f"📊 Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON: {e}")
return None
else:
webhook_data = payload_or_data
payment_id = None
status = None
amount_kopeks = 0
telegram_user_id = None
payment_id = webhook_data.get("id") or webhook_data.get("payment_id")
status = webhook_data.get("status")
amount_kopeks = webhook_data.get("amount", 0)
telegram_user_id = webhook_data.get("telegram_user_id") or webhook_data.get("user_id")
if not payment_id and "payload" in webhook_data:
data = webhook_data["payload"]
payment_id = data.get("id") or data.get("payment_id")
status = data.get("status")
amount_kopeks = data.get("amount", 0)
telegram_user_id = data.get("telegram_user_id") or data.get("user_id")
if not payment_id and "name" in webhook_data:
event_name = webhook_data.get("name")
data = webhook_data.get("payload", {})
payment_id = str(data.get("donation_request_id"))
amount_kopeks = data.get("amount", 0)
telegram_user_id = data.get("telegram_user_id")
if event_name == "new_donation":
status = "paid"
elif event_name == "cancelled_subscription":
status = "cancelled"
else:
status = "unknown"
logger.info(f"📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
if not telegram_user_id:
logger.error("❌ Не найден telegram_user_id в webhook данных")
logger.error(f"🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
return None
try:
telegram_user_id = int(telegram_user_id)
except (ValueError, TypeError):
logger.error(f"❌ Некорректный telegram_user_id: {telegram_user_id}")
return None
result = {
"event_type": "payment",
"payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}",
"user_id": telegram_user_id,
"amount_kopeks": int(amount_kopeks) if amount_kopeks else 0,
"status": status or "paid",
"external_id": f"donation_{payment_id or 'unknown'}",
"payment_system": "tribute"
}
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
return result
except Exception as e:
logger.error(f"❌ Ошибка обработки Tribute webhook: {e}", exc_info=True)
logger.error(f"🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
return None
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
try:
logger.info(f"Запрос статуса платежа {payment_id}")
return {"status": "unknown", "payment_id": payment_id}
except Exception as e:
logger.error(f"Ошибка получения статуса платежа: {e}")
return None
async def refund_payment(
self,
payment_id: str,
amount_kopeks: Optional[int] = None,
reason: str = "Возврат по запросу"
) -> Optional[Dict[str, Any]]:
try:
logger.info(f"Создание возврата для платежа {payment_id}")
return {"refund_id": f"refund_{payment_id}", "status": "pending"}
except Exception as e:
logger.error(f"Ошибка создания возврата: {e}")
return None
+423
View File
@@ -0,0 +1,423 @@
import base64
import hashlib
import hmac
import logging
import json
from typing import Optional, Iterable
from aiohttp import web
from aiogram import Bot
from app.config import settings
from app.services.tribute_service import TributeService
from app.services.payment_service import PaymentService
from app.database.database import get_db
logger = logging.getLogger(__name__)
class WebhookServer:
def __init__(self, bot: Bot):
self.bot = bot
self.app = None
self.runner = None
self.site = None
self.tribute_service = TributeService(bot)
async def create_app(self) -> web.Application:
self.app = web.Application()
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
if settings.is_mulenpay_enabled():
self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
self.app.router.add_get('/health', self._health_check)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
if settings.is_mulenpay_enabled():
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
return self.app
async def start(self):
try:
if not self.app:
await self.create_app()
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(
self.runner,
host=settings.TRIBUTE_WEBHOOK_HOST,
port=settings.TRIBUTE_WEBHOOK_PORT
)
await self.site.start()
logger.info(
"Webhook сервер запущен на %s:%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
)
logger.info(
"Tribute webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.TRIBUTE_WEBHOOK_PATH,
)
if settings.is_mulenpay_enabled():
logger.info(
"Mulen Pay webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(
"CryptoBot webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.CRYPTOBOT_WEBHOOK_PATH,
)
except Exception as e:
logger.error(f"Ошибка запуска webhook сервера: {e}")
raise
async def stop(self):
try:
if self.site:
await self.site.stop()
logger.info("Webhook сайт остановлен")
if self.runner:
await self.runner.cleanup()
logger.info("Webhook runner очищен")
except Exception as e:
logger.error(f"Ошибка остановки webhook сервера: {e}")
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
}
)
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("Пустой Mulen Pay webhook")
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
# Временно отключаем проверку подписи для отладки
# TODO: Включить обратно после настройки MulenPay
if not self._verify_mulenpay_signature(request, raw_body):
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError as error:
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
payment_service = PaymentService(self.bot)
# Получаем соединение с БД
db_generator = get_db()
db = await db_generator.__anext__()
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
@staticmethod
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
for header_name in header_names:
value = request.headers.get(header_name)
if value:
return value.strip()
return None
@staticmethod
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
if not secret_key:
logger.error("Mulen Pay secret key is not configured")
return False
# Логируем все заголовки для отладки
logger.info("MulenPay webhook headers for signature verification:")
for header_name, header_value in request.headers.items():
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
logger.info(f" {header_name}: {header_value}")
signature = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Signature',
'X-Mulenpay-Signature',
'X-MULENPAY-SIGNATURE',
'X-MulenPay-Webhook-Signature',
'X-Mulenpay-Webhook-Signature',
'X-MULENPAY-WEBHOOK-SIGNATURE',
'X-Signature',
'Signature',
'X-MulenPay-Sign',
'X-Mulenpay-Sign',
'X-MULENPAY-SIGN',
'MulenPay-Signature',
'Mulenpay-Signature',
'MULENPAY-SIGNATURE',
'signature',
'sign',
)
)
if signature:
normalized_signature = signature
if normalized_signature.lower().startswith('sha256='):
normalized_signature = normalized_signature.split('=', 1)[1].strip()
hmac_digest = hmac.new(
secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).digest()
expected_hex_signature = hmac_digest.hex()
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
normalized_signature_lower = normalized_signature.lower()
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
return True
normalized_signature_no_padding = normalized_signature.rstrip('=')
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
return True
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
return True
logger.error("Неверная подпись Mulen Pay webhook")
return False
authorization_header = request.headers.get('Authorization')
if authorization_header:
scheme, _, value = authorization_header.partition(' ')
scheme_lower = scheme.lower()
token = value.strip() if value else scheme.strip()
if scheme_lower in ('bearer', 'token'):
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
return False
if not value and hmac.compare_digest(token, secret_key):
return True
fallback_token = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Token',
'X-Mulenpay-Token',
'X-Webhook-Token',
)
)
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.info(
"Mulen Pay webhook headers received: %s",
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
)
logger.error("Отсутствует подпись Mulen Pay webhook")
return False
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
logger.info(f"Headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("Получен пустой webhook от Tribute")
return web.json_response(
{"status": "error", "reason": "empty_body"},
status=400
)
payload = raw_body.decode('utf-8')
logger.info(f"Payload: {payload}")
try:
webhook_data = json.loads(payload)
logger.info(f"Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON: {e}")
return web.json_response(
{"status": "error", "reason": "invalid_json"},
status=400
)
signature = request.headers.get('trbt-signature')
logger.info(f"Signature: {signature}")
if not signature:
logger.error("Отсутствует заголовок подписи Tribute webhook")
return web.json_response(
{"status": "error", "reason": "missing_signature"},
status=401
)
if settings.TRIBUTE_API_KEY:
from app.external.tribute import TributeService as TributeAPI
tribute_api = TributeAPI()
if not tribute_api.verify_webhook_signature(payload, signature):
logger.error("Неверная подпись Tribute webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
)
result = await self.tribute_service.process_webhook(payload)
if result:
logger.info(f"Tribute webhook обработан успешно: {result}")
return web.json_response({"status": "ok", "result": result}, status=200)
else:
logger.error("Ошибка обработки Tribute webhook")
return web.json_response(
{"status": "error", "reason": "processing_failed"},
status=400
)
except Exception as e:
logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
return web.json_response(
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
)
async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен CryptoBot webhook: {request.method} {request.path}")
logger.info(f"Headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("Получен пустой CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "empty_body"},
status=400
)
payload = raw_body.decode('utf-8')
logger.info(f"CryptoBot Payload: {payload}")
try:
webhook_data = json.loads(payload)
logger.info(f"CryptoBot данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга CryptoBot JSON: {e}")
return web.json_response(
{"status": "error", "reason": "invalid_json"},
status=400
)
signature = request.headers.get('Crypto-Pay-API-Signature')
logger.info(f"CryptoBot Signature: {signature}")
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
from app.external.cryptobot import CryptoBotService
cryptobot_service = CryptoBotService()
if not cryptobot_service.verify_webhook_signature(payload, signature):
logger.error("Неверная подпись CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
)
from app.services.payment_service import PaymentService
from app.database.database import AsyncSessionLocal
payment_service = PaymentService(self.bot)
async with AsyncSessionLocal() as db:
result = await payment_service.process_cryptobot_webhook(db, webhook_data)
if result:
logger.info(f"CryptoBot webhook обработан успешно")
return web.json_response({"status": "ok"}, status=200)
else:
logger.error("Ошибка обработки CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "processing_failed"},
status=400
)
except Exception as e:
logger.error(f"Критическая ошибка обработки CryptoBot webhook: {e}", exc_info=True)
return web.json_response(
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
)
async def _health_check(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
"service": "payment-webhooks",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"cryptobot_enabled": settings.is_cryptobot_enabled(),
"port": settings.TRIBUTE_WEBHOOK_PORT,
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
})
+252
View File
@@ -0,0 +1,252 @@
import asyncio
import logging
import json
import hashlib
import hmac
import base64
from typing import Optional, Dict, Any
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
from app.database.database import get_db
logger = logging.getLogger(__name__)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
try:
signature_parts = signature.strip().split(' ')
if len(signature_parts) < 4:
logger.error(f"Неверный формат подписи YooKassa: {signature}")
return False
version = signature_parts[0]
payment_id = signature_parts[1]
timestamp = signature_parts[2]
received_signature = signature_parts[3]
if version != "v1":
logger.error(f"Неподдерживаемая версия подписи: {version}")
return False
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
expected_signature_1 = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
expected_signature_2 = hmac.new(
secret.encode('utf-8'),
signed_payload_2.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
signed_payload_3 = f"{timestamp}.{body}"
expected_signature_3 = hmac.new(
secret.encode('utf-8'),
signed_payload_3.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
logger.debug(f"Получена подпись: {received_signature}")
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
is_valid = (
hmac.compare_digest(received_signature, expected_signature_1_b64) or
hmac.compare_digest(received_signature, expected_signature_2_b64) or
hmac.compare_digest(received_signature, expected_signature_3_b64)
)
if is_valid:
logger.info("✅ Подпись YooKassa webhook проверена успешно")
else:
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
return False
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
body = await request.text()
if not body:
logger.warning("⚠️ Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
logger.info(f"📄 Body: {body}")
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
logger.info(f"🔐 Получена подпись: {signature}")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
else:
logger.info("✅ Подпись webhook проверена успешно")
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
else:
logger.info("ℹ️ Проверка подписи отключена")
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async for db in get_db():
try:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
"message": "YooKassa webhook endpoint is working",
"method": "GET",
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature',
}
)
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(
"✅ YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
)
logger.info(
"🎯 YooKassa webhook URL: http://%s:%s%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("🛑 YooKassa webhook сервер получил сигнал остановки")
finally:
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
View File
+1
View File
@@ -0,0 +1 @@
# Инициализация админских обработчиков
+647
View File
@@ -0,0 +1,647 @@
import logging
import os
from datetime import datetime
from pathlib import Path
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.backup_service import backup_service
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
class BackupStates(StatesGroup):
waiting_backup_file = State()
waiting_settings_update = State()
def get_backup_main_keyboard(language: str = "ru"):
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"),
InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore")
],
[
InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"),
InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings")
],
[
InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")
]
])
def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5):
keyboard = []
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
page_backups = backups[start_idx:end_idx]
for backup in page_backups:
try:
if backup.get("timestamp"):
dt = datetime.fromisoformat(backup["timestamp"].replace('Z', '+00:00'))
date_str = dt.strftime("%d.%m %H:%M")
else:
date_str = "?"
except:
date_str = "?"
size_str = f"{backup.get('file_size_mb', 0):.1f}MB"
records_str = backup.get('total_records', '?')
button_text = f"📦 {date_str}{size_str}{records_str} записей"
callback_data = f"backup_manage_{backup['filename']}"
keyboard.append([InlineKeyboardButton(text=button_text, callback_data=callback_data)])
if len(backups) > per_page:
total_pages = (len(backups) + per_page - 1) // per_page
nav_row = []
if page > 1:
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"backup_list_page_{page-1}"))
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="noop"))
if page < total_pages:
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"backup_list_page_{page+1}"))
keyboard.append(nav_row)
keyboard.extend([
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_backup_manage_keyboard(backup_filename: str):
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}")
],
[
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}")
],
[
InlineKeyboardButton(text="◀️ К списку", callback_data="backup_list")
]
])
def get_backup_settings_keyboard(settings_obj):
auto_status = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
compression_status = "✅ Включено" if settings_obj.compression_enabled else "❌ Отключено"
logs_status = "✅ Включены" if settings_obj.include_logs else "❌ Отключены"
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"🔄 Автобекапы: {auto_status}",
callback_data="backup_toggle_auto"
)
],
[
InlineKeyboardButton(
text=f"🗜️ Сжатие: {compression_status}",
callback_data="backup_toggle_compression"
)
],
[
InlineKeyboardButton(
text=f"📋 Логи в бекапе: {logs_status}",
callback_data="backup_toggle_logs"
)
],
[
InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")
]
])
@admin_required
@error_handler
async def show_backup_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
settings_obj = await backup_service.get_backup_settings()
status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
text = f"""🗄️ <b>СИСТЕМА БЕКАПОВ</b>
📊 <b>Статус:</b>
• Автобекапы: {status_auto}
• Интервал: {settings_obj.backup_interval_hours} часов
• Хранить: {settings_obj.max_backups_keep} файлов
• Сжатие: {'Да' if settings_obj.compression_enabled else 'Нет'}
📁 <b>Расположение:</b> <code>/app/data/backups</code>
⚡ <b>Доступные операции:</b>
• Создание полного бекапа всех данных
• Восстановление из файла бекапа
• Управление автоматическими бекапами
"""
await callback.message.edit_text(
text,
parse_mode="HTML",
reply_markup=get_backup_main_keyboard(db_user.language)
)
await callback.answer()
@admin_required
@error_handler
async def create_backup_handler(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("🔄 Создание бекапа запущено...")
progress_msg = await callback.message.edit_text(
"🔄 <b>Создание бекапа...</b>\n\n"
"⏳ Экспортируем данные из базы...\n"
"Это может занять несколько минут.",
parse_mode="HTML"
)
# Создаем бекап
success, message, file_path = await backup_service.create_backup(
created_by=db_user.telegram_id,
compress=True
)
if success:
await progress_msg.edit_text(
f"✅ <b>Бекап создан успешно!</b>\n\n{message}",
parse_mode="HTML",
reply_markup=get_backup_main_keyboard(db_user.language)
)
else:
await progress_msg.edit_text(
f"❌ <b>Ошибка создания бекапа</b>\n\n{message}",
parse_mode="HTML",
reply_markup=get_backup_main_keyboard(db_user.language)
)
@admin_required
@error_handler
async def show_backup_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = 1
if callback.data.startswith("backup_list_page_"):
try:
page = int(callback.data.split("_")[-1])
except:
page = 1
backups = await backup_service.get_backup_list()
if not backups:
text = "📦 <b>Список бекапов пуст</b>\n\nБекапы еще не создавались."
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🚀 Создать первый бекап", callback_data="backup_create")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
])
else:
text = f"📦 <b>Список бекапов</b> (всего: {len(backups)})\n\n"
text += "Выберите бекап для управления:"
keyboard = get_backup_list_keyboard(backups, page)
await callback.message.edit_text(
text,
parse_mode="HTML",
reply_markup=keyboard
)
await callback.answer()
@admin_required
@error_handler
async def manage_backup_file(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
filename = callback.data.replace("backup_manage_", "")
backups = await backup_service.get_backup_list()
backup_info = None
for backup in backups:
if backup["filename"] == filename:
backup_info = backup
break
if not backup_info:
await callback.answer("❌ Файл бекапа не найден", show_alert=True)
return
try:
if backup_info.get("timestamp"):
dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00'))
date_str = dt.strftime("%d.%m.%Y %H:%M:%S")
else:
date_str = "Неизвестно"
except:
date_str = "Ошибка формата даты"
text = f"""📦 <b>Информация о бекапе</b>
📄 <b>Файл:</b> <code>{filename}</code>
📅 <b>Создан:</b> {date_str}
💾 <b>Размер:</b> {backup_info.get('file_size_mb', 0):.2f} MB
📊 <b>Таблиц:</b> {backup_info.get('tables_count', '?')}
📈 <b>Записей:</b> {backup_info.get('total_records', '?'):,}
🗜️ <b>Сжатие:</b> {'Да' if backup_info.get('compressed') else 'Нет'}
🗄️ <b>БД:</b> {backup_info.get('database_type', 'unknown')}
"""
if backup_info.get("error"):
text += f"\n⚠️ <b>Ошибка:</b> {backup_info['error']}"
await callback.message.edit_text(
text,
parse_mode="HTML",
reply_markup=get_backup_manage_keyboard(filename)
)
await callback.answer()
@admin_required
@error_handler
async def delete_backup_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
filename = callback.data.replace("backup_delete_", "")
text = f"🗑️ <b>Удаление бекапа</b>\n\n"
text += f"Вы уверены, что хотите удалить бекап?\n\n"
text += f"📄 <code>{filename}</code>\n\n"
text += "⚠️ <b>Это действие нельзя отменить!</b>"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"backup_delete_confirm_{filename}"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
]
])
await callback.message.edit_text(
text,
parse_mode="HTML",
reply_markup=keyboard
)
await callback.answer()
@admin_required
@error_handler
async def delete_backup_execute(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
filename = callback.data.replace("backup_delete_confirm_", "")
success, message = await backup_service.delete_backup(filename)
if success:
await callback.message.edit_text(
f"✅ <b>Бекап удален</b>\n\n{message}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📋 К списку бекапов", callback_data="backup_list")]
])
)
else:
await callback.message.edit_text(
f"❌ <b>Ошибка удаления</b>\n\n{message}",
parse_mode="HTML",
reply_markup=get_backup_manage_keyboard(filename)
)
await callback.answer()
@admin_required
@error_handler
async def restore_backup_start(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
if callback.data.startswith("backup_restore_file_"):
# Восстановление из конкретного файла
filename = callback.data.replace("backup_restore_file_", "")
text = f"📥 <b>Восстановление из бекапа</b>\n\n"
text += f"📄 <b>Файл:</b> <code>{filename}</code>\n\n"
text += "⚠️ <b>ВНИМАНИЕ!</b>\n"
text += "• Процесс может занять несколько минут\n"
text += "• Рекомендуется создать бекап перед восстановлением\n"
text += "• Существующие данные будут дополнены\n\n"
text += "Продолжить восстановление?"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да, восстановить", callback_data=f"backup_restore_execute_{filename}"),
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_clear_{filename}")
],
[
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
]
])
else:
text = """📥 <b>Восстановление из бекапа</b>
📎 Отправьте файл бекапа (.json или .json.gz)
⚠️ <b>ВАЖНО:</b>
• Файл должен быть создан этой системой бекапов
• Процесс может занять несколько минут
• Рекомендуется создать бекап перед восстановлением
💡 Или выберите из существующих бекапов ниже."""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📋 Выбрать из списка", callback_data="backup_list")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")]
])
await state.set_state(BackupStates.waiting_backup_file)
await callback.message.edit_text(
text,
parse_mode="HTML",
reply_markup=keyboard
)
await callback.answer()
@admin_required
@error_handler
async def restore_backup_execute(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
if callback.data.startswith("backup_restore_execute_"):
filename = callback.data.replace("backup_restore_execute_", "")
clear_existing = False
elif callback.data.startswith("backup_restore_clear_"):
filename = callback.data.replace("backup_restore_clear_", "")
clear_existing = True
else:
await callback.answer("❌ Неверный формат команды", show_alert=True)
return
await callback.answer("🔄 Восстановление запущено...")
# Показываем прогресс
action_text = "очисткой и восстановлением" if clear_existing else "восстановлением"
progress_msg = await callback.message.edit_text(
f"📥 <b>Восстановление из бекапа...</b>\n\n"
f"⏳ Работаем с {action_text} данных...\n"
f"📄 Файл: <code>{filename}</code>\n\n"
f"Это может занять несколько минут.",
parse_mode="HTML"
)
backup_path = backup_service.backup_dir / filename
success, message = await backup_service.restore_backup(
str(backup_path),
clear_existing=clear_existing
)
if success:
await progress_msg.edit_text(
f"✅ <b>Восстановление завершено!</b>\n\n{message}",
parse_mode="HTML",
reply_markup=get_backup_main_keyboard(db_user.language)
)
else:
await progress_msg.edit_text(
f"❌ <b>Ошибка восстановления</b>\n\n{message}",
parse_mode="HTML",
reply_markup=get_backup_manage_keyboard(filename)
)
@admin_required
@error_handler
async def handle_backup_file_upload(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
if not message.document:
await message.answer(
"❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
])
)
return
document = message.document
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
await message.answer(
"❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
])
)
return
if document.file_size > 50 * 1024 * 1024:
await message.answer(
"❌ Файл слишком большой (максимум 50MB)",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
])
)
return
try:
file = await message.bot.get_file(document.file_id)
temp_path = backup_service.backup_dir / f"uploaded_{document.file_name}"
await message.bot.download_file(file.file_path, temp_path)
text = f"""📥 <b>Файл загружен</b>
📄 <b>Имя:</b> <code>{document.file_name}</code>
💾 <b>Размер:</b> {document.file_size / 1024 / 1024:.2f} MB
⚠️ <b>ВНИМАНИЕ!</b>
Процесс восстановления изменит данные в базе.
Рекомендуется создать бекап перед восстановлением.
Продолжить?"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Восстановить", callback_data=f"backup_restore_uploaded_{temp_path.name}"),
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_uploaded_clear_{temp_path.name}")
],
[
InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")
]
])
await message.answer(text, parse_mode="HTML", reply_markup=keyboard)
await state.clear()
except Exception as e:
logger.error(f"Ошибка загрузки файла бекапа: {e}")
await message.answer(
f"❌ Ошибка загрузки файла: {str(e)}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
])
)
@admin_required
@error_handler
async def show_backup_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
settings_obj = await backup_service.get_backup_settings()
text = f"""⚙️ <b>Настройки системы бекапов</b>
🔄 <b>Автоматические бекапы:</b>
• Статус: {'✅ Включены' if settings_obj.auto_backup_enabled else '❌ Отключены'}
• Интервал: {settings_obj.backup_interval_hours} часов
• Время запуска: {settings_obj.backup_time}
📦 <b>Хранение:</b>
• Максимум файлов: {settings_obj.max_backups_keep}
• Сжатие: {'✅ Включено' if settings_obj.compression_enabled else '❌ Отключено'}
• Включать логи: {'✅ Да' if settings_obj.include_logs else '❌ Нет'}
📁 <b>Расположение:</b> <code>{settings_obj.backup_location}</code>
"""
await callback.message.edit_text(
text,
parse_mode="HTML",
reply_markup=get_backup_settings_keyboard(settings_obj)
)
await callback.answer()
@admin_required
@error_handler
async def toggle_backup_setting(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
settings_obj = await backup_service.get_backup_settings()
if callback.data == "backup_toggle_auto":
new_value = not settings_obj.auto_backup_enabled
await backup_service.update_backup_settings(auto_backup_enabled=new_value)
status = "включены" if new_value else "отключены"
await callback.answer(f"Автобекапы {status}")
elif callback.data == "backup_toggle_compression":
new_value = not settings_obj.compression_enabled
await backup_service.update_backup_settings(compression_enabled=new_value)
status = "включено" if new_value else "отключено"
await callback.answer(f"Сжатие {status}")
elif callback.data == "backup_toggle_logs":
new_value = not settings_obj.include_logs
await backup_service.update_backup_settings(include_logs=new_value)
status = "включены" if new_value else "отключены"
await callback.answer(f"Логи в бекапе {status}")
await show_backup_settings(callback, db_user, db)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_backup_panel,
F.data == "backup_panel"
)
dp.callback_query.register(
create_backup_handler,
F.data == "backup_create"
)
dp.callback_query.register(
show_backup_list,
F.data.startswith("backup_list")
)
dp.callback_query.register(
manage_backup_file,
F.data.startswith("backup_manage_")
)
dp.callback_query.register(
delete_backup_confirm,
F.data.startswith("backup_delete_") & ~F.data.startswith("backup_delete_confirm_")
)
dp.callback_query.register(
delete_backup_execute,
F.data.startswith("backup_delete_confirm_")
)
dp.callback_query.register(
restore_backup_start,
F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_")
)
dp.callback_query.register(
restore_backup_execute,
F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_")
)
dp.callback_query.register(
show_backup_settings,
F.data == "backup_settings"
)
dp.callback_query.register(
toggle_backup_setting,
F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"])
)
dp.message.register(
handle_backup_file_upload,
BackupStates.waiting_backup_file
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+449
View File
@@ -0,0 +1,449 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.admin import (
get_admin_main_keyboard,
get_admin_users_submenu_keyboard,
get_admin_promo_submenu_keyboard,
get_admin_communications_submenu_keyboard,
get_admin_support_submenu_keyboard,
get_admin_settings_submenu_keyboard,
get_admin_system_submenu_keyboard
)
from app.localization.texts import get_texts
from app.handlers.admin import support_settings as support_settings_handlers
from app.utils.decorators import admin_required, error_handler
from app.services.support_settings_service import SupportSettingsService
from app.database.crud.rules import clear_all_rules, get_rules_statistics
from app.localization.texts import clear_rules_cache
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.database.crud.ticket import TicketCRUD
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_admin_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
admin_text = texts.ADMIN_PANEL
try:
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
stats = await remnawave_service.get_system_statistics()
system_stats = stats.get("system", {})
users_online = system_stats.get("users_online", 0)
users_today = system_stats.get("users_last_day", 0)
users_week = system_stats.get("users_last_week", 0)
admin_text = admin_text.replace(
"\n\nВыберите раздел для управления:",
(
f"\n\n- 🟢 Онлайн сейчас: {users_online}"
f"\n- 📅 Онлайн сегодня: {users_today}"
f"\n- 🗓️ На этой неделе: {users_week}"
"\n\nВыберите раздел для управления:"
),
)
except Exception as e:
logger.error(f"Не удалось получить статистику Remnawave для админ-панели: {e}")
await callback.message.edit_text(
admin_text,
reply_markup=get_admin_main_keyboard(db_user.language)
)
await callback.answer()
@admin_required
@error_handler
async def show_users_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
await callback.answer()
@admin_required
@error_handler
async def show_promo_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
await callback.answer()
@admin_required
@error_handler
async def show_communications_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
await callback.answer()
@admin_required
@error_handler
async def show_support_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# Moderators have access only to tickets and not to settings
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
kb = get_admin_support_submenu_keyboard(db_user.language)
if is_moderator_only:
# Rebuild keyboard to include only tickets and back to main menu
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
await callback.message.edit_text(
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
if is_moderator_only
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
),
reply_markup=kb,
parse_mode="Markdown"
)
await callback.answer()
# Moderator panel entry (from main menu quick button)
async def show_moderator_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
])
await callback.message.edit_text(
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑‍⚖️ <b>Модерация поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
parse_mode="HTML",
reply_markup=kb
)
await callback.answer()
@admin_required
@error_handler
async def show_support_audit(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# pagination
page = 1
if callback.data.startswith("admin_support_audit_page_"):
try:
page = int(callback.data.split("_")[-1])
except Exception:
page = 1
per_page = 10
total = await TicketCRUD.count_support_audit(db)
total_pages = max(1, (total + per_page - 1) // per_page)
if page < 1:
page = 1
if page > total_pages:
page = total_pages
offset = (page - 1) * per_page
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
if not logs:
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
else:
for log in logs:
role = (
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
if getattr(log, 'is_moderator', False)
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
)
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
action_map = {
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
}
action_text = action_map.get(log.action, log.action)
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
details = log.details or {}
extra = ""
if log.action == 'block_user_timed' and 'minutes' in details:
extra = f" ({details['minutes']} мин)"
lines.append(f"{ts}{role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
# keyboard with pagination
nav_row = []
if total_pages > 1:
if page > 1:
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}"))
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page"))
if page < total_pages:
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}"))
kb_rows = []
if nav_row:
kb_rows.append(nav_row)
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
await callback.answer()
@admin_required
@error_handler
async def show_settings_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
await callback.answer()
@admin_required
@error_handler
async def show_system_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
await callback.answer()
@admin_required
@error_handler
async def clear_rules_command(
message: types.Message,
db_user: User,
db: AsyncSession
):
try:
stats = await get_rules_statistics(db)
if stats['total_active'] == 0:
await message.reply(
"️ <b>Правила уже очищены</b>\n\n"
"В системе нет активных правил. Используются стандартные правила по умолчанию."
)
return
success = await clear_all_rules(db, db_user.language)
if success:
clear_rules_cache()
await message.reply(
f"✅ <b>Правила успешно очищены!</b>\n\n"
f"📊 <b>Статистика:</b>\n"
f"• Очищено правил: {stats['total_active']}\n"
f"• Язык: {db_user.language}\n"
f"• Выполнил: {db_user.full_name}\n\n"
f"Теперь используются стандартные правила по умолчанию."
)
logger.info(f"Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})")
else:
await message.reply(
"⚠️ <b>Нет правил для очистки</b>\n\n"
"Активные правила не найдены."
)
except Exception as e:
logger.error(f"Ошибка при очистке правил командой: {e}")
await message.reply(
"❌ <b>Ошибка при очистке правил</b>\n\n"
f"Произошла ошибка: {str(e)}\n"
"Попробуйте через админ-панель или повторите позже."
)
@admin_required
@error_handler
async def rules_stats_command(
message: types.Message,
db_user: User,
db: AsyncSession
):
try:
stats = await get_rules_statistics(db)
if 'error' in stats:
await message.reply(f"❌ Ошибка получения статистики: {stats['error']}")
return
text = f"📊 <b>Статистика правил сервиса</b>\n\n"
text += f"📋 <b>Общая информация:</b>\n"
text += f"• Активных правил: {stats['total_active']}\n"
text += f"• Всего в истории: {stats['total_all_time']}\n"
text += f"• Поддерживаемых языков: {stats['total_languages']}\n\n"
if stats['languages']:
text += f"🌐 <b>По языкам:</b>\n"
for lang, lang_stats in stats['languages'].items():
text += f"• <code>{lang}</code>: {lang_stats['active_count']} правил, "
text += f"{lang_stats['content_length']} символов\n"
if lang_stats['last_updated']:
text += f" Обновлено: {lang_stats['last_updated'].strftime('%d.%m.%Y %H:%M')}\n"
else:
text += "ℹ️ Активных правил нет - используются правила по умолчанию"
await message.reply(text)
except Exception as e:
logger.error(f"Ошибка при получении статистики правил: {e}")
await message.reply(
f"❌ <b>Ошибка получения статистики</b>\n\n"
f"Произошла ошибка: {str(e)}"
)
@admin_required
@error_handler
async def admin_commands_help(
message: types.Message,
db_user: User,
db: AsyncSession
):
help_text = """
🔧 <b>Доступные админские команды:</b>
<b>📋 Управление правилами:</b>
<code>/clear_rules</code> - очистить все правила
<code>/rules_stats</code> - статистика правил
<b> Справка:</b>
<code>/admin_help</code> - это сообщение
<b>📱 Панель управления:</b>
Используйте кнопку "Админ панель" в главном меню для полного доступа ко всем функциям.
<b> Важно:</b>
Все команды логируются и требуют админских прав.
"""
await message.reply(help_text)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_admin_panel,
F.data == "admin_panel"
)
dp.callback_query.register(
show_users_submenu,
F.data == "admin_submenu_users"
)
dp.callback_query.register(
show_promo_submenu,
F.data == "admin_submenu_promo"
)
dp.callback_query.register(
show_communications_submenu,
F.data == "admin_submenu_communications"
)
dp.callback_query.register(
show_support_submenu,
F.data == "admin_submenu_support"
)
dp.callback_query.register(
show_support_audit,
F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_")
)
dp.callback_query.register(
show_settings_submenu,
F.data == "admin_submenu_settings"
)
dp.callback_query.register(
show_system_submenu,
F.data == "admin_submenu_system"
)
dp.callback_query.register(
show_moderator_panel,
F.data == "moderator_panel"
)
# Support settings module
support_settings_handlers.register_handlers(dp)
dp.message.register(
clear_rules_command,
Command("clear_rules")
)
dp.message.register(
rules_stats_command,
Command("rules_stats")
)
dp.message.register(
admin_commands_help,
Command("admin_help")
)
+434
View File
@@ -0,0 +1,434 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.maintenance_service import maintenance_service
from app.keyboards.admin import get_maintenance_keyboard, get_admin_main_keyboard
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
class MaintenanceStates(StatesGroup):
waiting_for_reason = State()
waiting_for_notification_message = State()
@admin_required
@error_handler
async def show_maintenance_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
texts = get_texts(db_user.language)
status_info = maintenance_service.get_status_info()
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
panel_status = await rw_service.get_panel_status_summary()
except Exception as e:
logger.error(f"Ошибка получения статуса панели: {e}")
panel_status = {"description": "❓ Не удалось проверить", "has_issues": True}
status_emoji = "🔧" if status_info["is_active"] else ""
status_text = "Включен" if status_info["is_active"] else "Выключен"
api_emoji = "" if status_info["api_status"] else ""
api_text = "Доступно" if status_info["api_status"] else "Недоступно"
monitoring_emoji = "🔄" if status_info["monitoring_active"] else "⏹️"
monitoring_text = "Запущен" if status_info["monitoring_active"] else "Остановлен"
enabled_info = ""
if status_info["is_active"] and status_info["enabled_at"]:
enabled_time = status_info["enabled_at"].strftime("%d.%m.%Y %H:%M:%S")
enabled_info = f"\n📅 <b>Включен:</b> {enabled_time}"
if status_info["reason"]:
enabled_info += f"\n📝 <b>Причина:</b> {status_info['reason']}"
last_check_info = ""
if status_info["last_check"]:
last_check_time = status_info["last_check"].strftime("%H:%M:%S")
last_check_info = f"\n🕐 <b>Последняя проверка:</b> {last_check_time}"
failures_info = ""
if status_info["consecutive_failures"] > 0:
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
panel_info = f"\n🌐 <b>Панель Remnawave:</b> {panel_status['description']}"
if panel_status.get("response_time"):
panel_info += f"\n⚡ <b>Время отклика:</b> {panel_status['response_time']}с"
message_text = f"""
🔧 <b>Управление техническими работами</b>
{status_emoji} <b>Режим техработ:</b> {status_text}
{api_emoji} <b>API Remnawave:</b> {api_text}
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
<b>Интервал проверки:</b> {status_info['check_interval']}с
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
{panel_info}
{enabled_info}
{last_check_info}
{failures_info}
<i>В режиме техработ обычные пользователи не могут использовать бота. Администраторы имеют полный доступ.</i>
"""
await callback.message.edit_text(
message_text,
reply_markup=get_maintenance_keyboard(
db_user.language,
status_info["is_active"],
status_info["monitoring_active"],
panel_status.get("has_issues", False)
)
)
await callback.answer()
@admin_required
@error_handler
async def toggle_maintenance_mode(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
is_active = maintenance_service.is_maintenance_active()
if is_active:
success = await maintenance_service.disable_maintenance()
if success:
await callback.answer("Режим техработ выключен", show_alert=True)
else:
await callback.answer("Ошибка выключения режима техработ", show_alert=True)
else:
await state.set_state(MaintenanceStates.waiting_for_reason)
await callback.message.edit_text(
"🔧 <b>Включение режима техработ</b>\n\nВведите причину включения техработ или отправьте /skip для пропуска:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_maintenance_reason(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
current_state = await state.get_state()
if current_state != MaintenanceStates.waiting_for_reason:
return
reason = None
if message.text and message.text != "/skip":
reason = message.text[:200]
success = await maintenance_service.enable_maintenance(reason=reason, auto=False)
if success:
response_text = "Режим техработ включен"
if reason:
response_text += f"\nПричина: {reason}"
else:
response_text = "Ошибка включения режима техработ"
await message.answer(response_text)
await state.clear()
status_info = maintenance_service.get_status_info()
await message.answer(
"Вернуться к панели управления техработами:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def toggle_monitoring(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
status_info = maintenance_service.get_status_info()
if status_info["monitoring_active"]:
success = await maintenance_service.stop_monitoring()
message = "Мониторинг остановлен" if success else "Ошибка остановки мониторинга"
else:
success = await maintenance_service.start_monitoring()
message = "Мониторинг запущен" if success else "Ошибка запуска мониторинга"
await callback.answer(message, show_alert=True)
await show_maintenance_panel(callback, db_user, db, None)
@admin_required
@error_handler
async def force_api_check(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("Проверка API...", show_alert=False)
check_result = await maintenance_service.force_api_check()
if check_result["success"]:
status_text = "доступно" if check_result["api_available"] else "недоступно"
message = f"API {status_text}\nВремя ответа: {check_result['response_time']}с"
else:
message = f"Ошибка проверки: {check_result.get('error', 'Неизвестная ошибка')}"
await callback.message.answer(message)
await show_maintenance_panel(callback, db_user, db, None)
@admin_required
@error_handler
async def check_panel_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("Проверка статуса панели...", show_alert=False)
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
status_data = await rw_service.check_panel_health()
status_text = {
"online": "🟢 Панель работает нормально",
"offline": "🔴 Панель недоступна",
"degraded": "🟡 Панель работает со сбоями"
}.get(status_data["status"], "❓ Статус неизвестен")
message_parts = [
f"🌐 <b>Статус панели Remnawave</b>\n",
f"{status_text}",
f"⚡ Время отклика: {status_data.get('response_time', 0)}с",
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
]
if status_data.get("api_error"):
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
message = "\n".join(message_parts)
await callback.message.answer(message, parse_mode="HTML")
except Exception as e:
await callback.message.answer(f"❌ Ошибка проверки статуса: {str(e)}")
@admin_required
@error_handler
async def send_manual_notification(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
await state.set_state(MaintenanceStates.waiting_for_notification_message)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="🟢 Онлайн", callback_data="manual_notify_online"),
types.InlineKeyboardButton(text="🔴 Офлайн", callback_data="manual_notify_offline")
],
[
types.InlineKeyboardButton(text="🟡 Проблемы", callback_data="manual_notify_degraded"),
types.InlineKeyboardButton(text="🔧 Обслуживание", callback_data="manual_notify_maintenance")
],
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
await callback.message.edit_text(
"📢 <b>Ручная отправка уведомления</b>\n\nВыберите статус для уведомления:",
reply_markup=keyboard
)
@admin_required
@error_handler
async def handle_manual_notification(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
status_map = {
"manual_notify_online": "online",
"manual_notify_offline": "offline",
"manual_notify_degraded": "degraded",
"manual_notify_maintenance": "maintenance"
}
status = status_map.get(callback.data)
if not status:
await callback.answer("Неизвестный статус")
return
await state.update_data(notification_status=status)
status_names = {
"online": "🟢 Онлайн",
"offline": "🔴 Офлайн",
"degraded": "🟡 Проблемы",
"maintenance": "🔧 Обслуживание"
}
await callback.message.edit_text(
f"📢 <b>Отправка уведомления: {status_names[status]}</b>\n\n"
f"Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def process_notification_message(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
current_state = await state.get_state()
if current_state != MaintenanceStates.waiting_for_notification_message:
return
data = await state.get_data()
status = data.get("notification_status")
if not status:
await message.answer("Ошибка: статус не выбран")
await state.clear()
return
notification_message = ""
if message.text and message.text != "/skip":
notification_message = message.text[:300]
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
success = await rw_service.send_manual_status_notification(
message.bot,
status,
notification_message
)
if success:
await message.answer("✅ Уведомление отправлено")
else:
await message.answer("❌ Ошибка отправки уведомления")
except Exception as e:
logger.error(f"Ошибка отправки ручного уведомления: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
await state.clear()
await message.answer(
"Вернуться к панели техработ:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def back_to_admin_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.ADMIN_PANEL,
reply_markup=get_admin_main_keyboard(db_user.language)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_maintenance_panel,
F.data == "maintenance_panel"
)
dp.callback_query.register(
toggle_maintenance_mode,
F.data == "maintenance_toggle"
)
dp.callback_query.register(
toggle_monitoring,
F.data == "maintenance_monitoring"
)
dp.callback_query.register(
force_api_check,
F.data == "maintenance_check_api"
)
dp.callback_query.register(
check_panel_status,
F.data == "maintenance_check_panel"
)
dp.callback_query.register(
send_manual_notification,
F.data == "maintenance_manual_notify"
)
dp.callback_query.register(
handle_manual_notification,
F.data.startswith("manual_notify_")
)
dp.callback_query.register(
back_to_admin_panel,
F.data == "admin_panel"
)
dp.message.register(
process_maintenance_reason,
MaintenanceStates.waiting_for_reason
)
dp.message.register(
process_notification_message,
MaintenanceStates.waiting_for_notification_message
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.privacy_policy_service import PrivacyPolicyService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
has_content = bool(policy and policy.content and policy.content.strip())
description = texts.t(
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
"Политика конфиденциальности отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
"⚠️ Показ политики выключен или текст отсутствует.",
)
if policy and policy.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
"✅ Политика активна и показывается пользователям.",
)
elif policy and policy.is_enabled:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = policy.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PRIVACY_POLICY_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
actions_prompt = texts.t(
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_privacy_policy_view",
)
])
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
"✅ Включить показ",
)
if policy and policy.is_enabled:
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_privacy_policy_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
@admin_required
@error_handler
async def show_privacy_policy_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ политики конфиденциальности: %s",
db_user.telegram_id,
"enabled" if updated_policy.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
if updated_policy.is_enabled
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if policy and policy.content:
preview = policy.content.strip()[:400]
if len(policy.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
callback_data="admin_privacy_policy_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_privacy_policy)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def process_privacy_policy_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_TOO_LONG",
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст политики конфиденциальности (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PRIVACY_POLICY_SAVED",
"✅ Политика конфиденциальности обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
if not policy or not policy.content or not policy.content.strip():
await callback.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
"Текст политики пока не задан.",
),
show_alert=True,
)
return
content = policy.content.strip()
truncated = False
max_length = 3800
if len(content) > max_length:
content = content[: max_length - 3] + "..."
truncated = True
header = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
"👀 <b>Текущий текст политики</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{content}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_privacy_policy_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_privacy_policy.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_privacy_policy_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_privacy_policy_management,
F.data == "admin_privacy_policy",
)
dp.callback_query.register(
toggle_privacy_policy,
F.data == "admin_privacy_policy_toggle",
)
dp.callback_query.register(
start_edit_privacy_policy,
F.data == "admin_privacy_policy_edit",
)
dp.callback_query.register(
cancel_edit_privacy_policy,
F.data == "admin_privacy_policy_cancel",
)
dp.callback_query.register(
view_privacy_policy,
F.data == "admin_privacy_policy_view",
)
dp.callback_query.register(
show_privacy_policy_html_help,
F.data == "admin_privacy_policy_help",
)
dp.message.register(
process_privacy_policy_edit,
AdminStates.editing_privacy_policy,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.public_offer_service import PublicOfferService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
normalized_language = PublicOfferService.normalize_language(db_user.language)
has_content = bool(offer and offer.content and offer.content.strip())
description = texts.t(
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
"Публичная оферта отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
"⚠️ Показ оферты выключен или текст отсутствует.",
)
if offer and offer.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
"✅ Оферта активна и показывается пользователям.",
)
elif offer and offer.is_enabled:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = offer.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PUBLIC_OFFER_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
actions_prompt = texts.t(
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_public_offer_view",
)
])
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
"✅ Включить показ",
)
if offer and offer.is_enabled:
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_public_offer_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
@admin_required
@error_handler
async def show_public_offer_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ публичной оферты: %s",
db_user.telegram_id,
"enabled" if updated_offer.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
if updated_offer.is_enabled
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if offer and offer.content:
preview = offer.content.strip()[:400]
if len(offer.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
callback_data="admin_public_offer_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_public_offer)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
"Редактирование оферты отменено.",
)
)
@admin_required
@error_handler
async def process_public_offer_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_TOO_LONG",
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PublicOfferService.save_offer(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст публичной оферты (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PUBLIC_OFFER_SAVED",
"✅ Публичная оферта обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
if not offer or not offer.content or not offer.content.strip():
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
content = offer.content.strip()
max_length = 3800
pages = PublicOfferService.split_content_into_pages(
content,
max_length=max_length,
)
if not pages:
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
preview = pages[0]
truncated = len(pages) > 1
header = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
"👀 <b>Текущий текст оферты</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{preview}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_public_offer_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_public_offer.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_public_offer_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_public_offer_management,
F.data == "admin_public_offer",
)
dp.callback_query.register(
toggle_public_offer,
F.data == "admin_public_offer_toggle",
)
dp.callback_query.register(
start_edit_public_offer,
F.data == "admin_public_offer_edit",
)
dp.callback_query.register(
cancel_edit_public_offer,
F.data == "admin_public_offer_cancel",
)
dp.callback_query.register(
view_public_offer,
F.data == "admin_public_offer_view",
)
dp.callback_query.register(
show_public_offer_html_help,
F.data == "admin_public_offer_help",
)
dp.message.register(
process_public_offer_edit,
AdminStates.editing_public_offer,
)
+216
View File
@@ -0,0 +1,216 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
import datetime
from app.config import settings
from app.database.models import User
from app.localization.texts import get_texts
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
from app.database.crud.user import get_user_by_id
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_referral_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
stats = await get_referral_statistics(db)
avg_per_referrer = 0
if stats.get('active_referrers', 0) > 0:
avg_per_referrer = stats.get('total_paid_kopeks', 0) / stats['active_referrers']
current_time = datetime.datetime.now().strftime("%H:%M:%S")
text = f"""
🤝 <b>Реферальная статистика</b>
<b>Общие показатели:</b>
- Пользователей с рефералами: {stats.get('users_with_referrals', 0)}
- Активных рефереров: {stats.get('active_referrers', 0)}
- Выплачено всего: {settings.format_price(stats.get('total_paid_kopeks', 0))}
<b>За период:</b>
- Сегодня: {settings.format_price(stats.get('today_earnings_kopeks', 0))}
- За неделю: {settings.format_price(stats.get('week_earnings_kopeks', 0))}
- За месяц: {settings.format_price(stats.get('month_earnings_kopeks', 0))}
<b>Средние показатели:</b>
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
<b>Топ-5 рефереров:</b>
"""
top_referrers = stats.get('top_referrers', [])
if top_referrers:
for i, referrer in enumerate(top_referrers[:5], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
user_id = referrer.get('user_id', 'N/A')
if count > 0:
text += f"{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n"
else:
logger.warning(f"Реферер {user_id} имеет {count} рефералов, но есть в топе")
else:
text += "Нет данных\n"
text += f"""
<b>Настройки реферальной системы:</b>
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
<i>🕐 Обновлено: {current_time}</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("Обновлено")
except Exception as edit_error:
if "message is not modified" in str(edit_error):
await callback.answer("Данные актуальны")
else:
logger.error(f"Ошибка редактирования сообщения: {edit_error}")
await callback.answer("Ошибка обновления")
except Exception as e:
logger.error(f"Ошибка в show_referral_statistics: {e}", exc_info=True)
current_time = datetime.datetime.now().strftime("%H:%M:%S")
text = f"""
🤝 <b>Реферальная статистика</b>
<b>Ошибка загрузки данных</b>
<b>Текущие настройки:</b>
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
except:
pass
await callback.answer("Произошла ошибка при загрузке статистики")
@admin_required
@error_handler
async def show_top_referrers(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
stats = await get_referral_statistics(db)
top_referrers = stats.get('top_referrers', [])
text = "🏆 <b>Топ рефереров</b>\n\n"
if top_referrers:
for i, referrer in enumerate(top_referrers[:20], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
display_name = referrer.get('display_name', 'N/A')
username = referrer.get('username', '')
telegram_id = referrer.get('telegram_id', 'N/A')
if username:
display_text = f"@{username} (ID{telegram_id})"
elif display_name and display_name != f"ID{telegram_id}":
display_text = f"{display_name} (ID{telegram_id})"
else:
display_text = f"ID{telegram_id}"
emoji = ""
if i == 1:
emoji = "🥇 "
elif i == 2:
emoji = "🥈 "
elif i == 3:
emoji = "🥉 "
text += f"{emoji}{i}. {display_text}\n"
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
else:
text += "Нет данных о рефererах\n"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
await callback.answer("Ошибка загрузки топа рефереров")
@admin_required
@error_handler
async def show_referral_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = f"""
<b>Настройки реферальной системы</b>
<b>Бонусы и награды:</b>
Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
<b>Уведомления:</b>
Статус: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
Попытки отправки: {getattr(settings, 'REFERRAL_NOTIFICATION_RETRY_ATTEMPTS', 3)}
<i>💡 Для изменения настроек отредактируйте файл .env и перезапустите бота</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
import logging
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.keyboards.admin import (
get_admin_report_result_keyboard,
get_admin_reports_keyboard,
)
from app.localization.texts import get_texts
from app.services.reporting_service import (
ReportPeriod,
ReportingServiceError,
reporting_service,
)
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_reports_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await callback.message.edit_text(
"📊 <b>Отчеты</b>\n\n"
"Выберите период, чтобы отправить отчет в админский топик.",
reply_markup=get_admin_reports_keyboard(db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def send_daily_report(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await _send_report(callback, ReportPeriod.DAILY, db_user.language)
@admin_required
@error_handler
async def send_weekly_report(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await _send_report(callback, ReportPeriod.WEEKLY, db_user.language)
@admin_required
@error_handler
async def send_monthly_report(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await _send_report(callback, ReportPeriod.MONTHLY, db_user.language)
async def _send_report(
callback: types.CallbackQuery,
period: ReportPeriod,
language: str,
) -> None:
try:
report_text = await reporting_service.send_report(period, send_to_topic=True)
except ReportingServiceError as exc:
logger.warning("Не удалось отправить отчет: %s", exc)
await callback.answer(str(exc), show_alert=True)
return
except Exception as exc: # noqa: BLE001
logger.error("Непредвиденная ошибка при отправке отчета: %s", exc)
await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True)
return
await callback.message.answer(
report_text,
reply_markup=get_admin_report_result_keyboard(language),
)
await callback.answer("Отчет отправлен в топик")
@admin_required
@error_handler
async def close_report_message(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
texts = get_texts(db_user.language)
try:
await callback.message.delete()
except (TelegramBadRequest, TelegramForbiddenError) as exc:
logger.warning("Не удалось закрыть сообщение отчета: %s", exc)
await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True)
return
await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт."))
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(show_reports_menu, F.data == "admin_reports")
dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily")
dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly")
dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly")
dp.callback_query.register(close_report_message, F.data == "admin_close_report")
+349
View File
@@ -0,0 +1,349 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import AdminStates
from app.database.models import User
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
from app.database.crud.rules import get_current_rules_content, create_or_update_rules, clear_all_rules
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_rules_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
📋 <b>Управление правилами сервиса</b>
Текущие правила показываются пользователям при регистрации и в главном меню.
Выберите действие:
"""
keyboard = [
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
[types.InlineKeyboardButton(text="️ Помощь по HTML", callback_data="admin_rules_help")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def view_current_rules(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
current_rules = await get_current_rules_content(db, db_user.language)
is_valid, error_msg = validate_html_tags(current_rules)
warning = ""
if not is_valid:
warning = f"\n\n⚠️ <b>Внимание:</b> В правилах найдена ошибка HTML: {error_msg}"
await callback.message.edit_text(
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}{warning}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
[types.InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_clear_rules")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
])
)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка при показе правил: {e}")
await callback.message.edit_text(
"❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
])
)
await callback.answer()
@admin_required
@error_handler
async def start_edit_rules(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
try:
current_rules = await get_current_rules_content(db, db_user.language)
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
text = (
"✏️ <b>Редактирование правил</b>\n\n"
f"<b>Текущие правила:</b>\n<code>{preview}</code>\n\n"
"Отправьте новый текст правил сервиса.\n\n"
"<i>Поддерживается HTML разметка. Все теги будут проверены перед сохранением.</i>\n\n"
"💡 <b>Совет:</b> Нажмите /html_help для просмотра поддерживаемых тегов"
)
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="️ HTML помощь", callback_data="admin_rules_help")],
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
])
)
await state.set_state(AdminStates.editing_rules_page)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка при начале редактирования правил: {e}")
await callback.answer("❌ Ошибка при загрузке правил для редактирования", show_alert=True)
@admin_required
@error_handler
async def process_rules_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
new_rules = message.text
if len(new_rules) > 4000:
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
return
is_valid, error_msg = validate_html_tags(new_rules)
if not is_valid:
await message.answer(
f"❌ <b>Ошибка в HTML разметке:</b>\n{error_msg}\n\n"
f"Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n"
f"💡 Используйте /html_help для просмотра правильного синтаксиса",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="️ HTML помощь", callback_data="admin_rules_help")],
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
])
)
return
try:
preview_text = f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
preview_text += f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
preview_text += f"Сохранить изменения?"
if len(preview_text) > 4000:
preview_text = (
"📋 <b>Предварительный просмотр новых правил:</b>\n\n"
f"{new_rules[:500]}...\n\n"
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
f"Текст правил: {len(new_rules)} символов\n"
f"Сохранить изменения?"
)
await message.answer(
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
]
])
)
await state.update_data(new_rules=new_rules)
except Exception as e:
logger.error(f"Ошибка при показе превью правил: {e}")
await message.answer(
"⚠️ <b>Подтверждение сохранения правил</b>\n\n"
f"Новые правила готовы к сохранению ({len(new_rules)} символов).\n"
f"HTML теги проверены и корректны.\n\n"
f"Сохранить изменения?",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
]
])
)
await state.update_data(new_rules=new_rules)
@admin_required
@error_handler
async def save_rules(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
new_rules = data.get('new_rules')
if not new_rules:
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
return
is_valid, error_msg = validate_html_tags(new_rules)
if not is_valid:
await callback.message.edit_text(
f"❌ <b>Ошибка при сохранении:</b>\n{error_msg}\n\n"
f"Правила не были сохранены из-за ошибок в HTML разметке.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_edit_rules")],
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
])
)
await state.clear()
await callback.answer()
return
try:
await create_or_update_rules(
db=db,
content=new_rules,
language=db_user.language
)
from app.localization.texts import clear_rules_cache
clear_rules_cache()
from app.localization.texts import refresh_rules_cache
await refresh_rules_cache(db_user.language)
await callback.message.edit_text(
"✅ <b>Правила сервиса успешно обновлены!</b>\n\n"
"✓ Новые правила сохранены в базе данных\n"
"✓ HTML теги проверены и корректны\n"
"✓ Кеш правил очищен и обновлен\n"
"✓ Правила будут показываться пользователям\n\n"
f"📊 Размер текста: {len(new_rules)} символов",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="👀 Просмотреть", callback_data="admin_view_rules")],
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
])
)
await state.clear()
logger.info(f"Правила сервиса обновлены администратором {db_user.telegram_id}")
await callback.answer()
except Exception as e:
logger.error(f"Ошибка сохранения правил: {e}")
await callback.message.edit_text(
"❌ <b>Ошибка при сохранении правил</b>\n\n"
"Произошла ошибка при записи в базу данных. Попробуйте еще раз.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_save_rules")],
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
])
)
await callback.answer()
@admin_required
@error_handler
async def clear_rules_confirmation(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🗑️ <b>Очистка правил сервиса</b>\n\n"
"⚠️ <b>ВНИМАНИЕ!</b> Вы собираетесь полностью удалить все правила сервиса.\n\n"
"После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n"
"Это действие нельзя отменить. Продолжить?",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_confirm_clear_rules"),
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
]
])
)
await callback.answer()
@admin_required
@error_handler
async def confirm_clear_rules(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
await clear_all_rules(db, db_user.language)
from app.localization.texts import clear_rules_cache
clear_rules_cache()
await callback.message.edit_text(
"✅ <b>Правила успешно очищены!</b>\n\n"
"✓ Все пользовательские правила удалены\n"
"✓ Теперь используются стандартные правила\n"
"✓ Кеш правил очищен\n\n"
"Пользователи будут видеть правила по умолчанию.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📝 Создать новые", callback_data="admin_edit_rules")],
[types.InlineKeyboardButton(text="👀 Посмотреть текущие", callback_data="admin_view_rules")],
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
])
)
logger.info(f"Правила очищены администратором {db_user.telegram_id}")
await callback.answer()
except Exception as e:
logger.error(f"Ошибка при очистке правил: {e}")
await callback.answer("❌ Ошибка при очистке правил", show_alert=True)
@admin_required
@error_handler
async def show_html_help(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
help_text = get_html_help_text()
await callback.message.edit_text(
f"️ <b>Справка по HTML форматированию</b>\n\n{help_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
])
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_rules_management, F.data == "admin_rules")
dp.callback_query.register(view_current_rules, F.data == "admin_view_rules")
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
dp.callback_query.register(clear_rules_confirmation, F.data == "admin_clear_rules")
dp.callback_query.register(confirm_clear_rules, F.data == "admin_confirm_clear_rules")
dp.callback_query.register(show_html_help, F.data == "admin_rules_help")
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
File diff suppressed because it is too large Load Diff
+396
View File
@@ -0,0 +1,396 @@
import logging
from datetime import datetime, timedelta
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.admin import get_admin_statistics_keyboard, get_period_selection_keyboard
from app.localization.texts import get_texts
from app.services.user_service import UserService
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
from app.database.crud.referral import get_referral_statistics
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_percentage
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_statistics_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
📊 <b>Статистика системы</b>
Выберите раздел для просмотра статистики:
"""
await callback.message.edit_text(
text,
reply_markup=get_admin_statistics_keyboard(db_user.language)
)
await callback.answer()
@admin_required
@error_handler
async def show_users_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_service = UserService()
stats = await user_service.get_user_statistics(db)
total_users = stats['total_users']
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
current_time = format_datetime(datetime.utcnow())
text = f"""
👥 <b>Статистика пользователей</b>
<b>Общие показатели:</b>
- Всего зарегистрировано: {stats['total_users']}
- Активных: {stats['active_users']} ({active_rate})
- Заблокированных: {stats['blocked_users']}
<b>Новые регистрации:</b>
- Сегодня: {stats['new_today']}
- За неделю: {stats['new_week']}
- За месяц: {stats['new_month']}
<b>Активность:</b>
- Коэффициент активности: {active_rate}
- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)})
<b>Обновлено:</b> {current_time}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики пользователей: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
return
await callback.answer("✅ Статистика обновлена")
@admin_required
@error_handler
async def show_subscriptions_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_subscriptions_statistics(db)
total_subs = stats['total_subscriptions']
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
current_time = format_datetime(datetime.utcnow())
text = f"""
📱 <b>Статистика подписок</b>
<b>Общие показатели:</b>
- Всего подписок: {stats['total_subscriptions']}
- Активных: {stats['active_subscriptions']}
- Платных: {stats['paid_subscriptions']}
- Триальных: {stats['trial_subscriptions']}
<b>Конверсия:</b>
- Из триала в платную: {conversion_rate}
- Активных платных: {stats['paid_subscriptions']}
<b>Продажи:</b>
- Сегодня: {stats['purchased_today']}
- За неделю: {stats['purchased_week']}
- За месяц: {stats['purchased_month']}
<b>Обновлено:</b> {current_time}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики подписок: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@error_handler
async def show_revenue_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_stats = await get_transactions_statistics(db, month_start, now)
all_time_stats = await get_transactions_statistics(db)
current_time = format_datetime(datetime.utcnow())
text = f"""
💰 <b>Статистика доходов</b>
<b>За текущий месяц:</b>
- Доходы: {settings.format_price(month_stats['totals']['income_kopeks'])}
- Расходы: {settings.format_price(month_stats['totals']['expenses_kopeks'])}
- Прибыль: {settings.format_price(month_stats['totals']['profit_kopeks'])}
- От подписок: {settings.format_price(month_stats['totals']['subscription_income_kopeks'])}
<b>Сегодня:</b>
- Транзакций: {month_stats['today']['transactions_count']}
- Доходы: {settings.format_price(month_stats['today']['income_kopeks'])}
<b>За все время:</b>
- Общий доход: {settings.format_price(all_time_stats['totals']['income_kopeks'])}
- Общая прибыль: {settings.format_price(all_time_stats['totals']['profit_kopeks'])}
<b>Способы оплаты:</b>
"""
for method, data in month_stats['by_payment_method'].items():
if method and data['count'] > 0:
text += f"{method}: {data['count']} ({settings.format_price(data['amount'])})\n"
text += f"\n<b>Обновлено:</b> {current_time}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
# [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики доходов: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@error_handler
async def show_referral_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_referral_statistics(db)
current_time = format_datetime(datetime.utcnow())
avg_per_referrer = 0
if stats['active_referrers'] > 0:
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
text = f"""
🤝 <b>Реферальная статистика</b>
<b>Общие показатели:</b>
- Пользователей с рефералами: {stats['users_with_referrals']}
- Активных рефереров: {stats['active_referrers']}
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
<b>За период:</b>
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
<b>Средние показатели:</b>
- На одного рефререра: {settings.format_price(int(avg_per_referrer))}
<b>Топ рефереры:</b>
"""
if stats['top_referrers']:
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
name = referrer['display_name']
earned = settings.format_price(referrer['total_earned_kopeks'])
count = referrer['referrals_count']
text += f"{i}. {name}: {earned} ({count} реф.)\n"
else:
text += "Пока нет активных рефереров"
text += f"\n<b>Обновлено:</b> {current_time}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления реферальной статистики: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@error_handler
async def show_summary_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_service = UserService()
user_stats = await user_service.get_user_statistics(db)
sub_stats = await get_subscriptions_statistics(db)
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
revenue_stats = await get_transactions_statistics(db, month_start, now)
current_time = format_datetime(datetime.utcnow())
conversion_rate = 0
if user_stats['total_users'] > 0:
conversion_rate = sub_stats['paid_subscriptions'] / user_stats['total_users'] * 100
arpu = 0
if user_stats['active_users'] > 0:
arpu = revenue_stats['totals']['income_kopeks'] / user_stats['active_users']
text = f"""
📊 <b>Общая сводка системы</b>
<b>Пользователи:</b>
- Всего: {user_stats['total_users']}
- Активных: {user_stats['active_users']}
- Новых за месяц: {user_stats['new_month']}
<b>Подписки:</b>
- Активных: {sub_stats['active_subscriptions']}
- Платных: {sub_stats['paid_subscriptions']}
- Конверсия: {format_percentage(conversion_rate)}
<b>Финансы (месяц):</b>
- Доходы: {settings.format_price(revenue_stats['totals']['income_kopeks'])}
- ARPU: {settings.format_price(int(arpu))}
- Транзакций: {sum(data['count'] for data in revenue_stats['by_type'].values())}
<b>Рост:</b>
- Пользователи: +{user_stats['new_month']} за месяц
- Продажи: +{sub_stats['purchased_month']} за месяц
<b>Обновлено:</b> {current_time}
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления общей статистики: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@error_handler
async def show_revenue_by_period(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
period = callback.data.split('_')[-1]
period_map = {
"today": 1,
"yesterday": 1,
"week": 7,
"month": 30,
"all": 365
}
days = period_map.get(period, 30)
revenue_data = await get_revenue_by_period(db, days)
if period == "yesterday":
yesterday = datetime.utcnow().date() - timedelta(days=1)
revenue_data = [r for r in revenue_data if r['date'] == yesterday]
elif period == "today":
today = datetime.utcnow().date()
revenue_data = [r for r in revenue_data if r['date'] == today]
total_revenue = sum(r['amount_kopeks'] for r in revenue_data)
avg_daily = total_revenue / len(revenue_data) if revenue_data else 0
text = f"""
📈 <b>Доходы за период: {period}</b>
<b>Сводка:</b>
- Общий доход: {settings.format_price(total_revenue)}
- Дней с данными: {len(revenue_data)}
- Средний доход в день: {settings.format_price(int(avg_daily))}
<b>По дням:</b>
"""
for revenue in revenue_data[-10:]:
text += f"{revenue['date'].strftime('%d.%m')}: {settings.format_price(revenue['amount_kopeks'])}\n"
if len(revenue_data) > 10:
text += f"... и еще {len(revenue_data) - 10} дней"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📊 Другой период", callback_data="admin_revenue_period")],
[types.InlineKeyboardButton(text="⬅️ К доходам", callback_data="admin_stats_revenue")]
])
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_statistics_menu, F.data == "admin_statistics")
dp.callback_query.register(show_users_statistics, F.data == "admin_stats_users")
dp.callback_query.register(show_subscriptions_statistics, F.data == "admin_stats_subs")
dp.callback_query.register(show_revenue_statistics, F.data == "admin_stats_revenue")
dp.callback_query.register(show_referral_statistics, F.data == "admin_stats_referrals")
dp.callback_query.register(show_summary_statistics, F.data == "admin_stats_summary")
dp.callback_query.register(show_revenue_by_period, F.data.startswith("period_"))
periods = ["today", "yesterday", "week", "month", "all"]
for period in periods:
dp.callback_query.register(
show_revenue_by_period,
F.data == f"period_{period}"
)
+441
View File
@@ -0,0 +1,441 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.states import AdminStates
from app.database.models import User
from app.keyboards.admin import get_admin_subscriptions_keyboard
from app.localization.texts import get_texts
from app.database.crud.subscription import (
get_expiring_subscriptions, get_subscriptions_statistics, get_expired_subscriptions,
get_all_subscriptions
)
from app.services.subscription_service import SubscriptionService
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
def get_country_flag(country_name: str) -> str:
flags = {
'USA': '🇺🇸', 'United States': '🇺🇸', 'US': '🇺🇸',
'Germany': '🇩🇪', 'DE': '🇩🇪', 'Deutschland': '🇩🇪',
'Netherlands': '🇳🇱', 'NL': '🇳🇱', 'Holland': '🇳🇱',
'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'GB': '🇬🇧',
'Japan': '🇯🇵', 'JP': '🇯🇵',
'France': '🇫🇷', 'FR': '🇫🇷',
'Canada': '🇨🇦', 'CA': '🇨🇦',
'Russia': '🇷🇺', 'RU': '🇷🇺',
'Singapore': '🇸🇬', 'SG': '🇸🇬',
}
return flags.get(country_name, '🌍')
async def get_users_by_countries(db: AsyncSession) -> dict:
try:
result = await db.execute(
select(User.preferred_location, func.count(User.id))
.where(User.preferred_location.isnot(None))
.group_by(User.preferred_location)
)
stats = {}
for location, count in result.fetchall():
if location:
stats[location] = count
return stats
except Exception as e:
logger.error(f"Ошибка получения статистики по странам: {e}")
return {}
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_subscriptions_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_subscriptions_statistics(db)
text = f"""
📱 <b>Управление подписками</b>
📊 <b>Статистика:</b>
- Всего: {stats['total_subscriptions']}
- Активных: {stats['active_subscriptions']}
- Платных: {stats['paid_subscriptions']}
- Триальных: {stats['trial_subscriptions']}
📈 <b>Продажи:</b>
- Сегодня: {stats['purchased_today']}
- За неделю: {stats['purchased_week']}
- За месяц: {stats['purchased_month']}
Выберите действие:
"""
keyboard = [
[
types.InlineKeyboardButton(text="📋 Список подписок", callback_data="admin_subs_list"),
types.InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
],
[
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_subscriptions_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
subscriptions, total_count = await get_all_subscriptions(db, page=page, limit=10)
total_pages = (total_count + 9) // 10
if not subscriptions:
text = "📱 <b>Список подписок</b>\n\n❌ Подписки не найдены."
else:
text = f"📱 <b>Список подписок</b>\n\n"
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
for i, sub in enumerate(subscriptions, 1 + (page - 1) * 10):
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
sub_type = "🎁" if sub.is_trial else "💎"
status = "✅ Активна" if sub.is_active else "❌ Неактивна"
text += f"{i}. {sub_type} {user_info}\n"
text += f" {status} | До: {format_datetime(sub.end_date)}\n"
if sub.device_limit > 0:
text += f" 📱 Устройств: {sub.device_limit}\n"
text += "\n"
keyboard = []
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(types.InlineKeyboardButton(
text="⬅️", callback_data=f"admin_subs_list_page_{page-1}"
))
nav_row.append(types.InlineKeyboardButton(
text=f"{page}/{total_pages}", callback_data="current_page"
))
if page < total_pages:
nav_row.append(types.InlineKeyboardButton(
text="➡️", callback_data=f"admin_subs_list_page_{page+1}"
))
keyboard.append(nav_row)
keyboard.extend([
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_list")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_expiring_subscriptions(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
expiring_3d = await get_expiring_subscriptions(db, 3)
expiring_1d = await get_expiring_subscriptions(db, 1)
expired = await get_expired_subscriptions(db)
text = f"""
<b>Истекающие подписки</b>
📊 <b>Статистика:</b>
- Истекают через 3 дня: {len(expiring_3d)}
- Истекают завтра: {len(expiring_1d)}
- Уже истекли: {len(expired)}
<b>Истекают через 3 дня:</b>
"""
for sub in expiring_3d[:5]:
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
sub_type = "🎁" if sub.is_trial else "💎"
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
if len(expiring_3d) > 5:
text += f"... и еще {len(expiring_3d) - 5}\n"
text += f"\n<b>Истекают завтра:</b>\n"
for sub in expiring_1d[:5]:
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
sub_type = "🎁" if sub.is_trial else "💎"
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
if len(expiring_1d) > 5:
text += f"... и еще {len(expiring_1d) - 5}\n"
keyboard = [
[types.InlineKeyboardButton(text="📨 Отправить напоминания", callback_data="admin_send_expiry_reminders")],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_expiring")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_subscriptions_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_subscriptions_statistics(db)
expiring_3d = await get_expiring_subscriptions(db, 3)
expiring_7d = await get_expiring_subscriptions(db, 7)
expired = await get_expired_subscriptions(db)
text = f"""
📊 <b>Детальная статистика подписок</b>
<b>📱 Общая информация:</b>
Всего подписок: {stats['total_subscriptions']}
Активных: {stats['active_subscriptions']}
Неактивных: {stats['total_subscriptions'] - stats['active_subscriptions']}
<b>💎 По типам:</b>
Платных: {stats['paid_subscriptions']}
Триальных: {stats['trial_subscriptions']}
<b>📈 Продажи:</b>
Сегодня: {stats['purchased_today']}
За неделю: {stats['purchased_week']}
За месяц: {stats['purchased_month']}
<b> Истечение:</b>
Истекают через 3 дня: {len(expiring_3d)}
Истекают через 7 дней: {len(expiring_7d)}
Уже истекли: {len(expired)}
<b>💰 Конверсия:</b>
Из триала в платную: {stats.get('trial_to_paid_conversion', 0)}%
Продлений: {stats.get('renewals_count', 0)}
"""
keyboard = [
# [
# types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
# types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
# ],
# [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_countries_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
nodes_data = await remnawave_service.get_all_nodes()
squads_data = await remnawave_service.get_all_squads()
text = "🌍 <b>Управление странами</b>\n\n"
if nodes_data:
text += "<b>Доступные серверы:</b>\n"
countries = {}
for node in nodes_data:
country_code = node.get('country_code', 'XX')
country_name = country_code
if country_name not in countries:
countries[country_name] = []
countries[country_name].append(node)
for country, nodes in countries.items():
active_nodes = len([n for n in nodes if n.get('is_connected') and n.get('is_node_online')])
total_nodes = len(nodes)
country_flag = get_country_flag(country)
text += f"{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n"
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
if total_users_online > 0:
text += f" 👥 Пользователей онлайн: {total_users_online}\n"
else:
text += "❌ Не удалось загрузить данные о серверах\n"
if squads_data:
text += f"\n<b>Всего сквадов:</b> {len(squads_data)}\n"
total_members = sum(squad.get('members_count', 0) for squad in squads_data)
text += f"<b>Участников в сквадах:</b> {total_members}\n"
text += "\n<b>Сквады:</b>\n"
for squad in squads_data[:5]:
name = squad.get('name', 'Неизвестно')
members = squad.get('members_count', 0)
inbounds = squad.get('inbounds_count', 0)
text += f"{name}: {members} участников, {inbounds} inbound(s)\n"
if len(squads_data) > 5:
text += f"... и еще {len(squads_data) - 5} сквадов\n"
user_stats = await get_users_by_countries(db)
if user_stats:
text += "\n<b>Пользователи по регионам:</b>\n"
for country, count in user_stats.items():
country_flag = get_country_flag(country)
text += f"{country_flag} {country}: {count} пользователей\n"
except Exception as e:
logger.error(f"Ошибка получения данных о странах: {e}")
text = f"""
🌍 <b>Управление странами</b>
<b>Ошибка загрузки данных</b>
Не удалось получить информацию о серверах.
Проверьте подключение к RemnaWave API.
<b>Детали ошибки:</b> {str(e)}
"""
keyboard = [
[
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries")
],
[
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
types.InlineKeyboardButton(text="🔧 Сквады", callback_data="admin_rw_squads")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def send_expiry_reminders(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"📨 Отправка напоминаний...\n\nПодождите, это может занять время.",
reply_markup=None
)
expiring_subs = await get_expiring_subscriptions(db, 1)
sent_count = 0
for subscription in expiring_subs:
if subscription.user:
try:
user = subscription.user
days_left = max(1, subscription.days_left)
reminder_text = f"""
<b>Подписка истекает!</b>
Ваша подписка истекает через {days_left} день(а).
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
💎 Продлить подписку можно в главном меню.
"""
await callback.bot.send_message(
chat_id=user.telegram_id,
text=reminder_text
)
sent_count += 1
except Exception as e:
logger.error(f"Ошибка отправки напоминания пользователю {subscription.user_id}: {e}")
await callback.message.edit_text(
f"✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subs_expiring")]
])
)
await callback.answer()
@admin_required
@error_handler
async def handle_subscriptions_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = int(callback.data.split('_')[-1])
await show_subscriptions_list(callback, db_user, db, page)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_subscriptions_menu, F.data == "admin_subscriptions")
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
dp.callback_query.register(
handle_subscriptions_pagination,
F.data.startswith("admin_subs_list_page_")
)
+468
View File
@@ -0,0 +1,468 @@
import logging
import re
import html
import contextlib
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.config import settings
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
from app.services.support_settings_service import SupportSettingsService
from app.states import SupportSettingsStates
logger = logging.getLogger(__name__)
def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
texts = get_texts(language)
mode = SupportSettingsService.get_system_mode()
menu_enabled = SupportSettingsService.is_support_menu_enabled()
admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled()
user_notif = SupportSettingsService.get_user_ticket_notifications_enabled()
sla_enabled = SupportSettingsService.get_sla_enabled()
sla_minutes = SupportSettingsService.get_sla_minutes()
rows: list[list[types.InlineKeyboardButton]] = []
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
def mode_button(label_key: str, default: str, active: bool) -> str:
prefix = "🔘" if active else ""
return f"{prefix} {texts.t(label_key, default)}"
rows.append([
types.InlineKeyboardButton(
text=(
f"{'' if menu_enabled else '🚫'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
),
callback_data="admin_support_toggle_menu"
)
])
rows.append([
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
callback_data="admin_support_mode_tickets"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
callback_data="admin_support_mode_contact"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
callback_data="admin_support_mode_both"
),
])
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
callback_data="admin_support_edit_desc"
)
])
# Notifications block
rows.append([
types.InlineKeyboardButton(
text=(
f"{'🔔' if admin_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
f"{status_enabled if admin_notif else status_disabled}"
),
callback_data="admin_support_toggle_admin_notifications"
)
])
rows.append([
types.InlineKeyboardButton(
text=(
f"{'🔔' if user_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
f"{status_enabled if user_notif else status_disabled}"
),
callback_data="admin_support_toggle_user_notifications"
)
])
# SLA block
rows.append([
types.InlineKeyboardButton(
text=(
f"{'' if sla_enabled else '⏹️'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
f"{status_enabled if sla_enabled else status_disabled}"
),
callback_data="admin_support_toggle_sla"
)
])
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
callback_data="admin_support_set_sla_minutes"
)
])
# Moderators
moderators = SupportSettingsService.get_moderators()
mod_count = len(moderators)
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑‍⚖️ Модераторы: {count}").format(count=mod_count),
callback_data="admin_support_list_moderators"
)
])
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
callback_data="admin_support_add_moderator"
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
callback_data="admin_support_remove_moderator"
)
])
rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")
])
return types.InlineKeyboardMarkup(inline_keyboard=rows)
@admin_required
@error_handler
async def show_support_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
desc = SupportSettingsService.get_support_info_text(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
desc,
reply_markup=_get_support_settings_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_support_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
current = SupportSettingsService.is_support_menu_enabled()
SupportSettingsService.set_support_menu_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_admin_ticket_notifications_enabled()
SupportSettingsService.set_admin_ticket_notifications_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_user_ticket_notifications_enabled()
SupportSettingsService.set_user_ticket_notifications_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_sla_enabled()
SupportSettingsService.set_sla_enabled(not current)
await show_support_settings(callback, db_user, db)
class SupportAdvancedStates(StatesGroup):
waiting_for_sla_minutes = State()
waiting_for_moderator_id = State()
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
await callback.answer()
@admin_required
@error_handler
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
text = (message.text or "").strip()
try:
minutes = int(text)
if minutes <= 0 or minutes > 1440:
raise ValueError()
except Exception:
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
return
SupportSettingsService.set_sla_minutes(minutes)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
@admin_required
@error_handler
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
await callback.answer()
@admin_required
@error_handler
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
# We'll reuse the same state; next message will decide action via flag
await state.update_data(action="remove_moderator")
await callback.answer()
@admin_required
@error_handler
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
data = await state.get_data()
action = data.get("action", "add")
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
return
if action == "remove_moderator":
ok = SupportSettingsService.remove_moderator(tid)
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
)
else:
ok = SupportSettingsService.add_moderator(tid)
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(msg, reply_markup=markup)
@admin_required
@error_handler
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
moderators = SupportSettingsService.get_moderators()
if not moderators:
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
return
text = (
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑‍⚖️ <b>Модераторы</b>") +
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
)
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
await callback.answer()
@admin_required
@error_handler
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
SupportSettingsService.set_system_mode("tickets")
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
SupportSettingsService.set_system_mode("contact")
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
SupportSettingsService.set_system_mode("both")
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
# plain text for display-only code block
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
kb_rows: list[list[types.InlineKeyboardButton]] = []
kb_rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
callback_data="admin_support_send_desc"
)
])
# Подготовим блок контакта (отдельным инлайном)
from app.config import settings
support_contact_display = settings.get_support_contact_display()
kb_rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")
])
text_parts = [
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
"",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
"",
f"<code>{html.escape(current_desc_plain)}</code>",
]
if support_contact_display:
text_parts += [
"",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
f"<code>{html.escape(support_contact_display)}</code>",
"",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
]
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows),
parse_mode="HTML"
)
await state.set_state(SupportSettingsStates.waiting_for_desc)
await callback.answer()
@admin_required
@error_handler
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
new_text = message.html_text or message.text
SupportSettingsService.set_support_info_text(db_user.language, new_text)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
@admin_required
@error_handler
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# send plain text for easy copying
texts = get_texts(db_user.language)
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
# attach delete button to the sent message
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
if len(current_desc_plain) <= 4000:
await callback.message.answer(current_desc_plain, reply_markup=markup)
else:
# split long messages (attach delete only to the last chunk)
chunk = 0
while chunk < len(current_desc_plain):
next_chunk = current_desc_plain[chunk:chunk+4000]
is_last = (chunk + 4000) >= len(current_desc_plain)
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
chunk += 4000
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
@error_handler
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Allow admins and moderators to delete informational notifications
try:
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
except Exception:
may_delete = False
texts = get_texts(db_user.language if db_user else 'ru')
if not may_delete:
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
try:
await callback.message.delete()
finally:
with contextlib.suppress(Exception):
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_support_settings, F.data == "admin_support_settings")
dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu")
dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets")
dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact")
dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both")
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications")
dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications")
dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla")
dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes")
dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator")
dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator")
dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators")
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes)
dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id)
+165
View File
@@ -0,0 +1,165 @@
import logging
from datetime import datetime
from html import escape
from pathlib import Path
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
LOG_PREVIEW_LIMIT = 2300
def _resolve_log_path() -> Path:
log_path = Path(settings.LOG_FILE)
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
return log_path
def _format_preview_block(text: str) -> str:
escaped_text = escape(text) if text else ""
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
def _build_logs_message(log_path: Path) -> str:
if not log_path.exists():
message = (
"🧾 <b>Системные логи</b>\n\n"
f"Файл <code>{log_path}</code> пока не создан.\n"
"Логи появятся автоматически после первой записи."
)
return message
try:
content = log_path.read_text(encoding="utf-8", errors="ignore")
except Exception as error: # pragma: no cover - защита от проблем чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
message = (
"❌ <b>Ошибка чтения логов</b>\n\n"
f"Не удалось прочитать файл <code>{log_path}</code>."
)
return message
total_length = len(content)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime)
if not content:
preview_text = "Лог-файл пуст."
truncated = False
else:
preview_text = content[-LOG_PREVIEW_LIMIT:]
truncated = total_length > LOG_PREVIEW_LIMIT
details_lines = [
"🧾 <b>Системные логи</b>",
"",
f"📁 <b>Файл:</b> <code>{log_path}</code>",
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
f"🧮 <b>Размер:</b> {total_length} символов",
(
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
if truncated
else "📄 Показано все содержимое файла."
),
"",
_format_preview_block(preview_text),
]
return "\n".join(details_lines)
def _get_logs_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
]
)
@admin_required
@error_handler
async def show_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer()
@admin_required
@error_handler
async def refresh_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("🔄 Обновлено")
@admin_required
@error_handler
async def download_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
if not log_path.exists() or not log_path.is_file():
await callback.answer("❌ Лог-файл не найден", show_alert=True)
return
try:
await callback.answer("⬇️ Отправляю лог...")
document = FSInputFile(log_path)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
caption = (
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
f"📁 Путь: <code>{log_path}</code>\n"
f"🕒 Обновлен: {updated_at}"
)
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
except Exception as error: # pragma: no cover - защита от ошибок отправки
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
await callback.message.answer(
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
"Проверьте журналы приложения или повторите попытку позже.",
parse_mode="HTML",
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_system_logs,
F.data == "admin_system_logs",
)
dp.callback_query.register(
refresh_system_logs,
F.data == "admin_system_logs_refresh",
)
dp.callback_query.register(
download_system_logs,
F.data == "admin_system_logs_download",
)
File diff suppressed because it is too large Load Diff
+274
View File
@@ -0,0 +1,274 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.version_service import version_service
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
def get_updates_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
buttons = [
[
InlineKeyboardButton(
text="🔄 Проверить обновления",
callback_data="admin_updates_check"
)
],
[
InlineKeyboardButton(
text="📋 Информация о версии",
callback_data="admin_updates_info"
)
],
[
InlineKeyboardButton(
text="🔗 Открыть репозиторий",
url=f"https://github.com/{version_service.repo}/releases"
)
],
[
InlineKeyboardButton(
text="◀️ Назад",
callback_data="admin_panel"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_version_info_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
buttons = [
[
InlineKeyboardButton(
text="🔄 Обновить",
callback_data="admin_updates_info"
)
],
[
InlineKeyboardButton(
text="◀️ К обновлениям",
callback_data="admin_updates"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
@admin_required
@error_handler
async def show_updates_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
version_info = await version_service.get_version_info()
current_version = version_info['current_version']
has_updates = version_info['has_updates']
total_newer = version_info['total_newer']
last_check = version_info['last_check']
status_icon = "🆕" if has_updates else ""
status_text = f"Доступно {total_newer} обновлений" if has_updates else "Актуальная версия"
last_check_text = ""
if last_check:
last_check_text = f"\n🕐 Последняя проверка: {last_check.strftime('%d.%m.%Y %H:%M')}"
message = f"""🔄 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>
📦 <b>Текущая версия:</b> <code>{current_version}</code>
{status_icon} <b>Статус:</b> {status_text}
🔗 <b>Репозиторий:</b> {version_service.repo}{last_check_text}
Система автоматически проверяет обновления каждый час и отправляет уведомления о новых версиях."""
await callback.message.edit_text(
message,
reply_markup=get_updates_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка показа меню обновлений: {e}")
await callback.answer("❌ Ошибка загрузки меню обновлений", show_alert=True)
@admin_required
@error_handler
async def check_updates(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("🔄 Проверяю обновления...")
try:
has_updates, newer_releases = await version_service.check_for_updates(force=True)
if not has_updates:
message = f"""✅ <b>ОБНОВЛЕНИЯ НЕ НАЙДЕНЫ</b>
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
🎯 <b>Статус:</b> У вас установлена последняя версия
🔗 <b>Репозиторий:</b> {version_service.repo}"""
else:
updates_list = []
for i, release in enumerate(newer_releases[:5]):
icon = version_service.format_version_display(release).split()[0]
updates_list.append(
f"{i+1}. {icon} <code>{release.tag_name}</code> • {release.formatted_date}"
)
updates_text = "\n".join(updates_list)
more_text = f"\n\n📋 И еще {len(newer_releases) - 5} обновлений..." if len(newer_releases) > 5 else ""
message = f"""🆕 <b>НАЙДЕНЫ ОБНОВЛЕНИЯ</b>
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
🎯 <b>Доступно обновлений:</b> {len(newer_releases)}
📋 <b>Последние версии:</b>
{updates_text}{more_text}
🔗 <b>Репозиторий:</b> {version_service.repo}"""
keyboard = get_updates_keyboard(db_user.language)
if has_updates:
keyboard.inline_keyboard.insert(-2, [
InlineKeyboardButton(
text="📋 Подробнее о версиях",
callback_data="admin_updates_info"
)
])
await callback.message.edit_text(
message,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка проверки обновлений: {e}")
await callback.message.edit_text(
f"❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n"
f"Не удалось связаться с сервером GitHub.\n"
f"Попробуйте позже.\n\n"
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
reply_markup=get_updates_keyboard(db_user.language),
parse_mode="HTML"
)
@admin_required
@error_handler
async def show_version_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("📋 Загружаю информацию о версиях...")
try:
version_info = await version_service.get_version_info()
current_version = version_info['current_version']
current_release = version_info['current_release']
newer_releases = version_info['newer_releases']
has_updates = version_info['has_updates']
last_check = version_info['last_check']
repo_url = version_info['repo_url']
current_info = f"📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n"
if current_release:
current_info += f"🏷️ <b>Версия:</b> <code>{current_release.tag_name}</code>\n"
current_info += f"📅 <b>Дата релиза:</b> {current_release.formatted_date}\n"
if current_release.short_description:
current_info += f"📝 <b>Описание:</b>\n{current_release.short_description}\n"
else:
current_info += f"🏷️ <b>Версия:</b> <code>{current_version}</code>\n"
current_info += f"️ <b>Статус:</b> Информация о релизе недоступна\n"
message_parts = [current_info]
if has_updates and newer_releases:
updates_info = f"\n🆕 <b>ДОСТУПНЫЕ ОБНОВЛЕНИЯ</b>\n\n"
for i, release in enumerate(newer_releases):
icon = "🔥" if i == 0 else "📦"
if release.prerelease:
icon = "🧪"
elif release.is_dev:
icon = "🔧"
updates_info += f"{icon} <b>{release.tag_name}</b>\n"
updates_info += f" 📅 {release.formatted_date}\n"
if release.short_description:
updates_info += f" 📝 {release.short_description}\n"
updates_info += "\n"
message_parts.append(updates_info.rstrip())
system_info = f"\n🔧 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>\n\n"
system_info += f"🔗 <b>Репозиторий:</b> {version_service.repo}\n"
system_info += f"⚡ <b>Автопроверка:</b> {'Включена' if version_service.enabled else 'Отключена'}\n"
system_info += f"🕐 <b>Интервал:</b> Каждый час\n"
if last_check:
system_info += f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%d.%m.%Y %H:%M')}\n"
message_parts.append(system_info.rstrip())
final_message = "\n".join(message_parts)
if len(final_message) > 4000:
final_message = final_message[:3900] + "\n\n... (информация обрезана)"
await callback.message.edit_text(
final_message,
reply_markup=get_version_info_keyboard(db_user.language),
parse_mode="HTML",
disable_web_page_preview=True
)
except Exception as e:
logger.error(f"Ошибка получения информации о версиях: {e}")
await callback.message.edit_text(
f"❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n"
f"Не удалось получить информацию о версиях.\n\n"
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
reply_markup=get_version_info_keyboard(db_user.language),
parse_mode="HTML"
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_updates_menu,
F.data == "admin_updates"
)
dp.callback_query.register(
check_updates,
F.data == "admin_updates_check"
)
dp.callback_query.register(
show_version_info,
F.data == "admin_updates_info"
)
+584
View File
@@ -0,0 +1,584 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user_message import (
create_user_message, get_all_user_messages, get_user_message_by_id,
update_user_message, delete_user_message, toggle_user_message_status,
get_user_messages_stats
)
from app.database.models import User
from app.keyboards.admin import get_admin_main_keyboard
from app.utils.decorators import admin_required, error_handler
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
class UserMessageStates(StatesGroup):
waiting_for_message_text = State()
waiting_for_edit_text = State()
def get_user_messages_keyboard(language: str = "ru"):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="📝 Добавить сообщение",
callback_data="add_user_message"
)
],
[
InlineKeyboardButton(
text="📋 Список сообщений",
callback_data="list_user_messages:0"
)
],
[
InlineKeyboardButton(
text="📊 Статистика",
callback_data="user_messages_stats"
)
],
[
InlineKeyboardButton(
text="🔙 Назад в админку",
callback_data="admin_panel"
)
]
])
def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = "ru"):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
status_text = "🔴 Деактивировать" if is_active else "🟢 Активировать"
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✏️ Редактировать",
callback_data=f"edit_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text=status_text,
callback_data=f"toggle_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text="🗑️ Удалить",
callback_data=f"delete_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text="🔙 К списку",
callback_data="list_user_messages:0"
)
]
])
@admin_required
@error_handler
async def show_user_messages_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
text = (
"📢 <b>Управление сообщениями в главном меню</b>\n\n"
"Здесь вы можете добавлять сообщения, которые будут показываться пользователям "
"в главном меню между информацией о подписке и кнопками действий.\n\n"
"• Сообщения поддерживают HTML теги\n"
"• Можно создать несколько сообщений\n"
"• Активные сообщения показываются случайно\n"
"• Неактивные сообщения не показываются"
)
await callback.message.edit_text(
text,
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def add_user_message_start(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
from app.utils.validators import get_html_help_text
await callback.message.edit_text(
f"📝 <b>Добавление нового сообщения</b>\n\n"
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
f"{get_html_help_text()}\n\n"
f"Отправьте /cancel для отмены.",
parse_mode="HTML"
)
await state.set_state(UserMessageStates.waiting_for_message_text)
await callback.answer()
@admin_required
@error_handler
async def process_new_message_text(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if message.text == "/cancel":
await state.clear()
await message.answer(
"❌ Добавление сообщения отменено.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
return
message_text = message.text.strip()
if len(message_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
from app.utils.validators import validate_html_tags, get_html_help_text
is_valid, error_msg = validate_html_tags(message_text)
if not is_valid:
await message.answer(
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
parse_mode=None
)
return
try:
new_message = await create_user_message(
db=db,
message_text=message_text,
created_by=db_user.id,
is_active=True
)
await state.clear()
await message.answer(
f"✅ <b>Сообщение добавлено!</b>\n\n"
f"<b>ID:</b> {new_message.id}\n"
f"<b>Статус:</b> {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n"
f"<b>Создано:</b> {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Предварительный просмотр:</b>\n"
f"<blockquote>{message_text}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка создания сообщения: {e}")
await state.clear()
await message.answer(
"❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
@admin_required
@error_handler
async def list_user_messages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = 0
if ":" in callback.data:
try:
page = int(callback.data.split(":")[1])
except (ValueError, IndexError):
page = 0
limit = 5
offset = page * limit
messages = await get_all_user_messages(db, offset=offset, limit=limit)
if not messages:
await callback.message.edit_text(
"📋 <b>Список сообщений</b>\n\n"
"Сообщений пока нет. Добавьте первое сообщение!",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
return
text = "📋 <b>Список сообщений</b>\n\n"
for msg in messages:
status_emoji = "🟢" if msg.is_active else "🔴"
preview = msg.message_text[:100] + "..." if len(msg.message_text) > 100 else msg.message_text
preview = preview.replace('<', '&lt;').replace('>', '&gt;')
text += (
f"{status_emoji} <b>ID {msg.id}</b>\n"
f"{preview}\n"
f"📅 {msg.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = []
for msg in messages:
status_emoji = "🟢" if msg.is_active else "🔴"
keyboard.append([
InlineKeyboardButton(
text=f"{status_emoji} ID {msg.id}",
callback_data=f"view_user_message:{msg.id}"
)
])
nav_buttons = []
if page > 0:
nav_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"list_user_messages:{page-1}"
)
)
nav_buttons.append(
InlineKeyboardButton(
text=" Добавить",
callback_data="add_user_message"
)
)
if len(messages) == limit:
nav_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=f"list_user_messages:{page+1}"
)
)
if nav_buttons:
keyboard.append(nav_buttons)
keyboard.append([
InlineKeyboardButton(
text="🔙 Назад",
callback_data="user_messages_panel"
)
])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def view_user_message(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
text = (
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
f"<b>Статус:</b> {status_text}\n"
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Содержимое:</b>\n"
f"<blockquote>{message.message_text}</blockquote>"
)
await callback.message.edit_text(
text,
reply_markup=get_message_actions_keyboard(
message_id, message.is_active, db_user.language
),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_message_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await toggle_user_message_status(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
status_text = "активировано" if message.is_active else "деактивировано"
await callback.answer(f"✅ Сообщение {status_text}")
await view_user_message(callback, db_user, db)
@admin_required
@error_handler
async def delete_message_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтвердить удаление сообщения"""
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
success = await delete_user_message(db, message_id)
if success:
await callback.answer("✅ Сообщение удалено")
await list_user_messages(
types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data="list_user_messages:0",
message=callback.message
),
db_user,
db
)
else:
await callback.answer("❌ Ошибка удаления сообщения", show_alert=True)
@admin_required
@error_handler
async def show_messages_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_user_messages_stats(db)
text = (
"📊 <b>Статистика сообщений</b>\n\n"
f"📝 Всего сообщений: <b>{stats['total_messages']}</b>\n"
f"🟢 Активных: <b>{stats['active_messages']}</b>\n"
f"🔴 Неактивных: <b>{stats['inactive_messages']}</b>\n\n"
"Активные сообщения показываются пользователям случайным образом "
"в главном меню между информацией о подписке и кнопками действий."
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔙 Назад",
callback_data="user_messages_panel"
)
]
])
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def edit_user_message_start(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
await callback.message.edit_text(
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
f"<b>Текущий текст:</b>\n"
f"<blockquote>{message.message_text}</blockquote>\n\n"
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
parse_mode="HTML"
)
await state.set_data({"editing_message_id": message_id})
await state.set_state(UserMessageStates.waiting_for_edit_text)
await callback.answer()
@admin_required
@error_handler
async def process_edit_message_text(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if message.text == "/cancel":
await state.clear()
await message.answer(
"❌ Редактирование отменено.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
return
data = await state.get_data()
message_id = data.get("editing_message_id")
if not message_id:
await state.clear()
await message.answer("❌ Ошибка: ID сообщения не найден")
return
new_text = message.text.strip()
if len(new_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
try:
updated_message = await update_user_message(
db=db,
message_id=message_id,
message_text=new_text
)
if updated_message:
await state.clear()
await message.answer(
f"✅ <b>Сообщение обновлено!</b>\n\n"
f"<b>ID:</b> {updated_message.id}\n"
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Новый текст:</b>\n"
f"<blockquote>{new_text}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
else:
await state.clear()
await message.answer(
"❌ Сообщение не найдено или ошибка обновления.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
except Exception as e:
logger.error(f"Ошибка обновления сообщения: {e}")
await state.clear()
await message.answer(
"❌ Произошла ошибка при обновлении сообщения.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_user_messages_panel,
F.data == "user_messages_panel"
)
dp.callback_query.register(
add_user_message_start,
F.data == "add_user_message"
)
dp.message.register(
process_new_message_text,
StateFilter(UserMessageStates.waiting_for_message_text)
)
dp.callback_query.register(
edit_user_message_start,
F.data.startswith("edit_user_message:")
)
dp.message.register(
process_edit_message_text,
StateFilter(UserMessageStates.waiting_for_edit_text)
)
dp.callback_query.register(
list_user_messages,
F.data.startswith("list_user_messages")
)
dp.callback_query.register(
view_user_message,
F.data.startswith("view_user_message:")
)
dp.callback_query.register(
toggle_message_status,
F.data.startswith("toggle_user_message:")
)
dp.callback_query.register(
delete_message_confirm,
F.data.startswith("delete_user_message:")
)
dp.callback_query.register(
show_messages_stats,
F.data == "user_messages_stats"
)
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.states import AdminStates
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
from app.utils.decorators import admin_required, error_handler
from app.database.crud.welcome_text import (
get_active_welcome_text,
set_welcome_text,
get_current_welcome_text_or_default,
get_available_placeholders,
get_current_welcome_text_settings,
toggle_welcome_text_status
)
logger = logging.getLogger(__name__)
def get_telegram_formatting_info() -> str:
return """
📝 <b>Поддерживаемые теги форматирования:</b>
<code>&lt;b&gt;жирный текст&lt;/b&gt;</code> <b>жирный текст</b>
<code>&lt;i&gt;курсив&lt;/i&gt;</code> <i>курсив</i>
<code>&lt;u&gt;подчеркнутый&lt;/u&gt;</code> <u>подчеркнутый</u>
<code>&lt;s&gt;зачеркнутый&lt;/s&gt;</code> <s>зачеркнутый</s>
<code>&lt;code&gt;моноширинный&lt;/code&gt;</code> <code>моноширинный</code>
<code>&lt;pre&gt;блок кода&lt;/pre&gt;</code> многострочный код
<code>&lt;a href="URL"&gt;ссылка&lt;/a&gt;</code> ссылка
<b>ВНИМАНИЕ:</b> Используйте ТОЛЬКО указанные выше теги!
Любые другие HTML-теги не поддерживаются и будут отображаться как обычный текст.
<b>НЕ используйте:</b> &lt;div&gt;, &lt;span&gt;, &lt;p&gt;, &lt;br&gt;, &lt;h1&gt;-&lt;h6&gt;, &lt;img&gt; и другие HTML-теги.
"""
@admin_required
@error_handler
async def show_welcome_text_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"👋 Управление приветственным текстом\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
f"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_welcome_text(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
new_status = await toggle_welcome_text_status(db, db_user.id)
status_emoji = "🟢" if new_status else "🔴"
status_text = "включено" if new_status else "отключено"
action_text = "включены" if new_status else "отключены"
await callback.message.edit_text(
f"👋 Управление приветственным текстом\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"✅ Приветственные сообщения {action_text}!\n\n"
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
f"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language, new_status),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_current_welcome_text(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
is_enabled = welcome_settings['is_enabled']
if not welcome_settings['id']:
status = "📝 Используется стандартный текст:"
else:
status = "📝 Текущий приветственный текст:"
status_emoji = "🟢" if is_enabled else "🔴"
status_text = "включено" if is_enabled else "отключено"
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
await callback.message.edit_text(
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"{status}\n\n"
f"<code>{current_text}</code>\n\n"
f"💡 Доступные плейсхолдеры:\n{placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language, is_enabled),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_placeholders_help(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
welcome_settings = await get_current_welcome_text_settings(db)
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
help_text = (
"💡 Доступные плейсхолдеры для автозамены:\n\n"
f"{placeholders_text}\n\n"
"📌 Примеры использования:\n"
"• <code>Привет, {user_name}! Добро пожаловать!</code>\n"
"• <code>Здравствуйте, {first_name}! Рады видеть вас!</code>\n"
"• <code>Привет, {username}! Спасибо за регистрацию!</code>\n\n"
"При отсутствии данных пользователя используется слово 'друг'."
)
await callback.message.edit_text(
help_text,
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_formatting_help(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
welcome_settings = await get_current_welcome_text_settings(db)
formatting_info = get_telegram_formatting_info()
await callback.message.edit_text(
formatting_info,
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def start_edit_welcome_text(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
await callback.message.edit_text(
f"📝 Редактирование приветственного текста\n\n"
f"Текущий текст:\n"
f"<code>{current_text}</code>\n\n"
f"💡 Доступные плейсхолдеры:\n{placeholders_text}\n\n"
f"Отправьте новый текст:",
parse_mode="HTML"
)
await state.set_state(AdminStates.editing_welcome_text)
await callback.answer()
@admin_required
@error_handler
async def process_welcome_text_edit(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
new_text = message.text.strip()
if len(new_text) < 10:
await message.answer("❌ Текст слишком короткий! Минимум 10 символов.")
return
if len(new_text) > 4000:
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
return
success = await set_welcome_text(db, new_text, db_user.id)
if success:
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
await message.answer(
f"✅ Приветственный текст успешно обновлен!\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Новый текст:\n"
f"<code>{new_text}</code>\n\n"
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
welcome_settings = await get_current_welcome_text_settings(db)
await message.answer(
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await state.clear()
@admin_required
@error_handler
async def reset_welcome_text(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
default_text = await get_current_welcome_text_or_default()
success = await set_welcome_text(db, default_text, db_user.id)
if success:
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"✅ Приветственный текст сброшен на стандартный!\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Стандартный текст:\n"
f"<code>{default_text}</code>\n\n"
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
welcome_settings = await get_current_welcome_text_settings(db)
await callback.message.edit_text(
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await callback.answer()
@admin_required
@error_handler
async def show_preview_welcome_text(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.database.crud.welcome_text import get_welcome_text_for_user
class TestUser:
def __init__(self):
self.first_name = "Иван"
self.username = "test_user"
test_user = TestUser()
preview_text = await get_welcome_text_for_user(db, test_user)
welcome_settings = await get_current_welcome_text_settings(db)
if preview_text:
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
f"<code>{preview_text}</code>",
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"🔴 Приветственные сообщения отключены.\n"
f"Новые пользователи не будут получать приветственный текст после регистрации.",
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
def register_welcome_text_handlers(dp: Dispatcher):
dp.callback_query.register(
show_welcome_text_panel,
F.data == "welcome_text_panel"
)
dp.callback_query.register(
toggle_welcome_text,
F.data == "toggle_welcome_text"
)
dp.callback_query.register(
show_current_welcome_text,
F.data == "show_welcome_text"
)
dp.callback_query.register(
show_placeholders_help,
F.data == "show_placeholders_help"
)
dp.callback_query.register(
show_formatting_help,
F.data == "show_formatting_help"
)
dp.callback_query.register(
show_preview_welcome_text,
F.data == "preview_welcome_text"
)
dp.callback_query.register(
start_edit_welcome_text,
F.data == "edit_welcome_text"
)
dp.callback_query.register(
reset_welcome_text,
F.data == "reset_welcome_text"
)
dp.message.register(
process_welcome_text_edit,
AdminStates.editing_welcome_text
)
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts, get_rules
from app.keyboards.inline import get_back_keyboard
logger = logging.getLogger(__name__)
async def handle_unknown_callback(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language if db_user else "ru")
await callback.answer(
texts.t(
"UNKNOWN_CALLBACK_ALERT",
"❓ Неизвестная команда. Попробуйте ещё раз.",
),
show_alert=True,
)
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
async def handle_noop(
callback: types.CallbackQuery,
db_user: User
):
try:
await callback.answer()
except Exception:
pass
async def handle_current_page(
callback: types.CallbackQuery,
db_user: User
):
try:
await callback.answer()
except Exception:
pass
async def handle_cancel(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
texts = get_texts(db_user.language)
await state.clear()
await callback.message.edit_text(
texts.OPERATION_CANCELLED,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def handle_unknown_message(
message: types.Message,
db_user: User
):
texts = get_texts(db_user.language if db_user else "ru")
await message.answer(
texts.t(
"UNKNOWN_COMMAND_MESSAGE",
"❓ Не понимаю эту команду. Используйте кнопки меню.",
),
reply_markup=get_back_keyboard(db_user.language if db_user else "ru"),
)
async def show_rules(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
rules_text = await get_rules(db_user.language)
await callback.message.edit_text(
rules_text,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_rules,
F.data == "menu_rules"
)
# No-op utility handlers used in many keyboards
dp.callback_query.register(
handle_noop,
F.data == "noop"
)
dp.callback_query.register(
handle_current_page,
F.data == "current_page"
)
dp.callback_query.register(
handle_cancel,
F.data.in_(["cancel", "subscription_cancel"])
)
# Самый последний: ловим любые неизвестные текстовые сообщения
# Исключаем специальные сервисные события (например, успешные платежи),
# чтобы их обработка не прерывалась общим хендлером неизвестных сообщений
dp.message.register(
handle_unknown_message,
StateFilter(None),
F.successful_payment.is_(None)
)
+824
View File
@@ -0,0 +1,824 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import (
get_main_menu_keyboard,
get_language_selection_keyboard,
get_info_menu_keyboard,
)
from app.localization.texts import get_texts, get_rules
from app.database.models import User
from app.database.crud.user_message import get_random_active_message
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
logger = logging.getLogger(__name__)
async def show_main_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
*,
skip_callback_answer: bool = False,
):
texts = get_texts(db_user.language)
db_user.last_activity = datetime.utcnow()
await db.commit()
has_active_subscription = bool(db_user.subscription)
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
),
parse_mode="HTML",
)
if not skip_callback_answer:
await callback.answer()
async def show_service_rules(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.database.crud.rules import get_current_rules_content
texts = get_texts(db_user.language)
rules_text = await get_current_rules_content(db, db_user.language)
if not rules_text:
rules_text = await get_rules(db_user.language)
await callback.message.edit_text(
f"{texts.t('RULES_HEADER', '📋 <b>Правила сервиса</b>')}\n\n{rules_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
)
await callback.answer()
async def show_info_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
header = texts.t("MENU_INFO_HEADER", "️ <b>Инфо</b>")
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
caption = f"{header}\n\n{prompt}" if prompt else header
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
faq_enabled = await FaqService.is_enabled(db, db_user.language)
await edit_or_answer_photo(
callback=callback,
caption=caption,
keyboard=get_info_menu_keyboard(
language=db_user.language,
show_privacy_policy=privacy_enabled,
show_public_offer=public_offer_enabled,
show_faq=faq_enabled,
),
parse_mode="HTML",
)
await callback.answer()
async def show_faq_pages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
pages = await FaqService.get_pages(db, db_user.language)
if not pages:
await callback.answer(
texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."),
show_alert=True,
)
return
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" )
caption = f"{header}\n\n{prompt}" if prompt else header
buttons: list[list[types.InlineKeyboardButton]] = []
for index, page in enumerate(pages, start=1):
raw_title = (page.title or "").strip()
if not raw_title:
raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
if len(raw_title) > 60:
raw_title = f"{raw_title[:57]}..."
buttons.append([
types.InlineKeyboardButton(
text=f"{index}. {raw_title}",
callback_data=f"menu_faq_page:{page.id}:1",
)
])
buttons.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
async def show_faq_page(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_data = callback.data or ""
parts = raw_data.split(":")
page_id = None
requested_page = 1
if len(parts) >= 2:
try:
page_id = int(parts[1])
except ValueError:
page_id = None
if len(parts) >= 3:
try:
requested_page = int(parts[2])
except ValueError:
requested_page = 1
if not page_id:
await callback.answer()
return
page = await FaqService.get_page(db, page_id, db_user.language)
if not page or not page.is_active:
await callback.answer(
texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."),
show_alert=True,
)
return
content_pages = FaqService.split_content_into_pages(page.content)
if not content_pages:
await callback.answer(
texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."),
show_alert=True,
)
return
total_pages = len(content_pages)
current_page = max(1, min(requested_page, total_pages))
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
page_title = (page.title or "").strip()
if not page_title:
page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
title_block = title_template.format(title=html.escape(page_title))
body = content_pages[current_page - 1]
footer_template = texts.t(
"FAQ_PAGE_FOOTER",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
parts_to_join = [header, title_block]
if body:
parts_to_join.append(body)
if footer:
parts_to_join.append(f"<code>{footer}</code>")
message_text = "\n\n".join(segment for segment in parts_to_join if segment)
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_faq_page:{page.id}:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_faq_page:{page.id}:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append([
types.InlineKeyboardButton(
text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"),
callback_data="menu_faq",
)
])
keyboard_rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
if not policy:
await callback.answer(
texts.t(
"PRIVACY_POLICY_NOT_AVAILABLE",
"Политика конфиденциальности временно недоступна.",
),
show_alert=True,
)
return
pages = PrivacyPolicyService.split_content_into_pages(policy.content)
if not pages:
await callback.answer(
texts.t(
"PRIVACY_POLICY_EMPTY_ALERT",
"Политика конфиденциальности ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PRIVACY_POLICY_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_privacy_policy:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_privacy_policy:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
offer = await PublicOfferService.get_active_offer(db, db_user.language)
if not offer:
await callback.answer(
texts.t(
"PUBLIC_OFFER_NOT_AVAILABLE",
"Публичная оферта временно недоступна.",
),
show_alert=True,
)
return
pages = PublicOfferService.split_content_into_pages(offer.content)
if not pages:
await callback.answer(
texts.t(
"PUBLIC_OFFER_EMPTY_ALERT",
"Публичная оферта ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PUBLIC_OFFER_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_public_offer:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_public_offer:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_language_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
await edit_or_answer_photo(
callback=callback,
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
keyboard=get_language_selection_keyboard(
current_language=db_user.language,
include_back=True,
language=db_user.language,
),
parse_mode="HTML",
)
await callback.answer()
async def process_language_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
selected_raw = (callback.data or "").split(":", 1)[-1]
normalized_selected = selected_raw.strip().lower()
available_map = {
lang.strip().lower(): lang.strip()
for lang in settings.get_available_languages()
if isinstance(lang, str) and lang.strip()
}
if normalized_selected not in available_map:
await callback.answer("❌ Unsupported language", show_alert=True)
return
resolved_language = available_map[normalized_selected].lower()
if db_user.language.lower() == normalized_selected:
await show_main_menu(
callback,
db_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
return
updated_user = await update_user(db, db_user, language=resolved_language)
texts = get_texts(updated_user.language)
await show_main_menu(
callback,
updated_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
async def handle_back_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
await state.clear()
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
),
parse_mode="HTML",
)
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
if days_left > 1:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
else:
if days_left > 7:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
elif days_left == 1:
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
else:
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
if not random_message:
return base_text
prompt = action_prompt or ""
if prompt and prompt in base_text:
parts = base_text.split(prompt, 1)
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}"
return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1)
return f"{base_text}\n\n{random_message}"
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
getattr(user, "id", None),
hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
getattr(user, "id", None),
test_error,
)
if info_sections:
extra_block = "\n\n".join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_back_to_menu,
F.data == "back_to_menu"
)
dp.callback_query.register(
show_service_rules,
F.data == "menu_rules"
)
dp.callback_query.register(
show_info_menu,
F.data == "menu_info",
)
dp.callback_query.register(
show_faq_pages,
F.data == "menu_faq",
)
dp.callback_query.register(
show_faq_page,
F.data.startswith("menu_faq_page:"),
)
dp.callback_query.register(
show_privacy_policy,
F.data == "menu_privacy_policy",
)
dp.callback_query.register(
show_privacy_policy,
F.data.startswith("menu_privacy_policy:"),
)
dp.callback_query.register(
show_public_offer,
F.data == "menu_public_offer",
)
dp.callback_query.register(
show_public_offer,
F.data.startswith("menu_public_offer:"),
)
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
)
dp.callback_query.register(
process_language_change,
F.data.startswith("language_select:"),
StateFilter(None)
)
+108
View File
@@ -0,0 +1,108 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.promocode_service import PromoCodeService
from app.services.admin_notification_service import AdminNotificationService
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def show_promocode_menu(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.PROMOCODE_ENTER,
reply_markup=get_back_keyboard(db_user.language)
)
await state.set_state(PromoCodeStates.waiting_for_code)
await callback.answer()
@error_handler
async def process_promocode(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
texts = get_texts(db_user.language)
code = message.text.strip()
if not code:
await message.answer(
texts.t(
"PROMOCODE_EMPTY_INPUT",
"❌ Введите корректный промокод",
),
reply_markup=get_back_keyboard(db_user.language)
)
return
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, db_user.id, code)
if result["success"]:
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
"expired": texts.PROMOCODE_EXPIRED,
"used": texts.PROMOCODE_USED,
"already_used_by_user": texts.PROMOCODE_USED,
"server_error": texts.ERROR
}
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
await message.answer(
error_text,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_promocode_menu,
F.data == "menu_promocode"
)
dp.message.register(
process_promocode,
PromoCodeStates.waiting_for_code
)
+488
View File
@@ -0,0 +1,488 @@
import logging
from pathlib import Path
import qrcode
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard
from app.localization.texts import get_texts
from app.utils.photo_message import edit_or_answer_photo
from app.utils.user_utils import (
get_detailed_referral_list,
get_referral_analytics,
get_user_referral_summary,
)
logger = logging.getLogger(__name__)
async def show_referral_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
referral_text = (
texts.t("REFERRAL_PROGRAM_TITLE", "👥 <b>Реферальная программа</b>")
+ "\n\n"
+ texts.t("REFERRAL_STATS_HEADER", "📊 <b>Ваша статистика:</b>")
+ "\n"
+ texts.t(
"REFERRAL_STATS_INVITED",
"• Приглашено пользователей: <b>{count}</b>",
).format(count=summary['invited_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_FIRST_TOPUPS",
"• Сделали первое пополнение: <b>{count}</b>",
).format(count=summary['paid_referrals_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_ACTIVE",
"• Активных рефералов: <b>{count}</b>",
).format(count=summary['active_referrals_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_CONVERSION",
"• Конверсия: <b>{rate}%</b>",
).format(rate=summary['conversion_rate'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_TOTAL_EARNED",
"• Заработано всего: <b>{amount}</b>",
).format(amount=texts.format_price(summary['total_earned_kopeks']))
+ "\n"
+ texts.t(
"REFERRAL_STATS_MONTH_EARNED",
"• За последний месяц: <b>{amount}</b>",
).format(amount=texts.format_price(summary['month_earned_kopeks']))
+ "\n\n"
+ texts.t("REFERRAL_REWARDS_HEADER", "🎁 <b>Как работают награды:</b>")
+ "\n"
+ texts.t(
"REFERRAL_REWARD_NEW_USER",
"• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
).format(
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
)
+ "\n"
+ texts.t(
"REFERRAL_REWARD_INVITER",
"• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
+ "\n"
+ texts.t(
"REFERRAL_REWARD_COMMISSION",
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
+ "\n\n"
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
+ f"\n<code>{referral_link}</code>\n\n"
+ texts.t("REFERRAL_CODE_TITLE", "🆔 <b>Ваш код:</b> <code>{code}</code>").format(code=db_user.referral_code)
+ "\n\n"
)
if summary['recent_earnings']:
meaningful_earnings = [
earning for earning in summary['recent_earnings'][:5]
if earning['amount_kopeks'] > 0
]
if meaningful_earnings:
referral_text += texts.t(
"REFERRAL_RECENT_EARNINGS_HEADER",
"💰 <b>Последние начисления:</b>",
) + "\n"
for earning in meaningful_earnings[:3]:
reason_text = {
"referral_first_topup": texts.t(
"REFERRAL_EARNING_REASON_FIRST_TOPUP",
"🎉 Первое пополнение",
),
"referral_commission_topup": texts.t(
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP",
"💰 Комиссия с пополнения",
),
"referral_commission": texts.t(
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE",
"💰 Комиссия с покупки",
),
}.get(earning['reason'], earning['reason'])
referral_text += texts.t(
"REFERRAL_RECENT_EARNINGS_ITEM",
"{reason}: <b>{amount}</b> от {referral_name}",
).format(
reason=reason_text,
amount=texts.format_price(earning['amount_kopeks']),
referral_name=earning['referral_name'],
) + "\n"
referral_text += "\n"
if summary['earnings_by_type']:
referral_text += texts.t(
"REFERRAL_EARNINGS_BY_TYPE_HEADER",
"📈 <b>Доходы по типам:</b>",
) + "\n"
if 'referral_first_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_first_topup']
if data['total_amount_kopeks'] > 0:
referral_text += texts.t(
"REFERRAL_EARNINGS_FIRST_TOPUPS",
"• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
if 'referral_commission_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission_topup']
if data['total_amount_kopeks'] > 0:
referral_text += texts.t(
"REFERRAL_EARNINGS_TOPUPS",
"• Комиссии с пополнений: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
if 'referral_commission' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission']
if data['total_amount_kopeks'] > 0:
referral_text += texts.t(
"REFERRAL_EARNINGS_PURCHASES",
"• Комиссии с покупок: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
referral_text += "\n"
referral_text += texts.t(
"REFERRAL_INVITE_FOOTER",
"📢 Приглашайте друзей и зарабатывайте!",
)
await edit_or_answer_photo(
callback,
referral_text,
get_referral_keyboard(db_user.language),
)
await callback.answer()
async def show_referral_qr(
callback: types.CallbackQuery,
db_user: User,
):
await callback.answer()
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
qr_dir = Path("data") / "referral_qr"
qr_dir.mkdir(parents=True, exist_ok=True)
file_path = qr_dir / f"{db_user.id}.png"
if not file_path.exists():
img = qrcode.make(referral_link)
img.save(file_path)
photo = FSInputFile(file_path)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
)
try:
await callback.message.edit_media(
types.InputMediaPhoto(
media=photo,
caption=texts.t(
"REFERRAL_LINK_CAPTION",
"🔗 Ваша реферальная ссылка:\n{link}",
).format(link=referral_link),
),
reply_markup=keyboard,
)
except TelegramBadRequest:
await callback.message.delete()
await callback.message.answer_photo(
photo,
caption=texts.t(
"REFERRAL_LINK_CAPTION",
"🔗 Ваша реферальная ссылка:\n{link}",
).format(link=referral_link),
reply_markup=keyboard,
)
async def show_detailed_referral_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
texts = get_texts(db_user.language)
referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10)
if not referrals_data['referrals']:
await edit_or_answer_photo(
callback,
texts.t(
"REFERRAL_LIST_EMPTY",
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
),
types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
),
parse_mode=None,
)
await callback.answer()
return
text = texts.t(
"REFERRAL_LIST_HEADER",
"👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
).format(
current=referrals_data['current_page'],
total=referrals_data['total_pages'],
) + "\n\n"
for i, referral in enumerate(referrals_data['referrals'], 1):
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
topup_emoji = "💰" if referral['has_made_first_topup'] else ""
text += texts.t(
"REFERRAL_LIST_ITEM_HEADER",
"{index}. {status} <b>{name}</b>",
).format(index=i, status=status_emoji, name=referral['full_name']) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_TOPUPS",
" {emoji} Пополнений: {count}",
).format(emoji=topup_emoji, count=referral['topups_count']) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_EARNED",
" 💎 Заработано с него: {amount}",
).format(amount=texts.format_price(referral['total_earned_kopeks'])) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_REGISTERED",
" 📅 Регистрация: {days} дн. назад",
).format(days=referral['days_since_registration']) + "\n"
if referral['days_since_activity'] is not None:
text += texts.t(
"REFERRAL_LIST_ITEM_ACTIVITY",
" 🕐 Активность: {days} дн. назад",
).format(days=referral['days_since_activity']) + "\n"
else:
text += texts.t(
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO",
" 🕐 Активность: давно",
) + "\n"
text += "\n"
keyboard = []
nav_buttons = []
if referrals_data['has_prev']:
nav_buttons.append(types.InlineKeyboardButton(
text=texts.t("REFERRAL_LIST_PREV_PAGE", "⬅️ Назад"),
callback_data=f"referral_list_page_{page - 1}"
))
if referrals_data['has_next']:
nav_buttons.append(types.InlineKeyboardButton(
text=texts.t("REFERRAL_LIST_NEXT_PAGE", "Вперед ➡️"),
callback_data=f"referral_list_page_{page + 1}"
))
if nav_buttons:
keyboard.append(nav_buttons)
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)])
await edit_or_answer_photo(
callback,
text,
types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def show_referral_analytics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
analytics = await get_referral_analytics(db, db_user.id)
text = texts.t("REFERRAL_ANALYTICS_TITLE", "📊 <b>Аналитика рефералов</b>") + "\n\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_HEADER",
"💰 <b>Доходы по периодам:</b>",
) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_TODAY",
"• Сегодня: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['today'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_WEEK",
"• За неделю: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['week'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_MONTH",
"• За месяц: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['month'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_QUARTER",
"• За квартал: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['quarter'])) + "\n\n"
if analytics['top_referrals']:
text += texts.t(
"REFERRAL_ANALYTICS_TOP_TITLE",
"🏆 <b>Топ-{count} рефералов:</b>",
).format(count=len(analytics['top_referrals'])) + "\n"
for i, ref in enumerate(analytics['top_referrals'], 1):
text += texts.t(
"REFERRAL_ANALYTICS_TOP_ITEM",
"{index}. {name}: {amount} ({count} начислений)",
).format(
index=i,
name=ref['referral_name'],
amount=texts.format_price(ref['total_earned_kopeks']),
count=ref['earnings_count'],
) + "\n"
text += "\n"
text += texts.t(
"REFERRAL_ANALYTICS_FOOTER",
"📈 Продолжайте развивать свою реферальную сеть!",
)
await edit_or_answer_photo(
callback,
text,
types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
]),
)
await callback.answer()
async def create_invite_message(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
invite_text = (
texts.t("REFERRAL_INVITE_TITLE", "🎉 Присоединяйся к VPN сервису!")
+ "\n\n"
+ texts.t(
"REFERRAL_INVITE_BONUS",
"💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
).format(
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
)
+ "\n\n"
+ texts.t("REFERRAL_INVITE_FEATURE_FAST", "🚀 Быстрое подключение")
+ "\n"
+ texts.t("REFERRAL_INVITE_FEATURE_SERVERS", "🌍 Серверы по всему миру")
+ "\n"
+ texts.t("REFERRAL_INVITE_FEATURE_SECURE", "🔒 Надежная защита")
+ "\n\n"
+ texts.t("REFERRAL_INVITE_LINK_PROMPT", "👇 Переходи по ссылке:")
+ f"\n{referral_link}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.t("REFERRAL_SHARE_BUTTON", "📤 Поделиться"),
switch_inline_query=invite_text
)],
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)]
])
await edit_or_answer_photo(
callback,
(
texts.t("REFERRAL_INVITE_CREATED_TITLE", "📝 <b>Приглашение создано!</b>")
+ "\n\n"
+ texts.t(
"REFERRAL_INVITE_CREATED_INSTRUCTION",
"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
)
+ "\n\n"
f"<code>{invite_text}</code>"
),
keyboard,
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_referral_info,
F.data == "menu_referrals"
)
dp.callback_query.register(
create_invite_message,
F.data == "referral_create_invite"
)
dp.callback_query.register(
show_referral_qr,
F.data == "referral_show_qr"
)
dp.callback_query.register(
show_detailed_referral_list,
F.data == "referral_list"
)
dp.callback_query.register(
show_referral_analytics,
F.data == "referral_analytics"
)
dp.callback_query.register(
lambda callback, db_user, db: show_detailed_referral_list(
callback, db_user, db, int(callback.data.split('_')[-1])
),
F.data.startswith("referral_list_page_")
)
+212
View File
@@ -0,0 +1,212 @@
import logging
from datetime import datetime
from typing import List, Tuple
from aiogram import Dispatcher, F, types
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_server_status_keyboard
from app.localization.texts import get_texts
from app.services.server_status_service import (
ServerStatusEntry,
ServerStatusError,
ServerStatusService,
)
logger = logging.getLogger(__name__)
_status_service = ServerStatusService()
async def show_server_status(callback: types.CallbackQuery, db_user: User) -> None:
await _render_server_status(callback, db_user, page=1)
async def change_server_status_page(callback: types.CallbackQuery, db_user: User) -> None:
try:
_, page_str = callback.data.split(":", 1)
page = int(page_str)
except (ValueError, AttributeError, IndexError):
page = 1
await _render_server_status(callback, db_user, page=page)
async def _render_server_status(
callback: types.CallbackQuery,
db_user: User,
page: int = 1,
) -> None:
texts = get_texts(db_user.language)
if settings.get_server_status_mode() != "xray":
await callback.answer(texts.t("SERVER_STATUS_NOT_CONFIGURED", "Функция недоступна."), show_alert=True)
return
try:
servers = await _status_service.get_servers()
except ServerStatusError as error:
logger.warning("Server status error: %s", error)
await callback.answer(
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
show_alert=True,
)
return
except Exception as error: # pragma: no cover - defensive logging
logger.error("Unexpected server status error: %s", error)
await callback.answer(
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
show_alert=True,
)
return
message, total_pages, current_page = _build_status_message(servers, texts, page)
keyboard = get_server_status_keyboard(db_user.language, current_page, total_pages)
await callback.message.edit_text(
message,
reply_markup=keyboard,
disable_web_page_preview=True,
)
await callback.answer()
def _build_status_message(
servers: List[ServerStatusEntry],
texts,
page: int,
) -> Tuple[str, int, int]:
total_servers = len(servers)
online_servers = [server for server in servers if server.is_online]
offline_servers = [server for server in servers if not server.is_online]
items_per_page = settings.get_server_status_items_per_page()
pages = _split_into_pages(online_servers, offline_servers, items_per_page)
total_pages = max(1, len(pages))
current_index = min(max(page - 1, 0), total_pages - 1)
current_online, current_offline = pages[current_index] if pages else ([], [])
lines: List[str] = [texts.t("SERVER_STATUS_TITLE", "📊 <b>Статус серверов</b>")]
if total_servers == 0:
lines.append("")
lines.append(texts.t("SERVER_STATUS_NO_SERVERS", "Нет данных о серверах."))
message = "\n".join(lines).strip()
return message, 1, 1
summary = texts.t(
"SERVER_STATUS_SUMMARY",
"Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
).format(
total=total_servers,
online=len(online_servers),
offline=len(offline_servers),
)
updated_at = datetime.now().strftime("%H:%M:%S")
lines.extend(
[
"",
summary,
texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at),
"",
]
)
if current_online:
lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ <b>Доступны</b>"))
lines.extend(_format_server_lines(current_online, texts, online=True))
lines.append("")
if current_offline:
lines.append(texts.t("SERVER_STATUS_UNAVAILABLE", "❌ <b>Недоступны</b>"))
lines.extend(_format_server_lines(current_offline, texts, online=False))
lines.append("")
if total_pages > 1:
lines.append(
texts.t("SERVER_STATUS_PAGINATION", "Страница {current} из {total}").format(
current=current_index + 1,
total=total_pages,
)
)
message = "\n".join(line for line in lines if line is not None)
message = message.strip()
return message, total_pages, current_index + 1
def _split_into_pages(
online: List[ServerStatusEntry],
offline: List[ServerStatusEntry],
items_per_page: int,
) -> List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]]:
if not online and not offline:
return [([], [])]
pages: List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]] = []
online_index = 0
offline_index = 0
while online_index < len(online) or offline_index < len(offline):
current_online: List[ServerStatusEntry] = []
current_offline: List[ServerStatusEntry] = []
remaining = max(1, items_per_page)
while remaining > 0 and online_index < len(online):
current_online.append(online[online_index])
online_index += 1
remaining -= 1
while remaining > 0 and offline_index < len(offline):
current_offline.append(offline[offline_index])
offline_index += 1
remaining -= 1
pages.append((current_online, current_offline))
return pages if pages else [([], [])]
def _format_server_lines(
servers: List[ServerStatusEntry],
texts,
*,
online: bool,
) -> List[str]:
lines: List[str] = []
for server in servers:
latency_text: str
if online:
if server.latency_ms and server.latency_ms > 0:
latency_text = texts.t("SERVER_STATUS_LATENCY", "{latency} мс").format(
latency=server.latency_ms
)
else:
latency_text = texts.t("SERVER_STATUS_LATENCY_UNKNOWN", "нет данных")
else:
latency_text = texts.t("SERVER_STATUS_OFFLINE", "нет ответа")
name = server.display_name or server.name
flag_prefix = f"{server.flag} " if server.flag else ""
server_line = f"{flag_prefix}{name}{latency_text}"
lines.append(f"<blockquote>{server_line}</blockquote>")
return lines
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_server_status,
F.data == "menu_server_status",
)
dp.callback_query.register(
change_server_status_page,
F.data.startswith("server_status_page:"),
)
+176
View File
@@ -0,0 +1,176 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
allowed_prefixes = ("balance_", "admin_stars_test_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_INVALID_PAYLOAD",
"Ошибка валидации платежа. Попробуйте еще раз.",
),
)
return
try:
from app.database.database import get_db
async for db in get_db():
user = await get_user_by_telegram_id(db, query.from_user.id)
if not user:
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_USER_NOT_FOUND",
"Пользователь не найден. Обратитесь в поддержку.",
),
)
return
texts = get_texts(user.language or DEFAULT_LANGUAGE)
break
except Exception as db_error:
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
return
await query.answer(ok=True)
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
except Exception as e:
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
await query.answer(
ok=False,
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
async def handle_successful_payment(
message: types.Message,
db: AsyncSession,
**kwargs
):
texts = get_texts(DEFAULT_LANGUAGE)
try:
payment = message.successful_payment
user_id = message.from_user.id
logger.info(
f"💳 Успешный Stars платеж от {user_id}: "
f"{payment.total_amount} XTR, "
f"payload: {payment.invoice_payload}, "
f"charge_id: {payment.telegram_payment_charge_id}"
)
user = await get_user_by_telegram_id(db, user_id)
texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE)
if not user:
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
await message.answer(
texts.t(
"STARS_PAYMENT_USER_NOT_FOUND",
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
)
)
return
payment_service = PaymentService(message.bot)
success = await payment_service.process_stars_payment(
db=db,
user_id=user.id,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id
)
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
keyboard = await payment_service.build_topup_success_keyboard(user)
transaction_id_short = payment.telegram_payment_charge_id[:8]
await message.answer(
texts.t(
"STARS_PAYMENT_SUCCESS",
"🎉 <b>Платеж успешно обработан!</b>\n\n"
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
amount=int(rubles_amount),
transaction_id=transaction_id_short,
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
f"✅ Stars платеж успешно обработан: "
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}"
)
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
await message.answer(
texts.t(
"STARS_PAYMENT_ENROLLMENT_ERROR",
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную.",
)
)
except Exception as e:
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
await message.answer(
texts.t(
"STARS_PAYMENT_PROCESSING_ERROR",
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы.",
)
)
def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register(
handle_pre_checkout_query,
F.currency == "XTR"
)
dp.message.register(
handle_successful_payment,
F.successful_payment
)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_support_keyboard
from app.services.support_settings_service import SupportSettingsService
from app.localization.texts import get_texts
from app.utils.photo_message import edit_or_answer_photo
logger = logging.getLogger(__name__)
async def show_support_info(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
support_info = SupportSettingsService.get_support_info_text(db_user.language)
await edit_or_answer_photo(
callback=callback,
caption=support_info,
keyboard=get_support_keyboard(db_user.language),
parse_mode="HTML",
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_support_info,
F.data == "menu_support"
)
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
import logging
from aiogram import types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import get_db
from app.database.crud.user import get_user_by_id, add_user_balance
from app.database.crud.transaction import create_transaction, get_transaction_by_external_id
from app.database.models import TransactionType, PaymentMethod
from app.external.tribute import TributeService
logger = logging.getLogger(__name__)
async def tribute_webhook(request):
try:
signature = request.headers.get('trbt-signature', '')
payload = await request.text()
tribute_service = TributeService()
if not tribute_service.verify_webhook_signature(payload, signature):
logger.warning("Неверная подпись Tribute webhook")
return web.Response(status=400, text="Invalid signature")
webhook_data = await request.json()
processed_data = await tribute_service.process_webhook(webhook_data)
if not processed_data:
logger.error("Ошибка обработки Tribute webhook")
return web.Response(status=400, text="Invalid webhook data")
async for db in get_db():
try:
existing_transaction = await get_transaction_by_external_id(
db, processed_data['payment_id'], PaymentMethod.TRIBUTE
)
if existing_transaction:
logger.info(f"Платеж {processed_data['payment_id']} уже обработан")
return web.Response(status=200, text="Already processed")
if processed_data['status'] == 'completed':
user = await get_user_by_id(db, processed_data['user_id'])
if user:
await add_user_balance(
db, user, processed_data['amount_kopeks'],
f"Пополнение через Tribute: {processed_data['payment_id']}"
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=processed_data['amount_kopeks'],
description=f"Пополнение через Tribute",
payment_method=PaymentMethod.TRIBUTE,
external_id=processed_data['payment_id']
)
logger.info(f"✅ Обработан Tribute платеж: {processed_data['payment_id']}")
return web.Response(status=200, text="OK")
except Exception as e:
logger.error(f"Ошибка обработки Tribute webhook: {e}")
await db.rollback()
return web.Response(status=500, text="Internal error")
finally:
break
except Exception as e:
logger.error(f"Ошибка в Tribute webhook: {e}")
return web.Response(status=500, text="Internal error")
async def handle_successful_payment(message: types.Message):
try:
payment = message.successful_payment
payload_parts = payment.invoice_payload.split('_')
if len(payload_parts) >= 3 and payload_parts[0] == 'balance':
user_id = int(payload_parts[1])
amount_kopeks = int(payload_parts[2])
async for db in get_db():
try:
existing_transaction = await get_transaction_by_external_id(
db, payment.telegram_payment_charge_id, PaymentMethod.TELEGRAM_STARS
)
if existing_transaction:
logger.info(f"Stars платеж {payment.telegram_payment_charge_id} уже обработан")
return
user = await get_user_by_id(db, user_id)
if user:
await add_user_balance(
db, user, amount_kopeks,
f"Пополнение через Telegram Stars"
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=payment.telegram_payment_charge_id
)
await message.answer(
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
)
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
except Exception as e:
logger.error(f"Ошибка обработки Stars платежа: {e}")
await db.rollback()
finally:
break
except Exception as e:
logger.error(f"Ошибка в обработчике Stars платежа: {e}")
async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery):
try:
await pre_checkout_query.answer(ok=True)
logger.info(f"Pre-checkout query принят: {pre_checkout_query.id}")
except Exception as e:
logger.error(f"Ошибка в pre-checkout query: {e}")
await pre_checkout_query.answer(ok=False, error_message="Ошибка обработки платежа")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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