Compare commits

...

162 Commits

Author SHA1 Message Date
Egor 53988c319b Update Dockerfile 2025-11-28 03:21:48 +03:00
Egor 777ea3b99b Update docker-registry.yml 2025-11-28 03:21:34 +03:00
Egor 672ce18f38 Update docker-hub.yml 2025-11-28 03:21:20 +03:00
Egor aafcec2716 Merge pull request #2083 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 03:03:03 +03:00
Egor 4e5b607288 Merge pull request #2082 from BEDOLAGA-DEV/ycxgll-bedolaga/restore-reverse-sync-button-in-remnawave
Recover missing RemnaWave users during panel sync
2025-11-28 03:00:24 +03:00
Egor aae85683a0 Recover missing RemnaWave users during panel sync 2025-11-28 03:00:09 +03:00
Egor 9971c43218 Merge pull request #2081 from BEDOLAGA-DEV/revert-2080-jq66gt-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Handle RemnaWave status validation changes"
2025-11-28 02:59:49 +03:00
Egor b4370e9a34 Revert "Handle RemnaWave status validation changes" 2025-11-28 02:59:41 +03:00
Egor 101e27acd6 Merge pull request #2080 from BEDOLAGA-DEV/jq66gt-bedolaga/restore-reverse-sync-button-in-remnawave
Handle RemnaWave status validation changes
2025-11-28 02:53:30 +03:00
Egor 5b9002896e Handle RemnaWave status validation changes 2025-11-28 02:53:13 +03:00
Egor e8ac14b0b7 Merge pull request #2079 from BEDOLAGA-DEV/revert-2078-lw4oi2-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Align RemnaWave sync with new status and expiration rules"
2025-11-28 02:52:57 +03:00
Egor 7cd6c3acb7 Revert "Align RemnaWave sync with new status and expiration rules" 2025-11-28 02:52:47 +03:00
Egor 7e7795fc4e Merge pull request #2078 from BEDOLAGA-DEV/lw4oi2-bedolaga/restore-reverse-sync-button-in-remnawave
Align RemnaWave sync with new status and expiration rules
2025-11-28 02:49:23 +03:00
Egor 1f75413abd Handle RemnaWave API status and expire constraints 2025-11-28 02:48:49 +03:00
Egor 3c6d674ce5 Merge pull request #2077 from BEDOLAGA-DEV/revert-2076-7uxban-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Add reverse remnawave sync with batched upload"
2025-11-28 02:48:37 +03:00
Egor 92efc52f7e Revert "Add reverse remnawave sync with batched upload" 2025-11-28 02:48:28 +03:00
Egor c558f69d62 Merge pull request #2076 from BEDOLAGA-DEV/7uxban-bedolaga/restore-reverse-sync-button-in-remnawave
Add reverse remnawave sync with batched upload
2025-11-28 02:39:55 +03:00
Egor 168cb5ea38 Add reverse remnawave sync with batched upload 2025-11-28 02:34:50 +03:00
Egor 6a48cc687c Merge pull request #2075 from BEDOLAGA-DEV/r3w3lo-bedolaga/fix-bot-crash-due-to-html-tags
Avoid unescaping attribute entities in sanitizer
2025-11-28 02:30:54 +03:00
Egor 9a5b0553c6 Avoid unescaping attribute entities in sanitizer 2025-11-28 02:22:39 +03:00
Egor 7515964599 Merge pull request #2074 from BEDOLAGA-DEV/revert-2072-ywwhae-bedolaga/fix-bot-crash-due-to-html-tags
Revert "Improve HTML sanitization for menu messages"
2025-11-28 02:12:41 +03:00
Egor bf5e0ecd4f Revert "Improve HTML sanitization for menu messages" 2025-11-28 02:12:32 +03:00
Egor 6d058beade Merge pull request #2072 from BEDOLAGA-DEV/ywwhae-bedolaga/fix-bot-crash-due-to-html-tags
Improve HTML sanitization for menu messages
2025-11-28 01:35:35 +03:00
Egor 91f557e357 Improve HTML sanitization for menu messages 2025-11-28 01:31:53 +03:00
Egor 8e6c594a7b Merge pull request #2071 from BEDOLAGA-DEV/revert-2069-k72cch-bedolaga/fix-bot-crash-due-to-html-tags
Revert "Validate HTML in user messages"
2025-11-28 01:31:47 +03:00
Egor 94cc04703b Revert "Validate HTML in user messages" 2025-11-28 01:31:39 +03:00
Egor 9ac621cebd Merge pull request #2069 from BEDOLAGA-DEV/k72cch-bedolaga/fix-bot-crash-due-to-html-tags
Validate HTML in user messages
2025-11-28 01:06:50 +03:00
Egor 5d9f6064d6 Validate user message HTML 2025-11-28 01:00:15 +03:00
Egor 010be14379 Merge pull request #2068 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 00:18:54 +03:00
Egor d864c80457 Merge pull request #2067 from BEDOLAGA-DEV/80cp2x-bedolaga/fix-cryptobot-notification-error
Handle missing greenlet when building topup keyboard
2025-11-28 00:11:37 +03:00
Egor ac04284d85 Handle lazy subscription access in checkout resume 2025-11-28 00:11:15 +03:00
Egor e1377ad7a0 Merge pull request #2066 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 00:03:34 +03:00
Egor 6c7bbba7ed Merge pull request #2065 from BEDOLAGA-DEV/78k0ik-bedolaga/filter-out-pricing-logs-from-bot
Reduce noisy price calculation logging
2025-11-27 23:57:00 +03:00
Egor 7a70a90d63 Reduce noisy price calculation logging 2025-11-27 23:55:54 +03:00
Egor 58187cecdb Merge pull request #2064 from BEDOLAGA-DEV/dev5
Dev5
2025-11-27 23:46:46 +03:00
Egor 649a358560 Merge pull request #2063 from BEDOLAGA-DEV/a812lu-bedolaga/fix-manual-status-check-error-in-payment-service
Refresh promo groups before platega finalize
2025-11-27 23:42:38 +03:00
Egor e0f1f221df Refresh promo groups before platega finalize 2025-11-27 23:38:16 +03:00
Egor 1342236b23 Merge pull request #2062 from BEDOLAGA-DEV/rq2kdr-bedolaga/fix-attributeerror-in-cryptobotpayment
Fix CryptoBot saved cart notifications
2025-11-27 23:33:02 +03:00
Egor 61681b393e Fix CryptoBot saved cart notifications 2025-11-27 23:28:53 +03:00
Egor 20a36fe869 Update README.md 2025-11-27 21:30:29 +03:00
Egor a4fe68bf2a Merge pull request #2061 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 10:09:29 +03:00
Egor 827cdd7805 Merge pull request #2060 from BEDOLAGA-DEV/9h699c-bedolaga/fix-telegram-flood-control-issue
Add retry when persisting broadcast results after DB disconnect
2025-11-25 10:02:44 +03:00
Egor 2479167073 Merge pull request #2059 from BEDOLAGA-DEV/y8nxh1-bedolaga/fix-payment-verification-service-error
Avoid lazy loading during Platega payment finalization
2025-11-25 10:00:13 +03:00
Egor 5cc8f7869f Add retry when persisting broadcast results after DB disconnect 2025-11-25 09:59:07 +03:00
Egor 330ace039b Avoid lazy loading during Platega payment finalization 2025-11-25 09:57:11 +03:00
Egor 36ad95dfef Merge pull request #2058 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 06:41:03 +03:00
Egor a7d564b7d1 Update README.md 2025-11-25 06:40:42 +03:00
Egor 4582daa1d1 Update docker-compose.local.yml 2025-11-25 06:38:14 +03:00
Egor c440274c32 Merge pull request #2057 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 04:59:59 +03:00
Egor a02b2f163b Merge pull request #2056 from BEDOLAGA-DEV/bedolaga-5s9q2y
Serve miniapp app-config and fix maintenance schema
2025-11-25 04:32:34 +03:00
Egor f5a6dbdeec Serve miniapp app-config and fix maintenance schema 2025-11-25 04:26:54 +03:00
Egor 282bf3aa11 Merge pull request #2055 from BEDOLAGA-DEV/revert-2053-bedolaga-1xcsyw
Revert "Block miniapp during maintenance mode"
2025-11-25 04:26:22 +03:00
Egor d302e02b0a Revert "Block miniapp during maintenance mode" 2025-11-25 04:26:12 +03:00
Egor da554736ba Merge pull request #2054 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 04:10:40 +03:00
Egor aefbc21033 Merge pull request #2053 from BEDOLAGA-DEV/bedolaga-1xcsyw
Block miniapp during maintenance mode
2025-11-25 04:03:15 +03:00
Egor add0fb4fbe Block miniapp during maintenance mode 2025-11-25 03:58:10 +03:00
Egor f1bec83a6f Merge pull request #2052 from BEDOLAGA-DEV/revert-2051-bedolaga-612jbk
Revert "Improve miniapp app-config loading fallback"
2025-11-25 03:58:02 +03:00
Egor a9e15a7cc1 Revert "Improve miniapp app-config loading fallback" 2025-11-25 03:57:52 +03:00
Egor 53c0f17200 Merge pull request #2051 from BEDOLAGA-DEV/bedolaga-612jbk
Improve miniapp app-config loading fallback
2025-11-25 03:46:46 +03:00
Egor 0c5a28d1c3 Improve miniapp app-config loading fallback 2025-11-25 03:46:23 +03:00
Egor cedb6b4cc4 Merge pull request #2050 from BEDOLAGA-DEV/revert-2048-bedolaga-myk79e
Revert "Show maintenance notice in miniapp"
2025-11-25 03:45:55 +03:00
Egor 5c7ec8c4a2 Revert "Show maintenance notice in miniapp" 2025-11-25 03:45:46 +03:00
Egor 3bb2a055e7 Merge pull request #2049 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 03:29:19 +03:00
Egor b117df126a Merge pull request #2048 from BEDOLAGA-DEV/bedolaga-myk79e
Show maintenance notice in miniapp
2025-11-25 03:13:40 +03:00
Egor 610a39e322 Show maintenance notice in miniapp 2025-11-25 03:12:30 +03:00
Egor 71fca0ccf8 Merge pull request #2046 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 02:49:53 +03:00
Egor 2568cf086a Merge pull request #2045 from BEDOLAGA-DEV/0dm6a3-bedolaga/fix-broadcast-message-failure-on-large-lists
Handle large broadcast status updates and cancellations
2025-11-25 02:42:34 +03:00
Egor ae453279bf Handle late cancellation finalization 2025-11-25 02:38:17 +03:00
Egor bdb04ab91b Merge pull request #2042 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 02:16:49 +03:00
Egor 3636cd27b6 Merge pull request #2041 from BEDOLAGA-DEV/q8dwo9-bedolaga/add-user-balance-to-promocode-notifications
Ensure balance fields exposed in subscription events API
2025-11-25 02:13:47 +03:00
Egor 04279542c5 Ensure balance fields exposed in subscription events API 2025-11-25 02:10:33 +03:00
Egor c7666274b8 Merge pull request #2040 from BEDOLAGA-DEV/revert-2039-uqw8rl-bedolaga/add-user-balance-to-promocode-notifications
Revert "Add balance details to promocode notifications"
2025-11-25 02:06:33 +03:00
Egor 5b27b8834d Revert "Add balance details to promocode notifications" 2025-11-25 02:06:23 +03:00
Egor 4b506cde0c Merge pull request #2039 from BEDOLAGA-DEV/uqw8rl-bedolaga/add-user-balance-to-promocode-notifications
Add balance details to promocode notifications
2025-11-25 02:05:23 +03:00
Egor 121d254fc0 Add balance details to promocode notifications 2025-11-25 02:03:16 +03:00
Egor 515c9dc8b1 Merge pull request #2038 from BEDOLAGA-DEV/dev5
Апи для сообщений в меню и приветственного текста и доп уведомления
2025-11-25 02:00:46 +03:00
Egor 4e03fa1b71 Merge pull request #2037 from BEDOLAGA-DEV/7z2yaf-bedolaga/fix-text-field-key-mismatch-in-updater
Fix welcome text patch text field mapping
2025-11-25 01:53:20 +03:00
Egor 80752c3004 Fix welcome text update payload mapping 2025-11-25 01:53:01 +03:00
Egor a11d37ef96 Merge pull request #2036 from BEDOLAGA-DEV/1docjs-bedolaga/add-notifications-to-api
Add notification events for balances and promotions
2025-11-25 01:51:45 +03:00
Egor 14c89c7aab Expand notification events 2025-11-25 01:43:07 +03:00
Egor b09b47fa49 Merge pull request #2034 from BEDOLAGA-DEV/2westv-bedolaga/expand-bot-api-for-welcome-text
Handle missing creator on welcome text creation
2025-11-25 01:25:57 +03:00
Egor 49b8a96a84 Handle missing creator on welcome text creation 2025-11-25 01:25:41 +03:00
Egor 6f800ac60b Merge pull request #2033 from BEDOLAGA-DEV/revert-2031-vb0a8m-bedolaga/expand-bot-api-for-welcome-text
Revert "Handle missing creator on user message creation"
2025-11-25 01:24:02 +03:00
Egor 46dfb59f3d Revert "Handle missing creator on user message creation" 2025-11-25 01:23:54 +03:00
Egor d5bbb9f14a Merge pull request #2031 from BEDOLAGA-DEV/vb0a8m-bedolaga/expand-bot-api-for-welcome-text
Handle missing creator on user message creation
2025-11-25 01:19:15 +03:00
Egor 7c52811750 Merge pull request #2032 from BEDOLAGA-DEV/revert-2030-j1ummj-bedolaga/expand-bot-api-for-welcome-text
Revert "Expose admin APIs for welcome texts and menu messages"
2025-11-25 01:19:01 +03:00
Egor 2d4b46afbd Revert "Expose admin APIs for welcome texts and menu messages" 2025-11-25 01:18:45 +03:00
Egor 83093236fc Handle missing creator on user message creation 2025-11-25 01:18:03 +03:00
Egor 3881e7d1d5 Merge pull request #2030 from BEDOLAGA-DEV/j1ummj-bedolaga/expand-bot-api-for-welcome-text
Expose admin APIs for welcome texts and menu messages
2025-11-25 01:08:09 +03:00
Egor 1c3dbd57c5 Add API endpoints for welcome texts and menu messages 2025-11-25 01:06:05 +03:00
Egor 6f1275f291 Merge pull request #2029 from BEDOLAGA-DEV/dev5
Апи для партнерки
2025-11-25 00:58:14 +03:00
Egor cfc48ea061 Merge pull request #2028 from BEDOLAGA-DEV/vinuia-bedolaga/expand-bot-api-for-partner-section
Fix referrer listing filter for partners API
2025-11-25 00:54:20 +03:00
Egor 2679172ae4 Fix referrer query predicate 2025-11-25 00:54:05 +03:00
Egor 6a0c276454 Merge pull request #2027 from BEDOLAGA-DEV/revert-2026-fvbx51-bedolaga/expand-bot-api-for-partner-section
Revert "Fix partner routes referral imports"
2025-11-25 00:52:21 +03:00
Egor 5872e3bd3c Revert "Fix partner routes referral imports" 2025-11-25 00:52:09 +03:00
Egor f6041ab055 Merge pull request #2026 from BEDOLAGA-DEV/fvbx51-bedolaga/expand-bot-api-for-partner-section
Fix partner routes referral imports
2025-11-25 00:45:37 +03:00
Egor b5c5da552d Fix partner routes referral imports 2025-11-25 00:45:23 +03:00
Egor c093ef95ed Merge pull request #2025 from BEDOLAGA-DEV/revert-2024-f0fpo3-bedolaga/expand-bot-api-for-partner-section
Revert "Add API endpoint to update user referral commission"
2025-11-25 00:45:07 +03:00
Egor d376a25e64 Revert "Add API endpoint to update user referral commission" 2025-11-25 00:44:56 +03:00
Egor 4ec926d072 Merge pull request #2024 from BEDOLAGA-DEV/f0fpo3-bedolaga/expand-bot-api-for-partner-section
Add API endpoint to update user referral commission
2025-11-25 00:42:20 +03:00
Egor 65a7df4549 Allow updating referral commission per user via API 2025-11-25 00:42:05 +03:00
Egor 57c252dcbf Merge pull request #2023 from BEDOLAGA-DEV/revert-2022-76qz7l-bedolaga/expand-bot-api-for-partner-section
Revert "Add API to update referrer commission percent"
2025-11-25 00:41:28 +03:00
Egor 9b74b40794 Revert "Add API to update referrer commission percent" 2025-11-25 00:41:19 +03:00
Egor 0b028e1f13 Merge pull request #2022 from BEDOLAGA-DEV/76qz7l-bedolaga/expand-bot-api-for-partner-section
Add API to update referrer commission percent
2025-11-25 00:40:08 +03:00
Egor 337b948fda Add API to update referrer commission percent 2025-11-25 00:39:50 +03:00
Egor 5de187c6cd Merge pull request #2021 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 00:27:50 +03:00
Egor 7150204c71 Merge pull request #2020 from BEDOLAGA-DEV/0i4s7o-bedolaga/update-bot-api-for-notifications
Add user details to subscription event API responses
2025-11-25 00:24:58 +03:00
Egor 3d44403318 Add user info to subscription event API 2025-11-25 00:24:32 +03:00
Egor 812f3e983c Merge pull request #2018 from BEDOLAGA-DEV/dev5
Dev5
2025-11-24 08:27:34 +03:00
Egor 7549e571fe Merge pull request #2017 from BEDOLAGA-DEV/7pp9bw-bedolaga/add-setting-to-disable-support-balance-top-up
Add setting to disable support balance top-ups
2025-11-24 08:25:55 +03:00
Egor 9d3f096be0 Add toggle for support top-ups 2025-11-24 08:17:23 +03:00
Egor fd40cd3ba6 Merge pull request #2016 from BEDOLAGA-DEV/dev5
Dev5
2025-11-24 08:12:05 +03:00
Egor 1ba566f30f Merge pull request #2015 from BEDOLAGA-DEV/mz6u7a-bedolaga/fix-subscription-notification-api
Rollback DB session on subscription event logging errors
2025-11-24 07:59:39 +03:00
Egor 9b9d17b8e7 Rollback session after subscription event logging failure 2025-11-24 07:55:52 +03:00
Egor 3fee600db0 Merge pull request #2012 from BEDOLAGA-DEV/revert-2011-rrpx8s-bedolaga/add-referral-income-withdrawal-feature
Revert "Debit balance when closing referral withdrawals"
2025-11-24 07:30:05 +03:00
Egor fbb1091f8b Revert "Debit balance when closing referral withdrawals" 2025-11-24 07:29:57 +03:00
Egor 8513d67a78 Merge pull request #2011 from BEDOLAGA-DEV/rrpx8s-bedolaga/add-referral-income-withdrawal-feature
Debit balance when closing referral withdrawals
2025-11-24 07:22:18 +03:00
Egor 45ce615fbe Debit balance when closing referral withdrawal requests 2025-11-24 07:21:39 +03:00
Egor 2ab4ca5b09 Merge pull request #2010 from BEDOLAGA-DEV/revert-2009-so9oqv-bedolaga/add-referral-income-withdrawal-feature
Revert "Harden referral withdrawal template formatting"
2025-11-24 07:21:04 +03:00
Egor b229dc22ba Revert "Harden referral withdrawal template formatting" 2025-11-24 07:20:56 +03:00
Egor bf49a5c1d5 Merge pull request #2009 from BEDOLAGA-DEV/so9oqv-bedolaga/add-referral-income-withdrawal-feature
Harden referral withdrawal template formatting
2025-11-24 07:11:04 +03:00
Egor 27b6e974ab Initialize texts in admin referral stats handler 2025-11-24 07:06:14 +03:00
Egor 9f00b56be0 Merge pull request #2002 from BEDOLAGA-DEV/j33on7-bedolaga/add-individual-referral-percentage-in-user-edit
Add universal migration for referral commission column
2025-11-24 05:35:46 +03:00
Egor 05177bff92 Chain referral commission migration to latest head 2025-11-24 05:35:37 +03:00
Egor f40f233562 Add universal migration for referral commission column 2025-11-24 05:27:04 +03:00
Egor d3a351aeba Merge pull request #1992 from BEDOLAGA-DEV/dd57ir-bedolaga/fix-validation-error-in-system-stats-response
Handle fractional uptime values in system stats
2025-11-24 05:23:15 +03:00
Egor fb76d30457 Merge pull request #2001 from BEDOLAGA-DEV/revert-2000-main
Revert "Обратная синхронизация remnawave"
2025-11-24 01:20:25 +03:00
Egor afba2137d1 Revert "Обратная синхронизация remnawave" 2025-11-24 01:20:17 +03:00
Egor 9ef3ca1831 Merge pull request #2000 from Gy9vin/main
Обратная синхронизация remnawave
2025-11-24 01:14:20 +03:00
gy9vin 65ae2f5c48 Обратная синхронизация 2025-11-24 00:09:58 +03:00
Egor 2686dcd30a Merge pull request #1998 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 06:40:08 +03:00
Egor 6bc125a2f4 Merge pull request #1997 from BEDOLAGA-DEV/w16jb9-bedolaga/add-admin-panel-for-pricing-limits
Fix admin pricing callbacks for base discount settings
2025-11-23 06:37:00 +03:00
Egor 29c6e296df Escape promo discount format hint for HTML 2025-11-23 06:36:44 +03:00
Egor 3d1d064af7 Merge pull request #1996 from BEDOLAGA-DEV/revert-1995-efy4mi-bedolaga/add-admin-panel-for-pricing-limits
Revert "Fix admin pricing callbacks for base discount settings"
2025-11-23 06:36:02 +03:00
Egor ccf1f8a71d Revert "Fix admin pricing callbacks for base discount settings" 2025-11-23 06:35:53 +03:00
Egor b83a8c9df0 Merge pull request #1995 from BEDOLAGA-DEV/efy4mi-bedolaga/add-admin-panel-for-pricing-limits
Fix admin pricing callbacks for base discount settings
2025-11-23 06:29:56 +03:00
Egor a7bd94f394 Shorten admin pricing callback payloads 2025-11-23 06:29:33 +03:00
Egor 96787c27d3 Merge pull request #1994 from BEDOLAGA-DEV/revert-1993-arxkfd-bedolaga/add-admin-panel-for-pricing-limits
Revert "Add base promo group discount controls to admin pricing"
2025-11-23 06:29:11 +03:00
Egor e99c731e27 Revert "Add base promo group discount controls to admin pricing" 2025-11-23 06:29:03 +03:00
Egor 4a4b5bd8ef Merge pull request #1993 from BEDOLAGA-DEV/arxkfd-bedolaga/add-admin-panel-for-pricing-limits
Add base promo group discount controls to admin pricing
2025-11-23 06:18:05 +03:00
Egor 9d27f1aa6b Add base promo group discount controls to admin pricing 2025-11-23 06:17:45 +03:00
Egor fb010038f1 Ensure uptime seconds parsed as integer 2025-11-23 06:08:33 +03:00
Egor 700f86fe38 Merge pull request #1991 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 06:00:33 +03:00
Egor 582a8b9615 Merge pull request #1990 from BEDOLAGA-DEV/bedolaga
Apply base promo discounts to all order components
2025-11-23 05:55:48 +03:00
Egor 7c9dfc352c Apply base promo discounts to all order components 2025-11-23 05:51:30 +03:00
Egor b86d549695 Merge pull request #1989 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 04:46:13 +03:00
Egor 577f6dfd8e Merge pull request #1988 from BEDOLAGA-DEV/bedolaga-dtalt2
Add subscription events table to universal migration
2025-11-23 04:37:53 +03:00
Egor eab4cce251 Add subscription events universal migration 2025-11-23 04:33:44 +03:00
Egor 7a099ab255 Merge pull request #1987 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 04:19:34 +03:00
Egor 9fa67f61f3 Merge pull request #1986 from BEDOLAGA-DEV/bedolaga/add-/upload-endpoint-for-file-uploads-lowg6q
Proxy media downloads without exposing bot token
2025-11-23 04:19:03 +03:00
Egor 86ebff4948 Serve proxy media with detected content type 2025-11-23 04:09:04 +03:00
Egor 821ac4668f Merge pull request #1983 from BEDOLAGA-DEV/main
w
2025-11-23 03:23:33 +03:00
Egor d0280f0ab9 Merge pull request #1980 from belousotroll/add-policy-page
Add policy page at registration stage
2025-11-23 03:22:49 +03:00
belousotroll ceb45b4c0b add policy page at registration stage 2025-11-22 21:30:11 +07:00
Egor 85ea3d97ef Update README.md 2025-11-22 02:36:04 +03:00
Egor 5640041e45 Update README.md 2025-11-22 02:35:17 +03:00
Egor 1357ddc1fe Update README.md 2025-11-22 02:34:29 +03:00
Egor 0765dae178 Update README.md 2025-11-22 02:31:48 +03:00
Egor 81f2e20c08 Update README.md 2025-11-22 02:29:35 +03:00
Egor b27d34ec92 Update README.md 2025-11-22 02:24:59 +03:00
Egor e7fbbd579c Merge pull request #1977 from remnawave-contrib/ivan-nginx-manual-update
docs: update manual update instructions
2025-11-22 00:11:22 +03:00
Ivan.Nginx 7a2a82fa87 docs: update manual update instructions 2025-11-21 10:25:02 +03:00
71 changed files with 4269 additions and 446 deletions
+2
View File
@@ -253,6 +253,8 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
VERSION="v2.8.0-$(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.7.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.8.0-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.7.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.8.0-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
VERSION="v2.8.0-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.8.0-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.8.0-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+2
View File
@@ -12,6 +12,8 @@ docker-compose.override.yml
!requirements.txt
!docs/
!docs/**
!migrations/
!migrations/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.7.0"
ARG VERSION="v2.8.0"
ARG BUILD_DATE
ARG VCS_REF
+325 -52
View File
@@ -154,7 +154,7 @@ WEBHOOK_SECRET_TOKEN=super-secret-token
# Настройки Web API
WEB_API_ENABLED=true
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com
WEB_API_DEFAULT_TOKEN=super-secret-token
# Настройки Remnawave
@@ -172,7 +172,7 @@ REMNAWAVE_API_KEY=your_api_key
```env
BOT_RUN_MODE=webhook
WEBHOOK_URL=https://api.domain.com
WEBHOOK_URL=https://hooks.domain.com
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=super-secret-token
WEBHOOK_DROP_PENDING_UPDATES=true
@@ -184,8 +184,7 @@ WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
WEB_API_ENABLED=true
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=https://bot.example.com
MINIAPP_CUSTOM_URL=https://bot.example.com/miniapp
WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com
```
* `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`.
@@ -326,7 +325,7 @@ networks:
Если бот и панель Remnawave запускаются на одном сервере, подключите бота к сети панели:
**docker-compose.yml бота:**
**docker-compose.local.yml бота(есть в репо):**
```yaml
services:
postgres:
@@ -433,8 +432,6 @@ networks:
### 5. Проверка здоровья
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
Проверьте, что единый сервер отвечает:
```bash
@@ -458,24 +455,117 @@ curl -s https://bot.example.com/health/unified | jq
`Caddyfile`:
```caddy
# API
api.domain.com {
# Hooks + API
hooks.domain.com {
encode gzip zstd
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
# Webhook пути для платежных систем
handle /yookassa-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
handle /platega-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /cryptobot-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /wata-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /heleket-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /tribute-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /pal24-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
handle /mulenpay-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
# app-config.json с CORS
handle /app-config.json {
header Access-Control-Allow-Origin "*"
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
# Все остальные запросы
handle {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
}
# Статика для miniapp
podpiska.domain.com {
miniapp.domain.com {
encode gzip zstd
# API эндпоинты /miniapp/* в приложение
@@ -518,7 +608,6 @@ podpiska.domain.com {
```nginx
events {}
http {
include /etc/nginx/mime.types;
sendfile on;
@@ -527,17 +616,114 @@ http {
server remnawave_bot:8080;
}
# API домен
# Hooks + API домен
server {
listen 80;
listen 443 ssl http2;
server_name api.domain.com;
server_name hooks.domain.com;
ssl_certificate /etc/ssl/private/api.fullchain.pem;
ssl_certificate_key /etc/ssl/private/api.privkey.pem;
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
client_max_body_size 32m;
# Webhook пути для платежных систем
location = /yookassa-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /platega-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /cryptobot-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /wata-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /heleket-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /tribute-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /pal24-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
location = /mulenpay-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
# app-config.json с CORS
location = /app-config.json {
add_header Access-Control-Allow-Origin "*";
@@ -566,10 +752,10 @@ http {
server {
listen 80;
listen 443 ssl http2;
server_name podpiska.domain.com;
server_name miniapp.domain.com;
ssl_certificate /etc/ssl/private/podpiska.fullchain.pem;
ssl_certificate_key /etc/ssl/private/podpiska.privkey.pem;
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
client_max_body_size 32m;
@@ -617,6 +803,102 @@ http {
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
### Быстрая настройка SSL для Nginx в Docker
#### 1. Установка Certbot
```bash
sudo apt update && sudo apt install certbot -y
```
#### 2. Генерация сертификатов
```bash
# Остановите Nginx Docker контейнер
docker compose down
# Сгенерируйте сертификаты (порт 80 должен быть свободен)
sudo certbot certonly --standalone -d hooks.domain.com --agree-tos --email your-email@example.com --non-interactive
sudo certbot certonly --standalone -d miniapp.domain.com --agree-tos --email your-email@example.com --non-interactive
```
#### 3. Копирование сертификатов
```bash
sudo mkdir -p /etc/ssl/private
sudo cp /etc/letsencrypt/live/hooks.domain.com/fullchain.pem /etc/ssl/private/hooks.fullchain.pem
sudo cp /etc/letsencrypt/live/hooks.domain.com/privkey.pem /etc/ssl/private/hooks.privkey.pem
sudo cp /etc/letsencrypt/live/miniapp.domain.com/fullchain.pem /etc/ssl/private/miniapp.fullchain.pem
sudo cp /etc/letsencrypt/live/miniapp.domain.com/privkey.pem /etc/ssl/private/miniapp.privkey.pem
sudo chmod 600 /etc/ssl/private/*.pem
```
#### 4. Обновите docker-compose.yml
```yaml
services:
nginx:
image: nginx:latest
container_name: nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/ssl/private:/etc/ssl/private:ro
- /var/www/remnawave-miniapp:/var/www/remnawave-miniapp:ro
networks:
- remnawave-network
restart: unless-stopped
networks:
remnawave-network:
external: true
```
#### 5. Запуск Nginx
```bash
docker compose up -d
```
#### 6. Автообновление сертификатов
```bash
# Создайте скрипт обновления
sudo tee /opt/renew-certs.sh > /dev/null <<'EOF'
#!/bin/bash
# Останавливаем Nginx для освобождения порта 80
docker compose -f /путь/к/docker-compose.yml down
# Обновляем сертификаты
certbot renew --quiet
# Копируем обновленные сертификаты
cp /etc/letsencrypt/live/hooks.domain.com/fullchain.pem /etc/ssl/private/hooks.fullchain.pem
cp /etc/letsencrypt/live/hooks.domain.com/privkey.pem /etc/ssl/private/hooks.privkey.pem
cp /etc/letsencrypt/live/miniapp.domain.com/fullchain.pem /etc/ssl/private/miniapp.fullchain.pem
cp /etc/letsencrypt/live/miniapp.domain.com/privkey.pem /etc/ssl/private/miniapp.privkey.pem
chmod 600 /etc/ssl/private/*.pem
# Запускаем Nginx обратно
docker compose -f /путь/к/docker-compose.yml up -d
EOF
# Сделайте скрипт исполняемым
sudo chmod +x /opt/renew-certs.sh
# Добавьте в cron (запуск каждый день в 3:00 AM)
echo "0 3 * * * /opt/renew-certs.sh" | sudo crontab -
```
#### Проверка работы SSL
```bash
curl -I https://hooks.domain.com
curl -I https://miniapp.domain.com
```
**Важно:**
- Замените `domain.com` и `your-email@example.com` на свои данные
- Замените `/путь/к/docker-compose.yml` на реальный путь к вашему docker-compose файлу
- Убедитесь, что DNS записи для доменов указывают на IP вашего сервера
---
## ⚙️ Конфигурация
@@ -1453,17 +1735,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
### 🆘 Частые вопросы
<details>
<summary><b>Как начать работу?</b></summary>
1. Скачайте репозиторий
2. Запустите `install_bot.sh`
3. Следуйте инструкциям установщика
4. Синхронизируйте серверы в админке
5. Готово! 🎉
</details>
<details>
<summary><b>Какие требования к серверу?</b></summary>
@@ -1498,18 +1769,26 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<details>
<summary><b>Как обновить бота?</b></summary>
**Через install_bot.sh (рекомендуется):**
```bash
./install_bot.sh
# Выбрать: 4. 🔄 Обновить проект из Git
```
**Вручную:**
```bash
git pull
docker compose down
docker compose pull
docker compose up -d
cd /root/remnawave-bedolaga-telegram-bot
# Обновление до последнего коммита (до последней мастер ветки, не всегда стабильно):
git pull origin main
# Обновление до конкретной версии (более стабильно, релиз версии):
git fetch --tags
git checkout v2.7.0
# Перезагружаем конты:
make reload
# Перезагружаем конты с логами:
make reload-follow
```
Скрипт автоматически создаст бэкап перед обновлением!
@@ -1523,12 +1802,6 @@ docker compose up -d
- Настройте в `.env`: `BACKUP_AUTO_ENABLED=true`
- Бэкапы создаются по расписанию
**Вручную через install_bot.sh:**
```bash
./install_bot.sh
# Выбрать: 5. 💾 Создать резервную копию
```
**Через админ-панель:**
- Админ панель → Настройки → Бэкапы → Создать
+12 -8
View File
@@ -194,6 +194,7 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
@@ -931,9 +932,12 @@ class Settings(BaseSettings):
return value
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and
self.YOOKASSA_SHOP_ID is not None and
return (self.YOOKASSA_ENABLED and
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def is_support_topup_enabled(self) -> bool:
return bool(self.SUPPORT_TOPUP_ENABLED)
def get_yookassa_return_url(self) -> str:
if self.YOOKASSA_RETURN_URL:
@@ -1332,13 +1336,13 @@ class Settings(BaseSettings):
packages = []
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
logger.info(f"CONFIG STRING: '{config_str}'")
logger.debug(f"CONFIG STRING: '{config_str}'")
if not config_str:
logger.info("CONFIG EMPTY, USING FALLBACK")
logger.debug("CONFIG EMPTY, USING FALLBACK")
return self._get_fallback_traffic_packages()
logger.info("PARSING CONFIG...")
logger.debug("PARSING CONFIG...")
for package_config in config_str.split(','):
package_config = package_config.strip()
@@ -1362,7 +1366,7 @@ class Settings(BaseSettings):
except ValueError:
continue
logger.info(f"PARSED {len(packages)} packages from config")
logger.debug(f"PARSED {len(packages)} packages from config")
return packages if packages else self._get_fallback_traffic_packages()
except Exception as e:
+6 -6
View File
@@ -988,8 +988,8 @@ async def calculate_subscription_total_cost(
]
}
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Базовый период: {base_price/100}")
logger.debug(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.debug(f" Базовый период: {base_price/100}")
if total_traffic_price > 0:
message = (
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}"
@@ -998,7 +998,7 @@ async def calculate_subscription_total_cost(
message += (
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_servers_price > 0:
message = (
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}"
@@ -1007,7 +1007,7 @@ async def calculate_subscription_total_cost(
message += (
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_devices_price > 0:
message = (
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}"
@@ -1016,8 +1016,8 @@ async def calculate_subscription_total_cost(
message += (
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_cost/100}")
logger.debug(message)
logger.debug(f" ИТОГО: {total_cost/100}")
return total_cost, details
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Iterable, Optional, Tuple
from sqlalchemy import and_, func, select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SubscriptionEvent
async def create_subscription_event(
db: AsyncSession,
*,
user_id: int,
event_type: str,
subscription_id: Optional[int] = None,
transaction_id: Optional[int] = None,
amount_kopeks: Optional[int] = None,
currency: Optional[str] = None,
message: Optional[str] = None,
occurred_at: Optional[datetime] = None,
extra: Optional[Dict[str, Any]] = None,
) -> SubscriptionEvent:
event = SubscriptionEvent(
user_id=user_id,
event_type=event_type,
subscription_id=subscription_id,
transaction_id=transaction_id,
amount_kopeks=amount_kopeks,
currency=currency,
message=message,
occurred_at=occurred_at or datetime.utcnow(),
extra=extra or None,
)
db.add(event)
await db.commit()
await db.refresh(event)
return event
async def list_subscription_events(
db: AsyncSession,
*,
limit: int,
offset: int,
event_types: Optional[Iterable[str]] = None,
user_id: Optional[int] = None,
) -> Tuple[list[SubscriptionEvent], int]:
base_query = select(SubscriptionEvent)
filters = []
if event_types:
filters.append(SubscriptionEvent.event_type.in_(set(event_types)))
if user_id:
filters.append(SubscriptionEvent.user_id == user_id)
if filters:
base_query = base_query.where(and_(*filters))
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(
base_query.options(selectinload(SubscriptionEvent.user))
.order_by(SubscriptionEvent.occurred_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all(), int(total)
+32 -10
View File
@@ -5,7 +5,8 @@ from typing import Optional, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import UserMessage
from app.database.models import User, UserMessage
from app.utils.validators import sanitize_html, validate_html_tags
logger = logging.getLogger(__name__)
@@ -13,15 +14,25 @@ logger = logging.getLogger(__name__)
async def create_user_message(
db: AsyncSession,
message_text: str,
created_by: int,
created_by: Optional[int] = None,
is_active: bool = True,
sort_order: int = 0
) -> UserMessage:
is_valid, error_message = validate_html_tags(message_text)
if not is_valid:
raise ValueError(error_message)
resolved_creator = created_by
if created_by is not None:
result = await db.execute(select(User.id).where(User.id == created_by))
resolved_creator = result.scalar_one_or_none()
message = UserMessage(
message_text=message_text,
is_active=is_active,
sort_order=sort_order,
created_by=created_by
created_by=resolved_creator,
)
db.add(message)
@@ -55,25 +66,33 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]:
return None
random_message = random.choice(active_messages)
return random_message.message_text
return sanitize_html(random_message.message_text)
async def get_all_user_messages(
db: AsyncSession,
offset: int = 0,
limit: int = 50
limit: int = 50,
include_inactive: bool = True,
) -> List[UserMessage]:
query = select(UserMessage).order_by(UserMessage.created_at.desc())
if not include_inactive:
query = query.where(UserMessage.is_active == True)
result = await db.execute(
select(UserMessage)
.order_by(UserMessage.created_at.desc())
query
.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)))
async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int:
query = select(func.count(UserMessage.id))
if not include_inactive:
query = query.where(UserMessage.is_active == True)
result = await db.execute(query)
return result.scalar()
@@ -88,8 +107,11 @@ async def update_user_message(
if not message:
return None
if message_text is not None:
is_valid, error_message = validate_html_tags(message_text)
if not is_valid:
raise ValueError(error_message)
message.message_text = message_text
if is_active is not None:
+114 -2
View File
@@ -1,10 +1,10 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WelcomeText
from app.database.models import User, WelcomeText
logger = logging.getLogger(__name__)
@@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
'id': None
}
async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]:
result = await db.execute(
select(WelcomeText).where(WelcomeText.id == welcome_text_id)
)
return result.scalar_one_or_none()
async def list_welcome_texts(
db: AsyncSession,
*,
include_inactive: bool = True,
limit: int = 50,
offset: int = 0,
):
query = select(WelcomeText).order_by(WelcomeText.updated_at.desc())
if not include_inactive:
query = query.where(WelcomeText.is_active == True)
result = await db.execute(query.limit(limit).offset(offset))
return result.scalars().all()
async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int:
query = select(func.count(WelcomeText.id))
if not include_inactive:
query = query.where(WelcomeText.is_active == True)
result = await db.execute(query)
return result.scalar()
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
try:
result = await db.execute(
@@ -113,6 +144,87 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
await db.rollback()
return False
async def create_welcome_text(
db: AsyncSession,
*,
text_content: str,
created_by: Optional[int] = None,
is_enabled: bool = True,
is_active: bool = True,
) -> WelcomeText:
resolved_creator = created_by
if created_by is not None:
result = await db.execute(select(User.id).where(User.id == created_by))
resolved_creator = result.scalar_one_or_none()
if is_active:
await db.execute(update(WelcomeText).values(is_active=False))
welcome_text = WelcomeText(
text_content=text_content,
is_active=is_active,
is_enabled=is_enabled,
created_by=resolved_creator,
)
db.add(welcome_text)
await db.commit()
await db.refresh(welcome_text)
logger.info(
"✅ Создан приветственный текст ID %s (активный=%s, включен=%s)",
welcome_text.id,
welcome_text.is_active,
welcome_text.is_enabled,
)
return welcome_text
async def update_welcome_text(
db: AsyncSession,
welcome_text: WelcomeText,
*,
text_content: Optional[str] = None,
is_enabled: Optional[bool] = None,
is_active: Optional[bool] = None,
) -> WelcomeText:
if is_active:
await db.execute(
update(WelcomeText)
.where(WelcomeText.id != welcome_text.id)
.values(is_active=False)
)
if text_content is not None:
welcome_text.text_content = text_content
if is_enabled is not None:
welcome_text.is_enabled = is_enabled
if is_active is not None:
welcome_text.is_active = is_active
welcome_text.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(welcome_text)
logger.info(
"📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)",
welcome_text.id,
welcome_text.is_active,
welcome_text.is_enabled,
)
return welcome_text
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
await db.delete(welcome_text)
await db.commit()
logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id)
async def get_current_welcome_text_or_default() -> str:
return (
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
+30 -1
View File
@@ -541,7 +541,11 @@ class PromoGroup(Base):
"traffic": self.traffic_discount_percent,
"devices": self.device_discount_percent,
}
percent = mapping.get(category, 0)
percent = mapping.get(category) or 0
if percent == 0 and self.is_default:
base_period_discount = self._get_period_discount(period_days)
percent = max(percent, base_period_discount)
return max(0, min(100, percent))
@@ -591,6 +595,7 @@ class User(Base):
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
referral_commission_percent = Column(Integer, nullable=True)
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
promo_offer_discount_source = Column(String(100), nullable=True)
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
@@ -1078,6 +1083,30 @@ class SentNotification(Base):
subscription = relationship("Subscription", backref="sent_notifications")
class SubscriptionEvent(Base):
__tablename__ = "subscription_events"
id = Column(Integer, primary_key=True, index=True)
event_type = Column(String(50), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
subscription_id = Column(
Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True
)
transaction_id = Column(
Integer, ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
)
amount_kopeks = Column(Integer, nullable=True)
currency = Column(String(16), nullable=True)
message = Column(Text, nullable=True)
occurred_at = Column(DateTime, nullable=False, default=func.now())
extra = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="subscription_events")
subscription = relationship("Subscription", backref="subscription_events")
transaction = relationship("Transaction", backref="subscription_events")
class DiscountOffer(Base):
__tablename__ = "discount_offers"
__table_args__ = (
+138 -1
View File
@@ -2742,6 +2742,35 @@ async def fix_foreign_keys_for_user_deletion():
logger.error(f"Ошибка обновления внешних ключей: {e}")
return False
async def add_referral_commission_percent_column() -> bool:
column_exists = await check_column_exists('users', 'referral_commission_percent')
if column_exists:
logger.info("️ Колонка referral_commission_percent уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
elif db_type == 'postgresql':
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
elif db_type == 'mysql':
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL"
else:
logger.error(f"Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}")
return False
await conn.execute(text(alter_sql))
logger.info("✅ Добавлена колонка referral_commission_percent в таблицу users")
return True
except Exception as error:
logger.error(f"Ошибка добавления referral_commission_percent: {error}")
return False
async def add_referral_system_columns():
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
@@ -2867,6 +2896,94 @@ async def create_subscription_conversions_table():
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
return False
async def create_subscription_events_table():
table_exists = await check_table_exists("subscription_events")
if table_exists:
logger.info("Таблица subscription_events уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE subscription_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(50) NOT NULL,
user_id INTEGER NOT NULL,
subscription_id INTEGER NULL,
transaction_id INTEGER NULL,
amount_kopeks INTEGER NULL,
currency VARCHAR(16) NULL,
message TEXT NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
extra JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
);
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE subscription_events (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL,
transaction_id INTEGER NULL REFERENCES transactions(id) ON DELETE SET NULL,
amount_kopeks INTEGER NULL,
currency VARCHAR(16) NULL,
message TEXT NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
extra JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
"""
elif db_type == "mysql":
create_sql = """
CREATE TABLE subscription_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
user_id INT NOT NULL,
subscription_id INT NULL,
transaction_id INT NULL,
amount_kopeks INT NULL,
currency VARCHAR(16) NULL,
message TEXT NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
extra JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
);
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы subscription_events: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("✅ Таблица subscription_events успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы subscription_events: {e}")
return False
async def fix_subscription_duplicates_universal():
async with engine.begin() as conn:
db_type = await get_database_type()
@@ -3721,6 +3838,12 @@ async def run_universal_migration():
if not referral_migration_success:
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
commission_column_ready = await add_referral_commission_percent_column()
if commission_column_ready:
logger.info("✅ Колонка referral_commission_percent готова")
else:
logger.warning("⚠️ Проблемы с колонкой referral_commission_percent")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
system_settings_ready = await create_system_settings_table()
if system_settings_ready:
@@ -4052,7 +4175,14 @@ async def run_universal_migration():
logger.info("✅ Таблица subscription_conversions готова")
else:
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_EVENTS ===")
events_created = await create_subscription_events_table()
if events_created:
logger.info("✅ Таблица subscription_events готова")
else:
logger.warning("⚠️ Проблемы с таблицей subscription_events")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
@@ -4089,6 +4219,7 @@ async def run_universal_migration():
logger.info("✅ CryptoBot таблица готова")
logger.info("✅ Heleket таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Таблица событий подписок создана")
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
logger.info("✅ Медиа поля в broadcast_history добавлены")
logger.info("✅ Дубликаты подписок исправлены")
@@ -4112,6 +4243,7 @@ async def check_migration_status():
"broadcast_history_media_fields": False,
"subscription_duplicates": False,
"subscription_conversions_table": False,
"subscription_events_table": False,
"promo_groups_table": False,
"server_promo_groups_table": False,
"server_squads_trial_column": False,
@@ -4126,6 +4258,7 @@ async def check_migration_status():
"users_promo_offer_discount_percent_column": False,
"users_promo_offer_discount_source_column": False,
"users_promo_offer_discount_expires_column": False,
"users_referral_commission_percent_column": False,
"subscription_crypto_link_column": False,
"discount_offers_table": False,
"discount_offers_effect_column": False,
@@ -4145,6 +4278,7 @@ async def check_migration_status():
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
status["public_offers_table"] = await check_table_exists('public_offers')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
status["subscription_events_table"] = await check_table_exists('subscription_events')
status["promo_groups_table"] = await check_table_exists('promo_groups')
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
@@ -4167,6 +4301,7 @@ async def check_migration_status():
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
media_fields_exist = (
@@ -4200,6 +4335,7 @@ async def check_migration_status():
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_events_table": "Таблица событий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок",
"promo_groups_table": "Таблица промо-групп",
"server_promo_groups_table": "Связи серверов и промогрупп",
@@ -4213,6 +4349,7 @@ async def check_migration_status():
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
+60 -5
View File
@@ -6,6 +6,7 @@ from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import InterfaceError
from sqlalchemy import select, func, and_, or_
from app.config import settings
@@ -17,6 +18,7 @@ from app.database.models import (
SubscriptionStatus,
BroadcastHistory,
)
from app.database.database import AsyncSessionLocal
from app.keyboards.admin import (
get_admin_messages_keyboard, get_broadcast_target_keyboard,
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
@@ -87,6 +89,56 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _persist_broadcast_result(
db: AsyncSession,
broadcast_history: BroadcastHistory,
sent_count: int,
failed_count: int,
status: str,
) -> None:
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = status
broadcast_history.completed_at = datetime.utcnow()
try:
await db.commit()
return
except InterfaceError as error:
logger.warning(
"Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз",
exc_info=error,
)
await db.rollback()
try:
async with AsyncSessionLocal() as retry_session:
retry_history = await retry_session.get(BroadcastHistory, broadcast_history.id)
if not retry_history:
logger.critical(
"Не удалось найти запись BroadcastHistory #%s для повторной записи результатов",
broadcast_history.id,
)
return
retry_history.sent_count = sent_count
retry_history.failed_count = failed_count
retry_history.status = status
retry_history.completed_at = broadcast_history.completed_at
await retry_session.commit()
logger.info(
"Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)",
broadcast_history.id,
)
except Exception as retry_error:
logger.critical(
"Не удалось сохранить результаты рассылки после восстановления подключения",
exc_info=retry_error,
)
@admin_required
@error_handler
async def show_messages_menu(
@@ -854,11 +906,14 @@ async def confirm_broadcast(
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.25)
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = "completed" if failed_count == 0 else "partial"
broadcast_history.completed_at = datetime.utcnow()
await db.commit()
status = "completed" if failed_count == 0 else "partial"
await _persist_broadcast_result(
db=db,
broadcast_history=broadcast_history,
sent_count=sent_count,
failed_count=failed_count,
status=status,
)
media_info = ""
if has_media:
+51 -6
View File
@@ -134,6 +134,24 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
label_en="💳 Base subscription price",
action="price",
),
SettingEntry(
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED",
section="core",
label_ru="🎟️ Базовые скидки для групп",
label_en="🎟️ Base group discounts",
action="toggle",
description_ru="Включает применение базовых скидок для групповых промо-периодов.",
description_en="Enables base discounts for promo group periods.",
),
SettingEntry(
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS",
section="core",
label_ru="🔖 Скидки по периодам",
label_en="🔖 Period discounts",
action="input",
description_ru="Формат: список пар дней и скидки через запятую (например 30:10,60:20).",
description_en="Format: comma-separated day/discount pairs (e.g. 30:10,60:20).",
),
SettingEntry(
key="DEFAULT_DEVICE_LIMIT",
section="core",
@@ -205,6 +223,26 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
}
SETTING_ENTRIES: Tuple[SettingEntry, ...] = tuple(
entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
)
SETTING_KEY_TO_TOKEN: Dict[str, str] = {
entry.key: f"s{index}" for index, entry in enumerate(SETTING_ENTRIES)
}
SETTING_TOKEN_TO_KEY: Dict[str, str] = {
token: key for key, token in SETTING_KEY_TO_TOKEN.items()
}
def _encode_setting_callback_key(key: str) -> str:
return SETTING_KEY_TO_TOKEN.get(key, key)
def _decode_setting_callback_key(raw: str) -> str:
return SETTING_TOKEN_TO_KEY.get(raw, raw)
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
@@ -447,7 +485,9 @@ def _build_settings_section(
[
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_pricing_toggle:{section}:{entry.key}",
callback_data=(
f"admin_pricing_toggle:{section}:{_encode_setting_callback_key(entry.key)}"
),
)
]
)
@@ -461,7 +501,7 @@ def _build_settings_section(
types.InlineKeyboardButton(
text=f"{icon} {option.label(lang_code)}",
callback_data=(
f"admin_pricing_choice:{section}:{entry.key}:{option.value}"
f"admin_pricing_choice:{section}:{_encode_setting_callback_key(entry.key)}:{option.value}"
),
)
)
@@ -477,7 +517,9 @@ def _build_settings_section(
[
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_pricing_setting:{section}:{entry.key}",
callback_data=(
f"admin_pricing_setting:{section}:{_encode_setting_callback_key(entry.key)}"
),
)
]
)
@@ -939,11 +981,12 @@ async def start_setting_edit(
state: FSMContext,
) -> None:
try:
_, section, key = callback.data.split(":", 2)
_, section, raw_key = callback.data.split(":", 2)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
entry = SETTING_ENTRY_BY_KEY.get(key)
texts = get_texts(db_user.language)
lang_code = _language_code(db_user.language)
@@ -1140,11 +1183,12 @@ async def toggle_setting(
state: FSMContext,
) -> None:
try:
_, section, key = callback.data.split(":", 2)
_, section, raw_key = callback.data.split(":", 2)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
entry = SETTING_ENTRY_BY_KEY.get(key)
if not entry or entry.action != "toggle":
await callback.answer()
@@ -1171,11 +1215,12 @@ async def select_setting_choice(
state: FSMContext,
) -> None:
try:
_, section, key, value_raw = callback.data.split(":", 3)
_, section, raw_key, value_raw = callback.data.split(":", 3)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
entry = SETTING_ENTRY_BY_KEY.get(key)
if not entry or entry.action != "choice" or not entry.choices:
await callback.answer()
+54
View File
@@ -2285,6 +2285,9 @@ async def show_sync_options(
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
"• Рекомендуется делать полную синхронизацию ежедневно\n"
"• Баланс пользователей НЕ удаляется\n\n"
"⬆️ <b>Обратная синхронизация:</b>\n"
"• Отправляет активных пользователей из бота в панель\n"
"• Используйте при сбоях панели или для восстановления данных\n\n"
+ "\n".join(status_lines)
)
@@ -2295,6 +2298,12 @@ async def show_sync_options(
callback_data="sync_all_users",
)
],
[
types.InlineKeyboardButton(
text="⬆️ Синхронизация в панель",
callback_data="sync_to_panel",
)
],
[
types.InlineKeyboardButton(
text="⚙️ Настройки автосинхронизации",
@@ -2654,6 +2663,50 @@ async def sync_all_users(
)
await callback.answer()
@admin_required
@error_handler
async def sync_users_to_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
await callback.message.edit_text(
"⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\n"
"Это может занять несколько минут.",
reply_markup=None,
)
remnawave_service = RemnaWaveService()
stats = await remnawave_service.sync_users_to_panel(db)
if stats["errors"] == 0:
status_emoji = ""
status_text = "успешно завершена"
else:
status_emoji = "⚠️" if (stats["created"] + stats["updated"]) > 0 else ""
status_text = "завершена с предупреждениями" if status_emoji == "⚠️" else "завершена с ошибками"
text = (
f"{status_emoji} <b>Синхронизация в панель {status_text}</b>\n\n"
"📊 <b>Результаты:</b>\n"
f"• 🆕 Создано: {stats['created']}\n"
f"• 🔄 Обновлено: {stats['updated']}\n"
f"• ❌ Ошибок: {stats['errors']}"
)
keyboard = [
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="sync_to_panel")],
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def show_sync_recommendations(
@@ -3126,6 +3179,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
dp.callback_query.register(sync_users_to_panel, F.data == "sync_to_panel")
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
+24 -12
View File
@@ -12,6 +12,11 @@ from app.database.crud.user_message import (
)
from app.database.models import User
from app.keyboards.admin import get_admin_main_keyboard
from app.utils.validators import (
get_html_help_text,
sanitize_html,
validate_html_tags,
)
from app.utils.decorators import admin_required, error_handler
from app.localization.texts import get_texts
@@ -122,8 +127,6 @@ async def add_user_message_start(
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"
@@ -161,8 +164,6 @@ async def process_new_message_text(
)
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(
@@ -312,20 +313,22 @@ async def view_user_message(
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
safe_content = sanitize_html(message.message_text)
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>"
f"<blockquote>{safe_content}</blockquote>"
)
await callback.message.edit_text(
@@ -455,7 +458,7 @@ async def edit_user_message_start(
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"<blockquote>{sanitize_html(message.message_text)}</blockquote>\n\n"
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
parse_mode="HTML"
)
@@ -489,14 +492,23 @@ async def process_edit_message_text(
return
new_text = message.text.strip()
if len(new_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
is_valid, error_msg = validate_html_tags(new_text)
if not is_valid:
await message.answer(
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
parse_mode=None
)
return
try:
updated_message = await update_user_message(
db=db,
@@ -511,7 +523,7 @@ async def process_edit_message_text(
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>",
f"<blockquote>{sanitize_html(new_text)}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
+301 -2
View File
@@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
from app.utils.user_utils import get_effective_referral_commission_percent
from app.services.remnawave_service import RemnaWaveService
from app.external.remnawave_api import TrafficLimitStrategy
from app.database.crud.server_squad import (
@@ -1536,6 +1537,9 @@ async def _build_user_referrals_view(
referrals = await get_referrals(db, user_id)
effective_percent = get_effective_referral_commission_percent(user)
default_percent = settings.REFERRAL_COMMISSION_PERCENT
header = texts.t(
"ADMIN_USER_REFERRALS_TITLE",
"🤝 <b>Рефералы пользователя</b>",
@@ -1551,6 +1555,24 @@ async def _build_user_referrals_view(
lines: List[str] = [header, summary]
if user.referral_commission_percent is None:
lines.append(
texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT",
"• Процент комиссии: {percent}% (стандартное значение)",
).format(percent=effective_percent)
)
else:
lines.append(
texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM",
"• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
).format(
percent=user.referral_commission_percent,
default_percent=default_percent,
)
)
if referrals:
lines.append(
texts.t(
@@ -1604,6 +1626,15 @@ async def _build_user_referrals_view(
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON",
"📈 Изменить процент",
),
callback_data=f"admin_user_referral_percent_{user_id}",
)
],
[
InlineKeyboardButton(
text=texts.t(
@@ -1636,12 +1667,12 @@ async def show_user_referrals(
user_id = int(callback.data.split('_')[-1])
current_state = await state.get_state()
if current_state == AdminStates.editing_user_referrals:
if current_state in {AdminStates.editing_user_referrals, AdminStates.editing_user_referral_percent}:
data = await state.get_data()
preserved_data = {
key: value
for key, value in data.items()
if key not in {"editing_referrals_user_id", "referrals_message_id"}
if key not in {"editing_referrals_user_id", "referrals_message_id", "editing_referral_percent_user_id"}
}
await state.clear()
if preserved_data:
@@ -1661,6 +1692,256 @@ async def show_user_referrals(
await callback.answer()
@admin_required
@error_handler
async def start_edit_referral_percent(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
texts = get_texts(db_user.language)
effective_percent = get_effective_referral_commission_percent(user)
default_percent = settings.REFERRAL_COMMISSION_PERCENT
prompt = texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT",
(
"📈 <b>Индивидуальный процент реферальной комиссии</b>\n\n"
"Текущее значение: {current}%\n"
"Стандартное значение: {default}%\n\n"
"Отправьте новое значение от 0 до 100 или слово 'стандарт' для сброса."
),
).format(current=effective_percent, default=default_percent)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="5%",
callback_data=f"admin_user_referral_percent_set_{user_id}_5",
),
InlineKeyboardButton(
text="10%",
callback_data=f"admin_user_referral_percent_set_{user_id}_10",
),
],
[
InlineKeyboardButton(
text="15%",
callback_data=f"admin_user_referral_percent_set_{user_id}_15",
),
InlineKeyboardButton(
text="20%",
callback_data=f"admin_user_referral_percent_set_{user_id}_20",
),
],
[
InlineKeyboardButton(
text=texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON",
"♻️ Сбросить на стандартный",
),
callback_data=f"admin_user_referral_percent_reset_{user_id}",
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_user_referrals_{user_id}",
)
],
]
)
await state.update_data(editing_referral_percent_user_id=user_id)
await state.set_state(AdminStates.editing_user_referral_percent)
await callback.message.edit_text(
prompt,
reply_markup=keyboard,
)
await callback.answer()
async def _update_referral_commission_percent(
db: AsyncSession,
user_id: int,
percent: Optional[int],
admin_id: int,
) -> Tuple[bool, Optional[int]]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False, None
user.referral_commission_percent = percent
user.updated_at = datetime.utcnow()
await db.commit()
effective = get_effective_referral_commission_percent(user)
logger.info(
"Админ %s обновил реферальный процент пользователя %s: %s",
admin_id,
user_id,
percent,
)
return True, effective
except Exception as e:
logger.error(
"Ошибка обновления реферального процента пользователя %s: %s",
user_id,
e,
)
try:
await db.rollback()
except Exception as rollback_error:
logger.error("Ошибка отката транзакции: %s", rollback_error)
return False, None
async def _render_referrals_after_update(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
user_id: int,
success_message: str,
):
view = await _build_user_referrals_view(db, db_user.language, user_id)
if view:
text, keyboard = view
text = f"{success_message}\n\n" + text
await callback.message.edit_text(text, reply_markup=keyboard)
else:
await callback.message.edit_text(success_message)
@admin_required
@error_handler
async def set_referral_percent_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split('_')
if "reset" in parts:
user_id = int(parts[-1])
percent_value: Optional[int] = None
else:
user_id = int(parts[-2])
percent_value = int(parts[-1])
texts = get_texts(db_user.language)
success, effective_percent = await _update_referral_commission_percent(
db,
user_id,
percent_value,
db_user.id,
)
if not success:
await callback.answer("❌ Не удалось обновить процент", show_alert=True)
return
await state.clear()
success_message = texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
"✅ Процент обновлён: {percent}%",
).format(percent=effective_percent)
await _render_referrals_after_update(callback, db, db_user, user_id, success_message)
await callback.answer()
@admin_required
@error_handler
async def process_referral_percent_input(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
user_id = data.get("editing_referral_percent_user_id")
if not user_id:
await message.answer("❌ Не удалось определить пользователя")
return
raw_text = message.text.strip()
normalized = raw_text.lower()
percent_value: Optional[int]
if normalized in {"стандарт", "standard", "default"}:
percent_value = None
else:
normalized_number = raw_text.replace(',', '.').strip()
try:
percent_float = float(normalized_number)
except (TypeError, ValueError):
await message.answer(
get_texts(db_user.language).t(
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
"❌ Введите число от 0 до 100 или слово 'стандарт'",
)
)
return
percent_value = int(round(percent_float))
if percent_value < 0 or percent_value > 100:
await message.answer(
get_texts(db_user.language).t(
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
"❌ Введите число от 0 до 100 или слово 'стандарт'",
)
)
return
texts = get_texts(db_user.language)
success, effective_percent = await _update_referral_commission_percent(
db,
int(user_id),
percent_value,
db_user.id,
)
if not success:
await message.answer("❌ Не удалось обновить процент")
return
await state.clear()
success_message = texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
"✅ Процент обновлён: {percent}%",
).format(percent=effective_percent)
view = await _build_user_referrals_view(db, db_user.language, int(user_id))
if view:
text, keyboard = view
await message.answer(f"{success_message}\n\n{text}", reply_markup=keyboard)
else:
await message.answer(success_message)
@admin_required
@error_handler
async def start_edit_user_referrals(
@@ -4621,6 +4902,24 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
)
dp.callback_query.register(
start_edit_referral_percent,
F.data.startswith("admin_user_referral_percent_")
& ~F.data.contains("_set_")
& ~F.data.contains("_reset")
)
dp.callback_query.register(
set_referral_percent_button,
F.data.startswith("admin_user_referral_percent_set_")
| F.data.startswith("admin_user_referral_percent_reset_")
)
dp.message.register(
process_referral_percent_input,
AdminStates.editing_user_referral_percent,
)
dp.callback_query.register(
start_edit_user_referrals,
F.data.startswith("admin_user_referrals_edit_")
+11 -1
View File
@@ -421,7 +421,17 @@ async def request_support_topup(
db_user: User
):
texts = get_texts(db_user.language)
if not settings.is_support_topup_enabled():
await callback.answer(
texts.t(
"SUPPORT_TOPUP_DISABLED",
"Пополнение через поддержку отключено. Попробуйте другой способ оплаты.",
),
show_alert=True,
)
return
support_text = f"""
🛠 <b>Пополнение через поддержку</b>
+2
View File
@@ -59,6 +59,8 @@ async def activate_promocode_for_registration(
user,
result.get("promocode", {"code": code}),
result["description"],
result.get("balance_before_kopeks"),
result.get("balance_after_kopeks"),
)
except Exception as notify_error:
logger.error(
+2 -1
View File
@@ -14,6 +14,7 @@ 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_effective_referral_commission_percent,
get_referral_analytics,
get_user_referral_summary,
)
@@ -86,7 +87,7 @@ async def show_referral_info(
+ texts.t(
"REFERRAL_REWARD_COMMISSION",
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
).format(percent=get_effective_referral_commission_percent(db_user))
+ "\n\n"
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
+ f"\n<code>{referral_link}</code>\n\n"
+24 -6
View File
@@ -134,8 +134,11 @@ async def start_simple_subscription_purchase(
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
traffic_limit_gb = subscription_params["traffic_limit_gb"]
traffic_label = "Безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ"
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
@@ -523,8 +526,11 @@ async def handle_simple_subscription_pay_with_balance(
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_traffic_gb = subscription_params["traffic_limit_gb"]
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {success_traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
@@ -721,8 +727,11 @@ async def handle_simple_subscription_other_payment_methods(
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
payment_traffic_gb = subscription_params["traffic_limit_gb"]
payment_traffic_label = "Безлимит" if payment_traffic_gb == 0 else f"{payment_traffic_gb} ГБ"
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {payment_traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
@@ -826,6 +835,9 @@ async def handle_simple_subscription_payment_method(
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
stars_traffic_gb = subscription_params["traffic_limit_gb"]
stars_traffic_label = "Безлимит" if stars_traffic_gb == 0 else f"{stars_traffic_gb} ГБ"
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=f"Подписка на {subscription_params['period_days']} дней",
@@ -833,7 +845,7 @@ async def handle_simple_subscription_payment_method(
f"Простая покупка подписки\n"
f"Период: {subscription_params['period_days']} дней\n"
f"Устройства: {subscription_params['device_limit']}\n"
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
f"Трафик: {stars_traffic_label}"
),
payload=(
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
@@ -977,8 +989,11 @@ async def handle_simple_subscription_payment_method(
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
yookassa_traffic_gb = subscription_params["traffic_limit_gb"]
yookassa_traffic_label = "Безлимит" if yookassa_traffic_gb == 0 else f"{yookassa_traffic_gb} ГБ"
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {yookassa_traffic_label}",
f"💰 Сумма: {settings.format_price(price_kopeks)}",
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
"",
@@ -2220,8 +2235,11 @@ async def confirm_simple_subscription_purchase(
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_traffic_gb = subscription_params["traffic_limit_gb"]
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {success_traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
+244 -50
View File
@@ -20,18 +20,20 @@ from app.database.crud.campaign import (
from app.database.models import UserStatus, SubscriptionStatus
from app.keyboards.inline import (
get_rules_keyboard,
get_privacy_policy_keyboard,
get_main_menu_keyboard,
get_post_registration_keyboard,
get_language_selection_keyboard,
)
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts, get_rules
from app.localization.texts import get_texts, get_rules, get_privacy_policy
from app.services.referral_service import process_referral_registration
from app.services.campaign_service import AdvertisingCampaignService
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.utils.user_utils import generate_unique_referral_code
from app.utils.promo_offer import (
build_promo_offer_hint,
@@ -104,6 +106,7 @@ async def handle_potential_referral_code(
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_privacy_policy_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None
]:
@@ -611,12 +614,105 @@ async def process_language_selection(
)
async def _show_privacy_policy_after_rules(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
language: str,
) -> bool:
"""
Показывает политику конфиденциальности после принятия правил.
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
"""
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
if not policy or not policy.is_enabled:
logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ")
return False
if not policy.content or not policy.content.strip():
privacy_policy_text = get_privacy_policy(language)
if not privacy_policy_text or not privacy_policy_text.strip():
logger.info("⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ")
return False
logger.info(f"🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка {language}")
else:
privacy_policy_text = policy.content
logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}")
try:
await callback.message.edit_text(
privacy_policy_text,
reply_markup=get_privacy_policy_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info(f"🔒 Политика конфиденциальности отправлена пользователю {callback.from_user.id}")
return True
except Exception as e:
logger.error(f"Ошибка при показе политики конфиденциальности: {e}", exc_info=True)
try:
await callback.message.answer(
privacy_policy_text,
reply_markup=get_privacy_policy_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info(f"🔒 Политика конфиденциальности отправлена новым сообщением пользователю {callback.from_user.id}")
return True
except Exception as e2:
logger.error(f"Критическая ошибка при отправке политики конфиденциальности: {e2}", exc_info=True)
return False
async def _continue_registration_after_rules(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
language: str,
) -> None:
"""
Продолжает регистрацию после принятия правил (реферальный код или завершение).
"""
data = await state.get_data() or {}
texts = get_texts(language)
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
if settings.SKIP_REFERRAL_CODE:
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.edit_text(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
async def process_rules_accept(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
"""
Обрабатывает принятие или отклонение правил пользователем.
"""
logger.info(f"📋 RULES: Начало обработки правил")
logger.info(f"📊 Callback data: {callback.data}")
logger.info(f"👤 User: {callback.from_user.id}")
@@ -637,50 +733,16 @@ async def process_rules_accept(
if callback.data == 'rules_accept':
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с правилами удалено")
except Exception as e:
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
try:
await callback.message.edit_text(
texts.t(
"RULES_ACCEPTED_PROCESSING",
"✅ Правила приняты! Завершаем регистрацию...",
),
reply_markup=None
)
except Exception:
pass
# Пытаемся показать политику конфиденциальности
policy_shown = await _show_privacy_policy_after_rules(
callback, state, db, language
)
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
if settings.SKIP_REFERRAL_CODE:
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.answer(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
# Если политика не была показана, продолжаем регистрацию
if not policy_shown:
await _continue_registration_after_rules(
callback, state, db, language
)
else:
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
@@ -697,10 +759,13 @@ async def process_rules_accept(
)
except Exception as e:
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
await callback.message.edit_text(
rules_required_text,
reply_markup=get_rules_keyboard(language)
)
try:
await callback.message.edit_text(
rules_required_text,
reply_markup=get_rules_keyboard(language)
)
except:
pass
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
@@ -727,6 +792,128 @@ async def process_rules_accept(
pass
async def process_privacy_policy_accept(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности")
logger.info(f"📊 Callback data: {callback.data}")
logger.info(f"👤 User: {callback.from_user.id}")
current_state = await state.get_state()
logger.info(f"📊 Текущее состояние: {current_state}")
language = DEFAULT_LANGUAGE
texts = get_texts(language)
try:
await callback.answer()
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
if callback.data == 'privacy_policy_accept':
logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}")
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено")
except Exception as e:
logger.warning(f"⚠️ Не удалось удалить сообщение с политикой конфиденциальности: {e}")
try:
await callback.message.edit_text(
texts.t(
"PRIVACY_POLICY_ACCEPTED_PROCESSING",
"✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
),
reply_markup=None
)
except Exception:
pass
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
if settings.SKIP_REFERRAL_CODE:
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
await complete_registration_from_callback(callback, state, db)
else:
try:
await state.set_data(data)
await state.set_state(RegistrationStates.waiting_for_referral_code)
await callback.bot.send_message(
chat_id=callback.from_user.id,
text=texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
else:
logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}")
privacy_policy_required_text = texts.t(
"PRIVACY_POLICY_REQUIRED",
"Для использования бота необходимо принять политику конфиденциальности.",
)
try:
await callback.message.edit_text(
privacy_policy_required_text,
reply_markup=get_privacy_policy_keyboard(language)
)
except Exception as e:
logger.error(f"Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}")
try:
await callback.message.edit_text(
privacy_policy_required_text,
reply_markup=get_privacy_policy_keyboard(language)
)
except:
pass
logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}")
except Exception as e:
logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True)
await callback.answer(
texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."),
show_alert=True,
)
try:
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
await callback.message.answer(
texts.t(
"ERROR_PRIVACY_POLICY_RETRY",
"Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
),
reply_markup=get_privacy_policy_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
except:
pass
async def process_referral_code_input(
message: types.Message,
state: FSMContext,
@@ -1751,7 +1938,14 @@ def register_handlers(dp: Dispatcher):
StateFilter(RegistrationStates.waiting_for_rules_accept)
)
logger.info("✅ Зарегистрирован process_rules_accept")
dp.callback_query.register(
process_privacy_policy_accept,
F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]),
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept)
)
logger.info("✅ Зарегистрирован process_privacy_policy_accept")
dp.callback_query.register(
process_language_selection,
F.data.startswith("language_select:"),
+6
View File
@@ -1118,6 +1118,12 @@ def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="sync_all_users"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SYNC_TO_PANEL", "⬆️ Синхронизация в панель"),
callback_data="sync_to_panel"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"),
+42 -7
View File
@@ -127,6 +127,21 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
]
])
def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.PRIVACY_POLICY_ACCEPT,
callback_data="privacy_policy_accept"
),
InlineKeyboardButton(
text=texts.PRIVACY_POLICY_DECLINE,
callback_data="privacy_policy_decline"
)
]
])
def get_channel_sub_keyboard(
channel_link: Optional[str],
language: str = DEFAULT_LANGUAGE,
@@ -743,6 +758,7 @@ def get_subscription_keyboard(
texts = get_texts(language)
keyboard = []
has_direct_payment_methods = False
if has_subscription:
subscription_link = get_display_subscription_link(subscription) if subscription else None
@@ -1131,6 +1147,7 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
has_direct_payment_methods = False
amount_kopeks = max(0, int(amount_kopeks or 0))
@@ -1146,6 +1163,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("stars")
)
])
has_direct_payment_methods = True
if settings.is_yookassa_enabled():
if settings.YOOKASSA_SBP_ENABLED:
@@ -1155,6 +1173,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("yookassa_sbp"),
)
])
has_direct_payment_methods = True
keyboard.append([
InlineKeyboardButton(
@@ -1162,6 +1181,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("yookassa"),
)
])
has_direct_payment_methods = True
if settings.TRIBUTE_ENABLED:
keyboard.append([
@@ -1170,6 +1190,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("tribute")
)
])
has_direct_payment_methods = True
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
@@ -1182,6 +1203,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("mulenpay")
)
])
has_direct_payment_methods = True
if settings.is_wata_enabled():
keyboard.append([
@@ -1190,6 +1212,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("wata")
)
])
has_direct_payment_methods = True
if settings.is_pal24_enabled():
keyboard.append([
@@ -1198,6 +1221,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("pal24")
)
])
has_direct_payment_methods = True
if settings.is_platega_enabled() and settings.get_platega_active_methods():
keyboard.append([
@@ -1206,6 +1230,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("platega"),
)
])
has_direct_payment_methods = True
if settings.is_cryptobot_enabled():
keyboard.append([
@@ -1214,6 +1239,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("cryptobot")
)
])
has_direct_payment_methods = True
if settings.is_heleket_enabled():
keyboard.append([
@@ -1222,15 +1248,24 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("heleket")
)
])
has_direct_payment_methods = True
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
callback_data="topup_support"
)
])
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
callback_data="topup_support"
)
])
if len(keyboard) == 1:
if not keyboard:
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
callback_data="payment_methods_unavailable"
)
])
elif not has_direct_payment_methods and settings.is_support_topup_enabled():
keyboard.insert(0, [
InlineKeyboardButton(
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
+9
View File
@@ -32,3 +32,12 @@ RULES_TEXT: |
2. Do not distribute spam or malicious content.
3. Respect other community members.
PAYMENT_METHODS_NONE_AVAILABLE: |
💳 <b>Balance top-up methods</b>
⚠️ Payment methods are temporarily unavailable.
Please try again later.
Choose a top-up option:
SUPPORT_TOPUP_DISABLED: "Top-ups via support are disabled. Please use another payment method."
+9
View File
@@ -32,3 +32,12 @@ RULES_TEXT: |
2. Не распространяйте спам и вредоносный контент.
3. Уважайте других пользователей.
PAYMENT_METHODS_NONE_AVAILABLE: |
💳 <b>Способы пополнения баланса</b>
⚠️ В данный момент способы оплаты временно недоступны.
Попробуйте позже.
Выберите способ пополнения:
SUPPORT_TOPUP_DISABLED: "Пополнение через поддержку отключено. Попробуйте другой способ оплаты."
+22
View File
@@ -731,6 +731,22 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "️ The user is already in this promo group.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Referrals",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>User referrals</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Total referrals: {count}",
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Commission percent: {percent}% (default)",
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Custom percent: {percent}% (default: {default_percent}%)",
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Change percent",
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Custom referral commission</b>\n\nCurrent value: {current}%\nDefault value: {default}%\n\nSend a value from 0 to 100 or the word 'standard' to reset.",
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Reset to default",
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Percent updated: {percent}%",
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Enter a number from 0 to 100 or the word 'standard'",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>List of referrals:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … and {count} more referrals",
"ADMIN_USER_REFERRALS_EMPTY": "No referrals yet.",
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ To change the list, tap “✏️ Edit” below.",
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Edit",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
@@ -1214,8 +1230,14 @@
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
"RULES_DECLINE": "❌ I do not accept",
"RULES_HEADER": "📋 <b>Service Rules</b>",
"PRIVACY_POLICY_ACCEPT": "✅ Accept",
"PRIVACY_POLICY_DECLINE": "❌ Decline",
"PRIVACY_POLICY_REQUIRED": "You must accept the privacy policy to use the bot.",
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Privacy policy accepted! Continuing registration...",
"ERROR_PRIVACY_POLICY_RETRY": "An error occurred. Please try accepting the privacy policy again:",
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Privacy Policy</b>\n\nWe are committed to protecting your privacy and personal data.\n\n<b>Data Collection:</b>\n• We collect only necessary information to provide services\n• Data is used exclusively for service operation\n\n<b>Data Protection:</b>\n• Your data is protected by modern encryption methods\n• We do not share data with third parties without your consent\n\n<b>Your Rights:</b>\n• You can request information about stored data\n• You can request deletion of your data\n\n<b>By using the service, you agree to the privacy policy.</b>",
"SELECT_COUNTRIES": "Select countries:",
"SELECT_DEVICES": "Number of devices:",
"SELECT_PERIOD": "Choose period:",
+14
View File
@@ -688,6 +688,7 @@
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
"ADMIN_SYNC_BACK": "⬅️ К синхронизации",
"ADMIN_SYNC_TO_PANEL": "⬆️ Синхронизация в панель",
"ADMIN_SYNC_CLEANUP": "🧹 Очистка",
"ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
"ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
@@ -734,6 +735,13 @@
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Процент комиссии: {percent}% (стандартное значение)",
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Изменить процент",
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Индивидуальный процент реферальной комиссии</b>\n\nТекущее значение: {current}%\nСтандартное значение: {default}%\n\nОтправьте новое значение от 0 до 100 или слово 'стандарт' для сброса.",
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Сбросить на стандартный",
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Процент обновлён: {percent}%",
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введите число от 0 до 100 или слово 'стандарт'",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
@@ -1234,8 +1242,14 @@
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
"RULES_DECLINE": "❌ Не принимаю",
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
"PRIVACY_POLICY_ACCEPT": "✅ Принять",
"PRIVACY_POLICY_DECLINE": "❌ Отклонить",
"PRIVACY_POLICY_REQUIRED": "Для использования бота необходимо принять политику конфиденциальности.",
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
"ERROR_PRIVACY_POLICY_RETRY": "Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Политика конфиденциальности</b>\n\nМы обязуемся защищать вашу конфиденциальность и личные данные.\n\n<b>Сбор данных:</b>\n• Мы собираем только необходимую информацию для предоставления услуг\n• Данные используются исключительно для работы сервиса\n\n<b>Защита данных:</b>\n• Ваши данные защищены современными методами шифрования\n• Мы не передаем данные третьим лицам без вашего согласия\n\n<b>Ваши права:</b>\n• Вы можете запросить информацию о хранимых данных\n• Вы можете запросить удаление ваших данных\n\n<b>Используя сервис, вы соглашаетесь с политикой конфиденциальности.</b>",
"SELECT_COUNTRIES": "Выберите страны:",
"SELECT_DEVICES": "Количество устройств:",
"SELECT_PERIOD": "Выберите период:",
+14 -6
View File
@@ -687,6 +687,7 @@
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Підтримка**\n\n",
"ADMIN_SUPPORT_TICKETS": "🎫 Тікети підтримки",
"ADMIN_SYNC_BACK": "⬅️ До синхронізації",
"ADMIN_SYNC_TO_PANEL": "⬆️ Синхронізація в панель",
"ADMIN_SYNC_CLEANUP": "🧹 Очищення",
"ADMIN_SYNC_CONFIRM": "✅ Підтвердити",
"ADMIN_SYNC_FULL": "🔄 Повна синхронізація",
@@ -730,12 +731,19 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Користувач вже перебуває у цій промогрупі.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ До користувача",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогрупа",
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Реферали",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Реферали користувача</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всього рефералів: {count}",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералів:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … і ще {count} рефералів",
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Реферали",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Реферали користувача</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всього рефералів: {count}",
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Відсоток комісії: {percent}% (стандартне значення)",
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Індивідуальний відсоток: {percent}% (стандарт: {default_percent}%)",
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Змінити відсоток",
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Індивідуальний відсоток реферальної комісії</b>\n\nПоточне значення: {current}%\nСтандартне значення: {default}%\n\nНадішліть нове значення від 0 до 100 або слово 'стандарт' для скидання.",
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Скинути на стандартний",
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Відсоток оновлено: {percent}%",
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введіть число від 0 до 100 або слово 'стандарт'",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералів:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … і ще {count} рефералів",
"ADMIN_USER_REFERRALS_EMPTY": "Рефералів поки немає.",
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Щоб змінити список, натисніть «✏️ Редагувати» нижче.",
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редагувати",
+7
View File
@@ -733,6 +733,13 @@
"ADMIN_USER_REFERRALS_BUTTON":"🤝推荐",
"ADMIN_USER_REFERRALS_TITLE":"🤝<b>用户推荐</b>",
"ADMIN_USER_REFERRALS_SUMMARY":"👤{name}(ID:<code>{telegram_id}</code>)\n👥总推荐数:{count}",
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT":"• 佣金比例:{percent}%(默认值)",
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM":"• 自定义比例:{percent}%(默认:{default_percent}%",
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON":"📈 修改比例",
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT":"📈 <b>自定义推荐佣金比例</b>\n\n当前值:{current}%\n默认值:{default}%\n\n发送0到100之间的数值或输入“标准”以恢复默认。",
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON":"♻️ 恢复默认",
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED":"✅ 比例已更新:{percent}%",
"ADMIN_USER_REFERRAL_COMMISSION_INVALID":"❌ 请输入0到100之间的数字或“标准”",
"ADMIN_USER_REFERRALS_LIST_HEADER":"<b>推荐列表:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM":"•{name}(ID:<code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED":"•…以及其他{count}个推荐",
+13
View File
@@ -234,6 +234,19 @@ def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
return fallback.get(default_key, "")
def _get_default_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
default_key = "PRIVACY_POLICY_TEXT_DEFAULT"
locale = load_locale(language)
if default_key in locale:
return locale[default_key]
fallback = load_locale(DEFAULT_LANGUAGE)
return fallback.get(default_key, "")
def get_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
return _get_default_privacy_policy(language)
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
if language in _cached_rules:
return _cached_rules[language]
+2 -1
View File
@@ -118,6 +118,7 @@ class AuthMiddleware(BaseMiddleware):
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_privacy_policy_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
@@ -128,7 +129,7 @@ class AuthMiddleware(BaseMiddleware):
isinstance(event, CallbackQuery)
and event.data
and (
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
event.data in ['rules_accept', 'rules_decline', 'privacy_policy_accept', 'privacy_policy_decline', 'referral_skip']
or event.data.startswith('language_select:')
)
)
+233 -15
View File
@@ -8,6 +8,7 @@ from sqlalchemy.exc import MissingGreenlet
from app.config import settings
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.subscription_event import create_subscription_event
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
from app.database.models import (
@@ -92,6 +93,51 @@ class AdminNotificationService:
return "IDUnknown"
return f"ID{telegram_id}"
async def _record_subscription_event(
self,
db: AsyncSession,
*,
event_type: str,
user: User,
subscription: Subscription | None,
transaction: Transaction | None = None,
amount_kopeks: int | None = None,
message: str | None = None,
extra: Dict[str, Any] | None = None,
occurred_at: datetime | None = None,
) -> None:
"""Persist subscription-related event for external dashboards."""
try:
await create_subscription_event(
db,
user_id=user.id,
event_type=event_type,
subscription_id=subscription.id if subscription else None,
transaction_id=transaction.id if transaction else None,
amount_kopeks=amount_kopeks,
currency=None,
message=message,
occurred_at=occurred_at,
extra=extra or None,
)
except Exception:
logger.error(
"Не удалось сохранить событие подписки (%s) для пользователя %s",
event_type,
getattr(user, "id", "unknown"),
exc_info=True,
)
try:
await db.rollback()
except Exception:
logger.error(
"Не удалось выполнить rollback после ошибки события подписки пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
@@ -194,10 +240,27 @@ class AdminNotificationService:
*,
charged_amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
try:
await self._record_subscription_event(
db,
event_type="activation",
user=user,
subscription=subscription,
transaction=None,
amount_kopeks=charged_amount_kopeks,
message="Trial activation",
occurred_at=datetime.utcnow(),
extra={
"charged_amount_kopeks": charged_amount_kopeks,
"trial_duration_days": settings.TRIAL_DURATION_DAYS,
"traffic_limit_gb": settings.TRIAL_TRAFFIC_LIMIT_GB,
"device_limit": subscription.device_limit,
},
)
if not self._is_enabled():
return False
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
@@ -255,10 +318,28 @@ class AdminNotificationService:
was_trial_conversion: bool = False,
amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
try:
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
await self._record_subscription_event(
db,
event_type="purchase",
user=user,
subscription=subscription,
transaction=transaction,
amount_kopeks=total_amount,
message="Subscription purchase",
occurred_at=(transaction.completed_at or transaction.created_at) if transaction else datetime.utcnow(),
extra={
"period_days": period_days,
"was_trial_conversion": was_trial_conversion,
"payment_method": self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс",
},
)
if not self._is_enabled():
return False
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
if was_trial_conversion:
@@ -275,7 +356,6 @@ class AdminNotificationService:
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
transaction_id = transaction.id if transaction else ""
message = f"""💎 <b>{event_type}</b>
@@ -469,11 +549,38 @@ class AdminNotificationService:
promo_group: PromoGroup | None,
db: AsyncSession | None = None,
) -> bool:
logger.info("Начинаем отправку уведомления о пополнении баланса")
if db:
try:
await self._record_subscription_event(
db,
event_type="balance_topup",
user=user,
subscription=subscription,
transaction=transaction,
amount_kopeks=transaction.amount_kopeks,
message="Balance top-up",
occurred_at=transaction.completed_at or transaction.created_at,
extra={
"status": topup_status,
"balance_before": old_balance,
"balance_after": user.balance_kopeks,
"referrer_info": referrer_info,
"promo_group_id": getattr(promo_group, "id", None),
"promo_group_name": getattr(promo_group, "name", None),
},
)
except Exception:
logger.error(
"Не удалось сохранить событие пополнения баланса пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
logger.info("Начинаем отправку уведомления о пополнении баланса")
try:
logger.info("Пытаемся создать сообщение уведомления")
message = self._build_balance_topup_message(
@@ -567,19 +674,37 @@ class AdminNotificationService:
new_end_date: datetime | None = None,
balance_after: int | None = None,
) -> bool:
if not self._is_enabled():
return False
try:
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
await self._record_subscription_event(
db,
event_type="renewal",
user=user,
subscription=subscription,
transaction=transaction,
amount_kopeks=transaction.amount_kopeks,
message="Subscription renewed",
occurred_at=transaction.completed_at or transaction.created_at,
extra={
"extended_days": extended_days,
"previous_end_date": old_end_date.isoformat(),
"new_end_date": current_end_date.isoformat(),
"payment_method": transaction.payment_method,
"balance_after": current_balance,
},
)
if not self._is_enabled():
return False
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user_display}
@@ -619,7 +744,41 @@ class AdminNotificationService:
user: User,
promocode_data: Dict[str, Any],
effect_description: str,
balance_before_kopeks: int | None = None,
balance_after_kopeks: int | None = None,
) -> bool:
try:
await self._record_subscription_event(
db,
event_type="promocode_activation",
user=user,
subscription=None,
transaction=None,
amount_kopeks=promocode_data.get("balance_bonus_kopeks"),
message="Promocode activation",
occurred_at=datetime.utcnow(),
extra={
"code": promocode_data.get("code"),
"type": promocode_data.get("type"),
"subscription_days": promocode_data.get("subscription_days"),
"balance_bonus_kopeks": promocode_data.get("balance_bonus_kopeks"),
"description": effect_description,
"valid_until": (
promocode_data.get("valid_until").isoformat()
if isinstance(promocode_data.get("valid_until"), datetime)
else promocode_data.get("valid_until")
),
"balance_before_kopeks": balance_before_kopeks,
"balance_after_kopeks": balance_after_kopeks,
},
)
except Exception:
logger.error(
"Не удалось сохранить событие активации промокода пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
@@ -665,6 +824,13 @@ class AdminNotificationService:
message_lines.extend(
[
"",
"💼 <b>Баланс:</b>",
(
f"{settings.format_price(balance_before_kopeks)}{settings.format_price(balance_after_kopeks)}"
if balance_before_kopeks is not None and balance_after_kopeks is not None
else "ℹ️ Баланс не изменился"
),
"",
"📝 <b>Эффект:</b>",
effect_description.strip() or "✅ Промокод активирован",
@@ -686,6 +852,31 @@ class AdminNotificationService:
campaign: AdvertisingCampaign,
user: Optional[User] = None,
) -> bool:
if user:
try:
await self._record_subscription_event(
db,
event_type="referral_link_visit",
user=user,
subscription=None,
transaction=None,
amount_kopeks=None,
message="Referral link visit",
occurred_at=datetime.utcnow(),
extra={
"campaign_id": campaign.id,
"campaign_name": campaign.name,
"start_parameter": campaign.start_parameter,
"was_registered": bool(user),
},
)
except Exception:
logger.error(
"Не удалось сохранить событие перехода по кампании для пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
@@ -744,6 +935,33 @@ class AdminNotificationService:
initiator: Optional[User] = None,
automatic: bool = False,
) -> bool:
try:
await self._record_subscription_event(
db,
event_type="promo_group_change",
user=user,
subscription=None,
transaction=None,
message="Promo group change",
occurred_at=datetime.utcnow(),
extra={
"old_group_id": getattr(old_group, "id", None),
"old_group_name": getattr(old_group, "name", None),
"new_group_id": new_group.id,
"new_group_name": new_group.name,
"reason": reason,
"initiator_id": getattr(initiator, "id", None),
"initiator_telegram_id": getattr(initiator, "telegram_id", None),
"automatic": automatic,
},
)
except Exception:
logger.error(
"Не удалось сохранить событие смены промогруппы пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
+238 -63
View File
@@ -8,6 +8,7 @@ from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
from app.database.database import AsyncSessionLocal
from app.database.models import BroadcastHistory
@@ -22,6 +23,8 @@ logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {"photo", "video", "document"}
LARGE_BROADCAST_THRESHOLD = 20_000
PROGRESS_UPDATE_STEP = 5_000
@dataclass(slots=True)
@@ -137,52 +140,53 @@ class BroadcastService:
keyboard = self._build_keyboard(config.selected_buttons)
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
logger.info(
"Запускаем стабильный режим рассылки для %s получателей", len(recipients)
)
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_resilient_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
else:
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_standard_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
if cancelled_during_run:
logger.info(
"Рассылка %s была отменена во время выполнения, финальный статус уже установлен",
broadcast_id,
)
return
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
if cancel_event.is_set():
logger.info(
"Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус",
broadcast_id,
)
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
await self._mark_finished(
broadcast_id,
sent_count,
failed_count,
cancelled=False,
)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
@@ -198,6 +202,127 @@ class BroadcastService:
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
async def _run_standard_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Базовый режим рассылки для небольших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent_count, failed_count, False
async def _run_resilient_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(15)
async def send_single_message(user):
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0:
await self._update_progress(broadcast_id, sent_count, failed_count)
await asyncio.sleep(0.1)
return sent_count, failed_count, False
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
if selected_buttons is None:
selected_buttons = []
@@ -251,18 +376,14 @@ class BroadcastService:
*,
cancelled: bool,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = "cancelled" if cancelled else (
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="cancelled" if cancelled else (
"completed" if failed_count == 0 else "partial"
)
broadcast.completed_at = datetime.utcnow()
await session.commit()
),
)
async def _mark_cancelled(
self,
@@ -283,16 +404,70 @@ class BroadcastService:
sent_count: int = 0,
failed_count: int = 0,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="failed",
)
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count or broadcast.failed_count
broadcast.status = "failed"
broadcast.completed_at = datetime.utcnow()
await session.commit()
async def _update_progress(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
"""Периодически обновляет прогресс рассылки, чтобы держать соединение активным."""
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="in_progress",
update_completed_at=False,
)
async def _safe_status_update(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
status: str,
update_completed_at: bool = True,
) -> None:
attempts = 0
while attempts < 2:
try:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = status
if update_completed_at:
broadcast.completed_at = datetime.utcnow()
await session.commit()
return
except InterfaceError as exc:
attempts += 1
logger.warning(
"Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2",
broadcast_id,
exc,
attempts,
)
await asyncio.sleep(0.2)
except SQLAlchemyError:
logger.exception(
"Не удалось обновить статус рассылки %s", broadcast_id
)
return
broadcast_service = BroadcastService()
+23 -4
View File
@@ -38,9 +38,28 @@ class PaymentCommonMixin:
texts = get_texts(user.language if user else "ru")
# Определяем статус подписки, чтобы показать подходящую кнопку.
has_active_subscription = bool(
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
)
has_active_subscription = False
subscription = None
if user:
try:
subscription = user.subscription
has_active_subscription = bool(
subscription
and not getattr(subscription, "is_trial", False)
and getattr(subscription, "is_active", False)
)
except MissingGreenlet as error:
logger.warning(
"Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s",
getattr(user, "id", None),
error,
)
except Exception as error: # pragma: no cover - защитный код
logger.error(
"Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s",
getattr(user, "id", None),
error,
)
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
first_button = build_miniapp_or_callback_button(
@@ -86,7 +105,7 @@ class PaymentCommonMixin:
])
else:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
if should_offer_checkout_resume(user, draft_exists, subscription=subscription):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
+2 -2
View File
@@ -381,7 +381,7 @@ class CryptoBotPaymentMixin:
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
total_amount=settings.format_price(amount_kopeks)
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
@@ -402,7 +402,7 @@ class CryptoBotPaymentMixin:
saved_cart_notification = _SavedCartNotificationPayload(
telegram_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
+11 -4
View File
@@ -128,6 +128,7 @@ class PlategaPaymentMixin:
"status": status,
"expires_at": expires_at,
"correlation_id": correlation_id,
"payload": payload_token,
}
async def process_platega_webhook(
@@ -335,6 +336,16 @@ class PlategaPaymentMixin:
logger.error("Пользователь %s не найден для Platega", payment.user_id)
return payment
# Убеждаемся, что промогруппы загружены в асинхронном контексте,
# чтобы избежать попыток ленивой загрузки без greenlet
await db.refresh(user, attribute_names=["promo_group", "user_promo_groups"])
for user_promo_group in getattr(user, "user_promo_groups", []):
await db.refresh(user_promo_group, attribute_names=["promo_group"])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
transaction_external_id = (
str(payload.get("id"))
if isinstance(payload, dict) and payload.get("id")
@@ -392,10 +403,6 @@ class PlategaPaymentMixin:
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
try:
+5
View File
@@ -52,7 +52,10 @@ class PromoCodeService:
if existing_use:
return {"success": False, "error": "already_used_by_user"}
balance_before_kopeks = user.balance_kopeks
result_description = await self._apply_promocode_effects(db, user, promocode)
balance_after_kopeks = user.balance_kopeks
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
from app.utils.user_utils import mark_user_as_had_paid_subscription
@@ -123,6 +126,8 @@ class PromoCodeService:
"success": True,
"description": result_description,
"promocode": promocode_data,
"balance_before_kopeks": balance_before_kopeks,
"balance_after_kopeks": balance_after_kopeks,
}
except Exception as e:
+15 -16
View File
@@ -7,6 +7,7 @@ from app.config import settings
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.crud.referral import create_referral_earning
from app.database.models import TransactionType, ReferralEarning
from app.utils.user_utils import get_effective_referral_commission_percent
logger = logging.getLogger(__name__)
@@ -48,8 +49,9 @@ async def process_referral_registration(
amount_kopeks=0,
reason="referral_registration_pending"
)
if bot:
commission_percent = get_effective_referral_commission_percent(referrer)
referral_notification = (
f"🎉 <b>Добро пожаловать!</b>\n\n"
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
@@ -64,8 +66,8 @@ async def process_referral_registration(
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или "
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
f"{commission_percent}% от суммы (что больше).\n\n"
f"📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
@@ -94,13 +96,14 @@ async def process_referral_topup(
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
commission_percent = get_effective_referral_commission_percent(referrer)
qualifies_for_first_bonus = (
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
)
commission_amount = 0
if settings.REFERRAL_COMMISSION_PERCENT > 0:
if commission_percent > 0:
commission_amount = int(
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
topup_amount_kopeks * commission_percent / 100
)
if not user.has_made_first_topup:
@@ -116,7 +119,7 @@ async def process_referral_topup(
db,
referrer,
commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
bot=bot,
)
@@ -139,7 +142,7 @@ async def process_referral_topup(
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"🎁 Ваша комиссия ({commission_percent}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
@@ -180,7 +183,7 @@ async def process_referral_topup(
)
await send_referral_notification(bot, user.telegram_id, bonus_notification)
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
commission_amount = int(topup_amount_kopeks * commission_percent / 100)
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
if inviter_bonus > 0:
@@ -204,7 +207,7 @@ async def process_referral_topup(
f"💰 <b>Реферальная награда!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
f"📈 Теперь с каждого его пополнения вы будете получать {commission_percent}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
@@ -212,7 +215,7 @@ async def process_referral_topup(
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
bot=bot
)
@@ -231,7 +234,7 @@ async def process_referral_topup(
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"🎁 Ваша комиссия ({commission_percent}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
@@ -261,11 +264,7 @@ async def process_referral_purchase(
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
commission_percent = 10
else:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
commission_percent = get_effective_referral_commission_percent(referrer)
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
+110 -45
View File
@@ -256,6 +256,29 @@ class RemnaWaveService:
)
return self._now_utc() + timedelta(days=30)
def _safe_expire_at_for_panel(self, expire_at: Optional[datetime]) -> datetime:
"""Гарантирует, что дата окончания не в прошлом для панели."""
now = self._now_utc()
minimum_expire = now + timedelta(minutes=1)
if not expire_at:
return minimum_expire
normalized_expire = expire_at
if normalized_expire.tzinfo is not None:
normalized_expire = normalized_expire.replace(tzinfo=None)
if normalized_expire < minimum_expire:
logger.debug(
"⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели",
normalized_expire,
minimum_expire,
)
return minimum_expire
return normalized_expire
def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime:
"""Парсит дату окончания подписки пользователя панели для сравнения."""
@@ -539,6 +562,13 @@ class RemnaWaveService:
nodes_weekly_data = list(nodes_by_name.values())
nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True)
uptime_seconds = 0
uptime_value = system_stats.get('uptime')
try:
uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0
except (TypeError, ValueError):
logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0")
result = {
"system": {
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
@@ -558,7 +588,7 @@ class RemnaWaveService:
"memory_used": system_stats.get('memory', {}).get('used', 0),
"memory_free": system_stats.get('memory', {}).get('free', 0),
"memory_available": system_stats.get('memory', {}).get('available', 0),
"uptime_seconds": system_stats.get('uptime', 0)
"uptime_seconds": uptime_seconds
},
"bandwidth": {
"realtime_download": total_download,
@@ -1608,39 +1638,28 @@ class RemnaWaveService:
async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]:
try:
stats = {"created": 0, "updated": 0, "errors": 0}
users = await get_users_list(db, offset=0, limit=10000)
batch_size = 100
offset = 0
async with self.get_api_client() as api:
for user in users:
if not user.subscription:
continue
while True:
users = await get_users_list(db, offset=offset, limit=batch_size)
try:
subscription = user.subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
if not users:
break
if user.remnawave_uuid:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
)
for user in users:
if not user.subscription:
continue
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
try:
subscription = user.subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
expire_at = self._safe_expire_at_for_panel(subscription.end_date)
status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED
await api.update_user(**update_kwargs)
stats["updated"] += 1
else:
username = settings.format_remnawave_username(
full_name=user.full_name,
username=user.username,
@@ -1649,8 +1668,8 @@ class RemnaWaveService:
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=expire_at,
status=status,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=user.telegram_id,
@@ -1665,20 +1684,66 @@ class RemnaWaveService:
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
new_user = await api.create_user(**create_kwargs)
await update_user(db, user, remnawave_uuid=new_user.uuid)
subscription.remnawave_short_uuid = new_user.short_uuid
# Убираем немедленный коммит для пакетной обработки
# await db.commit()
stats["created"] += 1
except Exception as e:
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
stats["errors"] += 1
logger.info(f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
if user.remnawave_uuid:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=status,
expire_at=expire_at,
traffic_limit_bytes=create_kwargs['traffic_limit_bytes'],
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
description=create_kwargs['description'],
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
try:
await api.update_user(**update_kwargs)
stats["updated"] += 1
except RemnaWaveAPIError as api_error:
if api_error.status_code == 404:
logger.warning(
"⚠️ Не найден пользователь %s в панели, создаем заново",
user.remnawave_uuid,
)
new_user = await api.create_user(**create_kwargs)
user.remnawave_uuid = new_user.uuid
subscription.remnawave_short_uuid = new_user.short_uuid
stats["created"] += 1
else:
raise
else:
new_user = await api.create_user(**create_kwargs)
user.remnawave_uuid = new_user.uuid
subscription.remnawave_short_uuid = new_user.short_uuid
stats["created"] += 1
except Exception as e:
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
stats["errors"] += 1
try:
await db.commit()
except Exception as commit_error:
logger.error(
"Ошибка фиксации транзакции при синхронизации в панель: %s",
commit_error,
)
await db.rollback()
stats["errors"] += len(users)
if len(users) < batch_size:
break
offset += batch_size
logger.info(
f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}"
)
return stats
except Exception as e:
+23 -3
View File
@@ -1,9 +1,15 @@
import logging
from typing import Optional
from app.database.models import User
from sqlalchemy.exc import MissingGreenlet
from app.database.models import Subscription, User
from app.utils.cache import UserCache
logger = logging.getLogger(__name__)
_CHECKOUT_SESSION_KEY = "subscription_checkout"
_CHECKOUT_TTL_SECONDS = 3600
@@ -33,7 +39,12 @@ async def has_subscription_checkout_draft(user_id: int) -> bool:
return draft is not None
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
def should_offer_checkout_resume(
user: User,
has_draft: bool,
*,
subscription: Subscription | None = None,
) -> bool:
"""
Determine whether checkout resume button should be available for the user.
@@ -44,7 +55,16 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
if not has_draft:
return False
subscription = getattr(user, "subscription", None)
if subscription is None:
try:
subscription = getattr(user, "subscription", None)
except MissingGreenlet as error:
logger.warning(
"Не удалось лениво загрузить подписку пользователя %s при проверке возврата к checkout: %s",
getattr(user, "id", None),
error,
)
subscription = None
if subscription is None:
return True
+24 -24
View File
@@ -521,36 +521,36 @@ class SubscriptionService:
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
logger.info(f"Расчет стоимости новой подписки:")
logger.debug("Расчет стоимости новой подписки:")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if discounted_traffic_price > 0:
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
logger.debug(message)
if discounted_devices_price > 0:
message = f" Устройства ({devices}): {devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -634,36 +634,36 @@ class SubscriptionService:
+ discounted_traffic_price
)
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.debug(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if servers_price > 0:
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}"
if servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
)
logger.info(message)
logger.debug(message)
if devices_price > 0:
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
)
logger.info(message)
logger.debug(message)
if traffic_price > 0:
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" 💎 ИТОГО: {total_price/100}")
return total_price
@@ -855,14 +855,14 @@ class SubscriptionService:
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.debug(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if total_traffic_price > 0:
message = (
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
@@ -871,14 +871,14 @@ class SubscriptionService:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
logger.debug(message)
if total_devices_price > 0:
message = (
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
@@ -887,8 +887,8 @@ class SubscriptionService:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -972,14 +972,14 @@ class SubscriptionService:
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.debug(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if total_servers_price > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}"
@@ -988,7 +988,7 @@ class SubscriptionService:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_devices_price > 0:
message = (
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
@@ -997,7 +997,7 @@ class SubscriptionService:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_traffic_price > 0:
message = (
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
@@ -1006,8 +1006,8 @@ class SubscriptionService:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" 💎 ИТОГО: {total_price/100}")
return total_price
+17
View File
@@ -254,6 +254,7 @@ class BotConfigurationService:
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
"SUPPORT_TOPUP_ENABLED": "PAYMENT",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
@@ -504,6 +505,22 @@ class BotConfigurationService:
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
},
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": {
"description": (
"Включает применение базовых скидок на периоды подписок в групповых промо."
),
"format": "Булево значение.",
"example": "true",
"warning": "Скидки применяются только если указаны корректные пары периодов и процентов.",
},
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": {
"description": (
"Список скидок для групп: каждая пара задаёт дни периода и процент скидки."
),
"format": "Через запятую пары вида &lt;дней&gt;:&lt;скидка&gt;.",
"example": "30:10,60:20,90:30,180:50,360:65",
"warning": "Некорректные записи будут проигнорированы. Процент ограничен 0-100.",
},
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
"description": (
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
+2
View File
@@ -3,6 +3,7 @@ from aiogram.fsm.state import State, StatesGroup
class RegistrationStates(StatesGroup):
waiting_for_language = State()
waiting_for_rules_accept = State()
waiting_for_privacy_policy_accept = State()
waiting_for_referral_code = State()
class SubscriptionStates(StatesGroup):
@@ -95,6 +96,7 @@ class AdminStates(StatesGroup):
editing_user_devices = State()
editing_user_traffic = State()
editing_user_referrals = State()
editing_user_referral_percent = State()
editing_rules_page = State()
editing_privacy_policy = State()
+3
View File
@@ -10,12 +10,15 @@ def is_registration_process(event: TelegramObject, current_state: Optional[str])
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_privacy_policy_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
registration_callbacks = [
"rules_accept",
"rules_decline",
"privacy_policy_accept",
"privacy_policy_decline",
"referral_skip"
]
+22 -11
View File
@@ -100,14 +100,14 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
"callback": "topup_platega",
})
# Поддержка всегда доступна
methods.append({
"id": "support",
"name": "Через поддержку",
"icon": "🛠️",
"description": "другие способы",
"callback": "topup_support"
})
if settings.is_support_topup_enabled():
methods.append({
"id": "support",
"name": "Через поддержку",
"icon": "🛠️",
"description": "другие способы",
"callback": "topup_support"
})
return methods
@@ -118,7 +118,18 @@ def get_payment_methods_text(language: str) -> str:
texts = get_texts(language)
methods = get_available_payment_methods()
if len(methods) <= 1: # Только поддержка
if not methods:
return texts.t(
"PAYMENT_METHODS_NONE_AVAILABLE",
"""💳 <b>Способы пополнения баланса</b>
В данный момент способы оплаты временно недоступны.
Попробуйте позже.
Выберите способ пополнения:""",
)
if len(methods) == 1 and methods[0]["id"] == "support":
return texts.t(
"PAYMENT_METHODS_ONLY_SUPPORT",
"""💳 <b>Способы пополнения баланса</b>
@@ -186,7 +197,7 @@ def is_payment_method_available(method_id: str) -> bool:
elif method_id == "platega":
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
elif method_id == "support":
return True # Поддержка всегда доступна
return settings.is_support_topup_enabled()
else:
return False
@@ -204,7 +215,7 @@ def get_payment_method_status() -> Dict[str, bool]:
"cryptobot": settings.is_cryptobot_enabled(),
"heleket": settings.is_heleket_enabled(),
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
"support": True
"support": settings.is_support_topup_enabled()
}
def get_enabled_payment_methods_count() -> int:
+2 -5
View File
@@ -72,11 +72,8 @@ def calculate_user_price(
# Get user's promo group discount for this category
discount_percent = user.get_promo_discount(category, period_days)
else:
# For None user, use base settings discount (only for period category)
if category == "period":
discount_percent = settings.get_base_promo_group_period_discount(period_days)
else:
discount_percent = 0
# For None user, use base settings discount
discount_percent = settings.get_base_promo_group_period_discount(period_days)
logger.debug(
f"calculate_user_price: user={user.telegram_id if user else 'None'}, "
+21
View File
@@ -1,12 +1,14 @@
import logging
import secrets
import string
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from sqlalchemy import select, func, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import User, ReferralEarning, Transaction, TransactionType
logger = logging.getLogger(__name__)
@@ -58,6 +60,25 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s
return f"ref{timestamp}"
def get_effective_referral_commission_percent(user: User) -> int:
"""Возвращает индивидуальный процент комиссии пользователя или дефолтное значение."""
percent = getattr(user, "referral_commission_percent", None)
if percent is None:
percent = settings.REFERRAL_COMMISSION_PERCENT
if percent < 0 or percent > 100:
logger.error(
"❌ Некорректный процент комиссии для пользователя %s: %s",
getattr(user, "telegram_id", None),
percent,
)
return max(0, min(100, settings.REFERRAL_COMMISSION_PERCENT))
return percent
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
try:
if user.has_had_paid_subscription:
+16 -13
View File
@@ -123,23 +123,26 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
def sanitize_html(text: str) -> str:
if not text:
return text
text = html.escape(text)
for tag in ALLOWED_HTML_TAGS:
allowed_tags = ALLOWED_HTML_TAGS.union(SELF_CLOSING_TAGS)
for tag in allowed_tags:
text = re.sub(
f'&lt;{tag}(&gt;|\\s[^&]*&gt;)',
lambda m: m.group(0).replace('&lt;', '<').replace('&gt;', '>'),
text,
f'&lt;(/?{tag}\\b[^>]*)&gt;',
lambda m: "<"
+ (
m.group(1)
.replace("&quot;", "\"")
.replace("&#x27;", "'")
.replace("&amp;", "&")
)
+ ">",
text,
flags=re.IGNORECASE
)
text = re.sub(
f'&lt;/{tag}&gt;',
f'</{tag}>',
text,
flags=re.IGNORECASE
)
return text
+43 -1
View File
@@ -13,14 +13,19 @@ from .routes import (
config,
health,
main_menu_buttons,
media,
miniapp,
partners,
polls,
promocodes,
promo_groups,
promo_offers,
user_messages,
welcome_texts,
pages,
remnawave,
servers,
subscription_events,
stats,
subscriptions,
tickets,
@@ -46,7 +51,11 @@ OPENAPI_TAGS = [
},
{
"name": "main-menu",
"description": "Управление кнопками главного меню Telegram-бота.",
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
},
{
"name": "welcome-texts",
"description": "Создание, редактирование и управление приветственными текстами.",
},
{
"name": "users",
@@ -96,10 +105,18 @@ OPENAPI_TAGS = [
"данных между ботом и панелью."
),
},
{
"name": "media",
"description": "Загрузка файлов в Telegram и получение ссылок на медиа.",
},
{
"name": "miniapp",
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
},
{
"name": "partners",
"description": "Просмотр участников реферальной программы, их доходов и рефералов.",
},
{
"name": "polls",
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
@@ -108,6 +125,14 @@ OPENAPI_TAGS = [
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
},
{
"name": "notifications",
"description": (
"Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, "
"пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и "
"сменах промогрупп пользователей для административной панели."
),
},
]
@@ -151,6 +176,16 @@ def create_web_api_app() -> FastAPI:
prefix="/main-menu/buttons",
tags=["main-menu"],
)
app.include_router(
user_messages.router,
prefix="/main-menu/messages",
tags=["main-menu"],
)
app.include_router(
welcome_texts.router,
prefix="/welcome-texts",
tags=["welcome-texts"],
)
app.include_router(pages.router, prefix="/pages", tags=["pages"])
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
@@ -158,8 +193,15 @@ def create_web_api_app() -> FastAPI:
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
app.include_router(media.router, tags=["media"])
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
app.include_router(partners.router, prefix="/partners", tags=["partners"])
app.include_router(polls.router, prefix="/polls", tags=["polls"])
app.include_router(logs.router, prefix="/logs", tags=["logs"])
app.include_router(
subscription_events.router,
prefix="/notifications/subscriptions",
tags=["notifications"],
)
return app
+10
View File
@@ -2,13 +2,18 @@ from . import (
config,
health,
main_menu_buttons,
media,
miniapp,
partners,
polls,
promo_offers,
user_messages,
welcome_texts,
pages,
promo_groups,
servers,
remnawave,
subscription_events,
stats,
subscriptions,
tickets,
@@ -22,13 +27,18 @@ __all__ = [
"config",
"health",
"main_menu_buttons",
"media",
"miniapp",
"partners",
"polls",
"promo_offers",
"user_messages",
"welcome_texts",
"pages",
"promo_groups",
"servers",
"remnawave",
"subscription_events",
"stats",
"subscriptions",
"tickets",
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import logging
import mimetypes
from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Request,
Response,
Security,
UploadFile,
status,
)
from app.config import settings
from ..dependencies import require_api_token
from ..schemas.media import MediaUploadResponse
router = APIRouter()
logger = logging.getLogger(__name__)
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
def _resolve_target_chat_id() -> int:
"""Выбирает чат для загрузки файлов (канал уведомлений или первый админ)."""
chat_id = settings.get_admin_notifications_chat_id()
if chat_id is not None:
return chat_id
admin_ids = settings.get_admin_ids()
if admin_ids:
return admin_ids[0]
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
"Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)",
)
def _build_media_url(request: Request, file_id: str) -> str:
return str(request.url_for("download_media", file_id=file_id))
@router.post("/upload", response_model=MediaUploadResponse, tags=["media"], status_code=status.HTTP_201_CREATED)
async def upload_media(
request: Request,
_: Any = Security(require_api_token),
file: UploadFile = File(...),
media_type: str = Form("document", description="Тип файла: photo, video или document"),
caption: str | None = Form(None, description="Необязательная подпись к файлу"),
) -> MediaUploadResponse:
media_type_normalized = (media_type or "").strip().lower()
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported media type")
file_bytes = await file.read()
if not file_bytes:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "File is empty")
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or "upload")
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
if media_type_normalized == "photo":
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
caption=caption,
)
media = message.photo[-1]
elif media_type_normalized == "video":
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
caption=caption,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
caption=caption,
)
media = message.document
media_url = _build_media_url(request, media.file_id)
return MediaUploadResponse(
media_type=media_type_normalized,
file_id=media.file_id,
file_unique_id=getattr(media, "file_unique_id", None),
media_url=media_url,
)
except HTTPException:
raise
except Exception as error:
logger.error("Failed to upload media: %s", error)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to upload media") from error
finally:
await bot.session.close()
@router.get("/media/{file_id}", name="download_media", tags=["media"])
async def download_media(
file_id: str,
_: Any = Security(require_api_token),
) -> Response:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
file = await bot.get_file(file_id)
if not file.file_path:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media file not found")
buffer = await bot.download_file(file.file_path)
if hasattr(buffer, "seek"):
buffer.seek(0)
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
filename = file.file_path.split("/")[-1]
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return Response(
content=content,
media_type=media_type,
headers={
"Content-Disposition": f"inline; filename={filename}",
},
)
except HTTPException:
raise
except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла
logger.error("Failed to download media %s: %s", file_id, error)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to download media") from error
finally:
await bot.session.close()
+263 -5
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import json
import logging
import re
import math
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR, ROUND_UP
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import uuid4
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Union
@@ -64,6 +66,7 @@ from app.services.remnawave_service import (
from app.services.payment_service import PaymentService, get_wata_payment_by_link_id
from app.services.promo_offer_service import promo_offer_service
from app.services.promocode_service import PromoCodeService
from app.services.maintenance_service import maintenance_service
from app.services.subscription_service import SubscriptionService
from app.services.subscription_renewal_service import (
SubscriptionRenewalChargeError,
@@ -96,6 +99,7 @@ from app.utils.telegram_webapp import (
parse_webapp_init_data,
)
from app.utils.user_utils import (
get_effective_referral_commission_percent,
get_detailed_referral_list,
get_user_referral_summary,
)
@@ -114,6 +118,7 @@ from ..schemas.miniapp import (
MiniAppDevice,
MiniAppDeviceRemovalRequest,
MiniAppDeviceRemovalResponse,
MiniAppMaintenanceStatusResponse,
MiniAppFaq,
MiniAppFaqItem,
MiniAppLegalDocuments,
@@ -124,6 +129,7 @@ from ..schemas.miniapp import (
MiniAppPaymentMethod,
MiniAppPaymentMethodsRequest,
MiniAppPaymentMethodsResponse,
MiniAppPaymentOption,
MiniAppPaymentStatusQuery,
MiniAppPaymentStatusRequest,
MiniAppPaymentStatusResponse,
@@ -192,6 +198,57 @@ _CRYPTOBOT_MIN_USD = 1.0
_CRYPTOBOT_MAX_USD = 1000.0
_CRYPTOBOT_FALLBACK_RATE = 95.0
@router.get("/app-config.json")
async def get_app_config() -> Dict[str, Any]:
data = _load_app_config_data()
if data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="App config not found")
return data
def _get_app_config_candidate_files() -> List[Path]:
seen: set[Path] = set()
candidates: List[Path] = []
def _add_candidate(path: Path) -> None:
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
candidates.append(resolved)
cwd = Path.cwd()
_add_candidate(cwd / "miniapp" / "app-config.json")
_add_candidate(cwd / "app-config.json")
current = Path(__file__).resolve()
for parent in current.parents:
_add_candidate(parent / "miniapp" / "app-config.json")
_add_candidate(parent / "app-config.json")
_add_candidate(Path("/var/www/remnawave-miniapp/app-config.json"))
return candidates
def _load_app_config_data() -> Optional[Dict[str, Any]]:
for path in _get_app_config_candidate_files():
if not path.is_file():
continue
try:
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
except (OSError, json.JSONDecodeError) as error:
logger.warning("Failed to load app-config from %s: %s", path, error)
continue
if isinstance(data, dict):
return data
return None
_DECIMAL_ONE_HUNDRED = Decimal(100)
_DECIMAL_CENT = Decimal("0.01")
@@ -624,6 +681,23 @@ def _build_mulenpay_iframe_config() -> Optional[MiniAppPaymentIframeConfig]:
return None
@router.post(
"/maintenance/status",
response_model=MiniAppMaintenanceStatusResponse,
)
async def get_maintenance_status(
payload: MiniAppSubscriptionRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppMaintenanceStatusResponse:
_, _ = await _resolve_user_from_init_data(db, payload.init_data)
status_info = maintenance_service.get_status_info()
return MiniAppMaintenanceStatusResponse(
is_active=bool(status_info.get("is_active")),
message=maintenance_service.get_maintenance_message(),
reason=status_info.get("reason"),
)
@router.post(
"/payments/methods",
response_model=MiniAppPaymentMethodsResponse,
@@ -707,6 +781,24 @@ async def get_payment_methods(
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
options=[
MiniAppPaymentOption(
id="sbp",
icon="🏦",
title_key="topup.method.pal24.option.sbp.title",
description_key="topup.method.pal24.option.sbp.description",
title="Faster Payments (SBP)",
description="Instant SBP transfer with no fees.",
),
MiniAppPaymentOption(
id="card",
icon="💳",
title_key="topup.method.pal24.option.card.title",
description_key="topup.method.pal24.option.card.description",
title="Bank card",
description="Pay with a bank card via PayPalych.",
),
],
)
)
@@ -723,6 +815,37 @@ async def get_payment_methods(
)
)
if settings.is_platega_enabled() and settings.get_platega_active_methods():
platega_methods = settings.get_platega_active_methods()
definitions = settings.get_platega_method_definitions()
options: List[MiniAppPaymentOption] = []
for method_code in platega_methods:
info = definitions.get(method_code, {})
options.append(
MiniAppPaymentOption(
id=str(method_code),
icon=info.get("icon") or ("🏦" if method_code == 2 else "💳"),
title_key=f"topup.method.platega.option.{method_code}.title",
description_key=f"topup.method.platega.option.{method_code}.description",
title=info.get("title") or info.get("name") or f"Platega {method_code}",
description=info.get("description") or info.get("name"),
)
)
methods.append(
MiniAppPaymentMethod(
id="platega",
icon="💳",
requires_amount=True,
currency=settings.PLATEGA_CURRENCY,
min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
options=options,
)
)
if settings.is_cryptobot_enabled():
rate = await _get_usd_to_rub_rate()
min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate)
@@ -768,10 +891,11 @@ async def get_payment_methods(
"yookassa": 3,
"mulenpay": 4,
"pal24": 5,
"wata": 6,
"cryptobot": 7,
"heleket": 8,
"tribute": 9,
"platega": 6,
"wata": 7,
"cryptobot": 8,
"heleket": 9,
"tribute": 10,
}
methods.sort(key=lambda item: order_map.get(item.id, 99))
@@ -948,6 +1072,54 @@ async def create_payment_link(
},
)
if method == "platega":
if not settings.is_platega_enabled() or not settings.get_platega_active_methods():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
if amount_kopeks is None or amount_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
active_methods = settings.get_platega_active_methods()
method_option = payload.payment_option or str(active_methods[0])
try:
method_code = int(str(method_option).strip())
except (TypeError, ValueError):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid Platega payment option")
if method_code not in active_methods:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Selected Platega method is unavailable")
payment_service = PaymentService()
result = await payment_service.create_platega_payment(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=user.language or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
)
redirect_url = result.get("redirect_url") if result else None
if not result or not redirect_url:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
return MiniAppPaymentCreateResponse(
method=method,
payment_url=redirect_url,
amount_kopeks=amount_kopeks,
extra={
"local_payment_id": result.get("local_payment_id"),
"payment_id": result.get("transaction_id"),
"correlation_id": result.get("correlation_id"),
"selected_option": str(method_code),
"payload": result.get("payload"),
"requested_at": _current_request_timestamp(),
},
)
if method == "wata":
if not settings.is_wata_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
@@ -1242,6 +1414,8 @@ async def _resolve_payment_status_entry(
)
if method == "mulenpay":
return await _resolve_mulenpay_payment_status(payment_service, db, user, query)
if method == "platega":
return await _resolve_platega_payment_status(payment_service, db, user, query)
if method == "wata":
return await _resolve_wata_payment_status(payment_service, db, user, query)
if method == "pal24":
@@ -1394,6 +1568,85 @@ async def _resolve_mulenpay_payment_status(
)
async def _resolve_platega_payment_status(
payment_service: PaymentService,
db: AsyncSession,
user: User,
query: MiniAppPaymentStatusQuery,
) -> MiniAppPaymentStatusResult:
from app.database.crud.platega import (
get_platega_payment_by_correlation_id,
get_platega_payment_by_id,
get_platega_payment_by_transaction_id,
)
payment = None
local_id = query.local_payment_id
if local_id:
payment = await get_platega_payment_by_id(db, local_id)
if not payment and query.payment_id:
payment = await get_platega_payment_by_transaction_id(db, query.payment_id)
if not payment and query.payload:
correlation = str(query.payload).replace("platega:", "")
payment = await get_platega_payment_by_correlation_id(db, correlation)
if not payment or payment.user_id != user.id:
return MiniAppPaymentStatusResult(
method="platega",
status="pending",
is_paid=False,
amount_kopeks=query.amount_kopeks,
message="Payment not found",
extra={
"local_payment_id": query.local_payment_id,
"payment_id": query.payment_id,
"payload": query.payload,
"started_at": query.started_at,
},
)
status_info = await payment_service.get_platega_payment_status(db, payment.id)
refreshed_payment = (status_info or {}).get("payment") or payment
status_raw = (status_info or {}).get("status") or getattr(payment, "status", None)
is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False))
status_value = _classify_status(status_raw, is_paid_flag)
completed_at = (
getattr(refreshed_payment, "paid_at", None)
or getattr(refreshed_payment, "updated_at", None)
or getattr(refreshed_payment, "created_at", None)
)
extra: Dict[str, Any] = {
"local_payment_id": refreshed_payment.id,
"payment_id": refreshed_payment.platega_transaction_id,
"correlation_id": refreshed_payment.correlation_id,
"status": status_raw,
"is_paid": getattr(refreshed_payment, "is_paid", False),
"payload": query.payload,
"started_at": query.started_at,
}
if status_info and status_info.get("remote"):
extra["remote"] = status_info.get("remote")
return MiniAppPaymentStatusResult(
method="platega",
status=status_value,
is_paid=status_value == "paid",
amount_kopeks=refreshed_payment.amount_kopeks,
currency=refreshed_payment.currency,
completed_at=completed_at,
transaction_id=refreshed_payment.transaction_id,
external_id=refreshed_payment.platega_transaction_id,
message=None,
extra=extra,
)
async def _resolve_wata_payment_status(
payment_service: PaymentService,
db: AsyncSession,
@@ -2447,7 +2700,12 @@ async def _build_referral_info(
minimum_topup_kopeks = int(referral_settings.get("minimum_topup_kopeks") or 0)
first_topup_bonus_kopeks = int(referral_settings.get("first_topup_bonus_kopeks") or 0)
inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0)
commission_percent = float(referral_settings.get("commission_percent") or 0)
commission_percent = float(
get_effective_referral_commission_percent(user)
if user
else referral_settings.get("commission_percent")
or 0
)
terms = MiniAppReferralTerms(
minimum_topup_kopeks=minimum_topup_kopeks,
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased, selectinload
from app.database.crud.referral import get_user_referral_stats
from app.database.crud.user import (
get_user_by_id,
get_user_by_telegram_id,
update_user,
)
from app.database.models import User
from app.utils.user_utils import (
get_detailed_referral_list,
get_effective_referral_commission_percent,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.partners import (
PartnerReferralItem,
PartnerReferralList,
PartnerReferralCommissionUpdate,
PartnerReferrerDetail,
PartnerReferrerItem,
PartnerReferrerListResponse,
)
router = APIRouter()
def _apply_search_filter(query, search: str):
search_lower = f"%{search.lower()}%"
conditions = [
func.lower(User.username).like(search_lower),
func.lower(User.first_name).like(search_lower),
func.lower(User.last_name).like(search_lower),
func.lower(User.referral_code).like(search_lower),
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
conditions.append(User.id == int(search))
return query.where(or_(*conditions))
def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem:
total_earned_kopeks = int(stats.get("total_earned_kopeks") or 0)
month_earned_kopeks = int(stats.get("month_earned_kopeks") or 0)
return PartnerReferrerItem(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
referral_code=user.referral_code,
referral_commission_percent=getattr(user, "referral_commission_percent", None),
effective_referral_commission_percent=get_effective_referral_commission_percent(user),
invited_count=int(stats.get("invited_count") or 0),
active_referrals=int(stats.get("active_referrals") or 0),
total_earned_kopeks=total_earned_kopeks,
total_earned_rubles=round(total_earned_kopeks / 100, 2),
month_earned_kopeks=month_earned_kopeks,
month_earned_rubles=round(month_earned_kopeks / 100, 2),
created_at=user.created_at,
last_activity=user.last_activity,
)
def _serialize_referral_item(referral: dict) -> PartnerReferralItem:
balance_kopeks = int(referral.get("balance_kopeks") or 0)
total_earned_kopeks = int(referral.get("total_earned_kopeks") or 0)
return PartnerReferralItem(
id=int(referral.get("id")),
telegram_id=int(referral.get("telegram_id")),
full_name=str(referral.get("full_name")),
username=referral.get("username"),
created_at=referral.get("created_at"),
last_activity=referral.get("last_activity"),
has_made_first_topup=bool(referral.get("has_made_first_topup", False)),
balance_kopeks=balance_kopeks,
balance_rubles=round(balance_kopeks / 100, 2),
total_earned_kopeks=total_earned_kopeks,
total_earned_rubles=round(total_earned_kopeks / 100, 2),
topups_count=int(referral.get("topups_count") or 0),
days_since_registration=int(referral.get("days_since_registration") or 0),
days_since_activity=referral.get("days_since_activity"),
status=str(referral.get("status") or "inactive"),
)
@router.get("/referrers", response_model=PartnerReferrerListResponse)
async def list_referrers(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
search: Optional[str] = Query(default=None),
) -> PartnerReferrerListResponse:
referral_alias = aliased(User)
has_referrals = (
select(referral_alias.id)
.where(referral_alias.referred_by_id == User.id)
.exists()
)
base_query = select(User).options(selectinload(User.referrer)).where(
or_(User.referral_code.isnot(None), has_referrals)
)
if search:
base_query = _apply_search_filter(base_query, search)
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
)
referrers = result.scalars().unique().all()
items: list[PartnerReferrerItem] = []
for referrer in referrers:
stats = await get_user_referral_stats(db, referrer.id)
items.append(_serialize_referrer(referrer, stats))
return PartnerReferrerListResponse(
items=items,
total=int(total),
limit=limit,
offset=offset,
)
@router.get("/referrers/{user_id}", response_model=PartnerReferrerDetail)
async def get_referrer_detail(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PartnerReferrerDetail:
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
stats = await get_user_referral_stats(db, user.id)
referrer_item = _serialize_referrer(user, stats)
referrals_data = await get_detailed_referral_list(db, user.id, limit=limit, offset=offset)
referral_items = [
_serialize_referral_item(referral) for referral in referrals_data.get("referrals", [])
]
referrals_list = PartnerReferralList(
items=referral_items,
total=int(referrals_data.get("total_count") or 0),
limit=limit,
offset=offset,
has_next=bool(referrals_data.get("has_next")),
has_prev=bool(referrals_data.get("has_prev")),
current_page=int(referrals_data.get("current_page") or 1),
total_pages=int(referrals_data.get("total_pages") or 1),
)
return PartnerReferrerDetail(referrer=referrer_item, referrals=referrals_list)
@router.patch("/referrers/{user_id}/commission", response_model=PartnerReferrerItem)
async def update_referrer_commission(
user_id: int,
payload: PartnerReferralCommissionUpdate,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PartnerReferrerItem:
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
await update_user(
db,
user,
referral_commission_percent=payload.referral_commission_percent,
)
stats = await get_user_referral_stats(db, user.id)
return _serialize_referrer(user, stats)
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from typing import Any, Iterable, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, SubscriptionEvent, Transaction, User
from app.database.crud.subscription_event import (
create_subscription_event,
list_subscription_events,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.subscription_events import (
SubscriptionEventCreate,
SubscriptionEventListResponse,
SubscriptionEventResponse,
)
router = APIRouter()
async def _get_user_or_error(db: AsyncSession, user_id: int) -> User:
user = await db.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
async def _ensure_subscription_exists(
db: AsyncSession, subscription_id: Optional[int]
) -> None:
if not subscription_id:
return
subscription = await db.get(Subscription, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription not found",
)
async def _ensure_transaction_exists(db: AsyncSession, transaction_id: Optional[int]) -> None:
if not transaction_id:
return
transaction_exists = await db.scalar(
select(Transaction.id).where(Transaction.id == transaction_id)
)
if not transaction_exists:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Transaction not found",
)
def _serialize_event(event: SubscriptionEvent) -> SubscriptionEventResponse:
user = event.user
extra = event.extra or {}
if event.event_type == "promocode_activation":
extra = {**extra}
extra.setdefault("balance_before_kopeks", None)
extra.setdefault("balance_after_kopeks", None)
return SubscriptionEventResponse(
id=event.id,
event_type=event.event_type,
user_id=event.user_id,
user_full_name=user.full_name if user else "",
user_username=user.username if user else None,
user_telegram_id=user.telegram_id if user else 0,
subscription_id=event.subscription_id,
transaction_id=event.transaction_id,
amount_kopeks=event.amount_kopeks,
currency=event.currency,
message=event.message,
occurred_at=event.occurred_at,
created_at=event.created_at,
extra=extra,
)
@router.post("", response_model=SubscriptionEventResponse, status_code=status.HTTP_201_CREATED)
async def receive_subscription_event(
payload: SubscriptionEventCreate,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionEventResponse:
user = await _get_user_or_error(db, payload.user_id)
await _ensure_subscription_exists(db, payload.subscription_id)
await _ensure_transaction_exists(db, payload.transaction_id)
event = await create_subscription_event(
db,
user_id=payload.user_id,
event_type=payload.event_type,
subscription_id=payload.subscription_id,
transaction_id=payload.transaction_id,
amount_kopeks=payload.amount_kopeks,
currency=payload.currency,
message=payload.message,
occurred_at=payload.occurred_at,
extra=payload.extra or None,
)
await db.refresh(event, attribute_names=["user"])
event.user = user
return _serialize_event(event)
@router.get("", response_model=SubscriptionEventListResponse)
async def list_subscription_event_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
event_types: Optional[Iterable[str]] = Query(default=None, alias="event_type"),
user_id: Optional[int] = Query(default=None),
) -> SubscriptionEventListResponse:
events, total = await list_subscription_events(
db,
limit=limit,
offset=offset,
event_types=event_types,
user_id=user_id,
)
return SubscriptionEventListResponse(
items=[_serialize_event(event) for event in events],
total=total,
limit=limit,
offset=offset,
)
+20 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram import Bot
@@ -27,6 +29,7 @@ from ..schemas.tickets import (
)
router = APIRouter()
logger = logging.getLogger(__name__)
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
@@ -248,6 +251,7 @@ async def reply_to_ticket(
async def get_ticket_message_media(
ticket_id: int,
message_id: int,
request: Request,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketMediaResponse:
@@ -262,10 +266,25 @@ async def get_ticket_message_media(
if not message.has_media or not message.media_file_id or not message.media_type:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message")
media_url: Optional[str] = None
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
file = await bot.get_file(message.media_file_id)
if file.file_path:
media_url = str(request.url_for("download_media", file_id=message.media_file_id))
except Exception as error:
logger.warning("Failed to resolve media URL for ticket %s message %s: %s", ticket_id, message_id, error)
finally:
await bot.session.close()
return TicketMediaResponse(
id=message.id,
ticket_id=ticket.id,
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
media_url=media_url,
)
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user_message import (
create_user_message,
delete_user_message,
get_all_user_messages,
get_user_message_by_id,
get_user_messages_count,
toggle_user_message_status,
update_user_message,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.user_messages import (
UserMessageCreateRequest,
UserMessageListResponse,
UserMessageResponse,
UserMessageUpdateRequest,
)
router = APIRouter()
def _serialize(message) -> UserMessageResponse:
return UserMessageResponse(
id=message.id,
message_text=message.message_text,
is_active=message.is_active,
sort_order=message.sort_order,
created_by=message.created_by,
created_at=message.created_at,
updated_at=message.updated_at,
)
@router.get("", response_model=UserMessageListResponse)
async def list_user_messages(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
include_inactive: bool = Query(True, description="Включать неактивные сообщения"),
) -> UserMessageListResponse:
total = await get_user_messages_count(db, include_inactive=include_inactive)
messages = await get_all_user_messages(
db,
offset=offset,
limit=limit,
include_inactive=include_inactive,
)
return UserMessageListResponse(
items=[_serialize(message) for message in messages],
total=total,
limit=limit,
offset=offset,
)
@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED)
async def create_user_message_endpoint(
payload: UserMessageCreateRequest,
token: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserMessageResponse:
created_by = getattr(token, "id", None)
try:
message = await create_user_message(
db,
message_text=payload.message_text,
created_by=created_by,
is_active=payload.is_active,
sort_order=payload.sort_order,
)
except ValueError as error:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
return _serialize(message)
@router.patch("/{message_id}", response_model=UserMessageResponse)
async def update_user_message_endpoint(
message_id: int,
payload: UserMessageUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserMessageResponse:
update_payload = payload.dict(exclude_unset=True)
try:
message = await update_user_message(db, message_id, **update_payload)
except ValueError as error:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
if not message:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
return _serialize(message)
@router.post("/{message_id}/toggle", response_model=UserMessageResponse)
async def toggle_user_message_endpoint(
message_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserMessageResponse:
message = await toggle_user_message_status(db, message_id)
if not message:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
return _serialize(message)
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user_message_endpoint(
message_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
message = await get_user_message_by_id(db, message_id)
if not message:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
await delete_user_message(db, message_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.welcome_text import (
count_welcome_texts,
create_welcome_text,
delete_welcome_text,
get_welcome_text_by_id,
list_welcome_texts,
update_welcome_text,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.welcome_texts import (
WelcomeTextCreateRequest,
WelcomeTextListResponse,
WelcomeTextResponse,
WelcomeTextUpdateRequest,
)
router = APIRouter()
def _serialize(text) -> WelcomeTextResponse:
return WelcomeTextResponse(
id=text.id,
text=text.text_content,
is_active=text.is_active,
is_enabled=text.is_enabled,
created_by=text.created_by,
created_at=text.created_at,
updated_at=text.updated_at,
)
@router.get("", response_model=WelcomeTextListResponse)
async def list_welcome_texts_endpoint(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
include_inactive: bool = Query(True, description="Включать неактивные тексты"),
) -> WelcomeTextListResponse:
total = await count_welcome_texts(db, include_inactive=include_inactive)
records = await list_welcome_texts(
db,
limit=limit,
offset=offset,
include_inactive=include_inactive,
)
return WelcomeTextListResponse(
items=[_serialize(item) for item in records],
total=total,
limit=limit,
offset=offset,
)
@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED)
async def create_welcome_text_endpoint(
payload: WelcomeTextCreateRequest,
token: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> WelcomeTextResponse:
created_by = getattr(token, "id", None)
record = await create_welcome_text(
db,
text_content=payload.text,
created_by=created_by,
is_enabled=payload.is_enabled,
is_active=payload.is_active,
)
return _serialize(record)
@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse)
async def get_welcome_text_endpoint(
welcome_text_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> WelcomeTextResponse:
record = await get_welcome_text_by_id(db, welcome_text_id)
if not record:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
return _serialize(record)
@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse)
async def update_welcome_text_endpoint(
welcome_text_id: int,
payload: WelcomeTextUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> WelcomeTextResponse:
record = await get_welcome_text_by_id(db, welcome_text_id)
if not record:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
update_payload = payload.dict(exclude_unset=True)
if "text" in update_payload:
update_payload["text_content"] = update_payload.pop("text")
updated = await update_welcome_text(db, record, **update_payload)
return _serialize(updated)
@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_welcome_text_endpoint(
welcome_text_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
record = await get_welcome_text_by_id(db, welcome_text_id)
if not record:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
await delete_welcome_text(db, record)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class MediaUploadResponse(BaseModel):
media_type: str = Field(description="Тип загруженного файла (photo, video, document)")
file_id: str = Field(description="Telegram file_id загруженного файла")
file_unique_id: Optional[str] = Field(
default=None, description="Уникальный идентификатор файла"
)
media_url: Optional[str] = Field(
default=None, description="Прямая ссылка на файл для предпросмотра"
)
+20
View File
@@ -17,6 +17,14 @@ class MiniAppSubscriptionRequest(BaseModel):
init_data: str = Field(..., alias="initData")
class MiniAppMaintenanceStatusResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
is_active: bool = Field(..., alias="isActive")
message: Optional[str] = None
reason: Optional[str] = None
class MiniAppSubscriptionUser(BaseModel):
telegram_id: int
username: Optional[str] = None
@@ -373,6 +381,17 @@ class MiniAppPaymentIntegrationType(str, Enum):
REDIRECT = "redirect"
class MiniAppPaymentOption(BaseModel):
id: str
icon: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
title_key: Optional[str] = Field(default=None, alias="titleKey")
description_key: Optional[str] = Field(default=None, alias="descriptionKey")
model_config = ConfigDict(populate_by_name=True)
class MiniAppPaymentIframeConfig(BaseModel):
expected_origin: str
@@ -402,6 +421,7 @@ class MiniAppPaymentMethod(BaseModel):
max_amount_kopeks: Optional[int] = None
amount_step_kopeks: Optional[int] = None
integration_type: MiniAppPaymentIntegrationType
options: List[MiniAppPaymentOption] = Field(default_factory=list)
iframe_config: Optional[MiniAppPaymentIframeConfig] = None
@model_validator(mode="after")
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class PartnerReferrerItem(BaseModel):
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
referral_code: Optional[str] = None
referral_commission_percent: Optional[int] = None
effective_referral_commission_percent: int
invited_count: int
active_referrals: int
total_earned_kopeks: int
total_earned_rubles: float
month_earned_kopeks: int
month_earned_rubles: float
created_at: datetime
last_activity: Optional[datetime] = None
class PartnerReferrerListResponse(BaseModel):
items: List[PartnerReferrerItem] = Field(default_factory=list)
total: int
limit: int
offset: int
class PartnerReferralItem(BaseModel):
id: int
telegram_id: int
full_name: str
username: Optional[str] = None
created_at: datetime
last_activity: Optional[datetime] = None
has_made_first_topup: bool
balance_kopeks: int
balance_rubles: float
total_earned_kopeks: int
total_earned_rubles: float
topups_count: int
days_since_registration: int
days_since_activity: Optional[int] = None
status: str
class PartnerReferralList(BaseModel):
items: List[PartnerReferralItem] = Field(default_factory=list)
total: int
limit: int
offset: int
has_next: bool
has_prev: bool
current_page: int
total_pages: int
class PartnerReferrerDetail(BaseModel):
referrer: PartnerReferrerItem
referrals: PartnerReferralList
class PartnerReferralCommissionUpdate(BaseModel):
referral_commission_percent: Optional[int] = Field(
default=None,
ge=0,
le=100,
description="Индивидуальный процент реферальной комиссии для пользователя",
)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, Field, field_validator
class SubscriptionEventCreate(BaseModel):
event_type: Literal[
"activation",
"purchase",
"renewal",
"balance_topup",
"promocode_activation",
"referral_link_visit",
"promo_group_change",
]
user_id: int = Field(..., ge=1)
subscription_id: Optional[int] = Field(default=None, ge=1)
transaction_id: Optional[int] = Field(default=None, ge=1)
amount_kopeks: Optional[int] = Field(default=None, ge=0)
currency: Optional[str] = Field(default=None, min_length=1, max_length=16)
message: Optional[str] = Field(default=None, max_length=2000)
occurred_at: Optional[datetime] = None
extra: Dict[str, Any] = Field(default_factory=dict)
@field_validator("message")
@classmethod
def _strip_message(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
return stripped or None
class SubscriptionEventResponse(BaseModel):
id: int
event_type: str
user_id: int
user_full_name: str
user_username: Optional[str] = None
user_telegram_id: int
subscription_id: Optional[int] = None
transaction_id: Optional[int] = None
amount_kopeks: Optional[int] = None
currency: Optional[str] = None
message: Optional[str] = None
occurred_at: datetime
created_at: datetime
extra: Dict[str, Any] = Field(default_factory=dict)
class SubscriptionEventListResponse(BaseModel):
items: list[SubscriptionEventResponse]
total: int
limit: int
offset: int
+1
View File
@@ -67,3 +67,4 @@ class TicketMediaResponse(BaseModel):
media_type: str
media_file_id: str
media_caption: Optional[str] = None
media_url: Optional[str] = None
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, validator
def _normalize_text(value: str) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise ValueError("Message text cannot be empty")
return cleaned
class UserMessageResponse(BaseModel):
id: int
message_text: str
is_active: bool
sort_order: int
created_by: Optional[int]
created_at: datetime
updated_at: datetime
class UserMessageCreateRequest(BaseModel):
message_text: str = Field(..., min_length=1, max_length=4000)
is_active: bool = True
sort_order: int = Field(0, ge=0)
_normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text)
class UserMessageUpdateRequest(BaseModel):
message_text: Optional[str] = Field(None, min_length=1, max_length=4000)
is_active: Optional[bool] = None
sort_order: Optional[int] = Field(None, ge=0)
@validator("message_text")
def validate_message_text(cls, value): # noqa: D401,B902
if value is None:
return value
return _normalize_text(value)
class UserMessageListResponse(BaseModel):
items: list[UserMessageResponse]
total: int
limit: int
offset: int
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, validator
def _normalize_text(value: str) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise ValueError("Text cannot be empty")
return cleaned
class WelcomeTextResponse(BaseModel):
id: int
text: str
is_active: bool
is_enabled: bool
created_by: Optional[int]
created_at: datetime
updated_at: datetime
class WelcomeTextCreateRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=4000)
is_enabled: bool = True
is_active: bool = True
_normalize_text = validator("text", allow_reuse=True)(_normalize_text)
class WelcomeTextUpdateRequest(BaseModel):
text: Optional[str] = Field(None, min_length=1, max_length=4000)
is_enabled: Optional[bool] = None
is_active: Optional[bool] = None
@validator("text")
def validate_text(cls, value): # noqa: D401,B902
if value is None:
return value
return _normalize_text(value)
class WelcomeTextListResponse(BaseModel):
items: list[WelcomeTextResponse]
total: int
limit: int
offset: int
+7 -8
View File
@@ -12,6 +12,7 @@ services:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
interval: 30s
@@ -28,6 +29,7 @@ services:
- redis_data:/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
@@ -53,28 +55,21 @@ services:
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
volumes:
# Логи
- ./logs:/app/logs:rw
# Данные приложения (для SQLite в случае переключения)
- ./data:/app/data:rw
- ./locales:/app/locales:rw
# Конфигурация приложения
# - ./app-config.json:/app/app-config.json:ro
# Timezone
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
interval: 60s
@@ -95,3 +90,7 @@ networks:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
remnawave-network:
name: remnawave-network
external: true # Используем существующую сеть панели
@@ -0,0 +1,77 @@
"""Add subscription_events table"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = "c2f9c3b5f5c4"
down_revision: Union[str, None] = "9f0f2d5a1c7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = "subscription_events"
def _table_exists(inspector: Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector):
return
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"subscription_id",
sa.Integer(),
sa.ForeignKey("subscriptions.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"transaction_id",
sa.Integer(),
sa.ForeignKey("transactions.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("amount_kopeks", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=16), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column(
"occurred_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
),
sa.Column("extra", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index(
"ix_subscription_events_event_type", TABLE_NAME, ["event_type"]
)
op.create_index("ix_subscription_events_user_id", TABLE_NAME, ["user_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
return
op.drop_index("ix_subscription_events_user_id", table_name=TABLE_NAME)
op.drop_index("ix_subscription_events_event_type", table_name=TABLE_NAME)
op.drop_table(TABLE_NAME)
@@ -0,0 +1,19 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e3c1e0b5b4a7"
down_revision: Union[str, None] = "c2f9c3b5f5c4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("referral_commission_percent", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "referral_commission_percent")
+363 -42
View File
@@ -40,6 +40,7 @@
--success: #10b981;
--success-rgb: 16, 185, 129;
--warning: #f59e0b;
--warning-rgb: 245, 158, 11;
--danger: #ef4444;
--danger-rgb: 239, 68, 68;
--info: #3b82f6;
@@ -288,6 +289,80 @@
padding: 80px 20px;
}
.maintenance-banner {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 16px;
margin-bottom: 16px;
border-radius: var(--radius-lg);
border: 1px solid rgba(var(--warning-rgb), 0.18);
background: linear-gradient(135deg, rgba(var(--warning-rgb), 0.08), rgba(var(--warning-rgb), 0.02));
color: var(--text-primary);
box-shadow: var(--shadow-sm);
}
.maintenance-icon {
font-size: 22px;
line-height: 1;
}
.maintenance-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.maintenance-title {
font-weight: 800;
font-size: 15px;
}
.maintenance-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.maintenance-state {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-lg);
margin-bottom: 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
}
.maintenance-state .maintenance-icon {
width: 54px;
height: 54px;
font-size: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--primary-rgb), 0.1);
border-radius: 50%;
color: var(--primary);
box-shadow: var(--shadow-sm);
}
.maintenance-state .maintenance-title {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
}
.maintenance-state .maintenance-text {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.7;
}
.spinner {
width: 48px;
height: 48px;
@@ -4766,6 +4841,24 @@
<div class="subtitle" data-i18n="app.subtitle">Secure & Fast Connection</div>
</div>
<div id="maintenanceBanner" class="maintenance-banner hidden" role="status" aria-live="polite">
<div class="maintenance-icon" aria-hidden="true">🔧</div>
<div class="maintenance-content">
<div class="maintenance-title" data-i18n="maintenance.title">Технические работы</div>
<div id="maintenanceMessage" class="maintenance-text" data-i18n="maintenance.message">
Сервис временно недоступен из-за технических работ. Попробуйте позже.
</div>
</div>
</div>
<div id="maintenanceState" class="maintenance-state hidden" role="status" aria-live="polite">
<div class="maintenance-icon" aria-hidden="true">🔧</div>
<div class="maintenance-title" data-i18n="maintenance.title">Технические работы</div>
<div id="maintenanceStateMessage" class="maintenance-text" data-i18n="maintenance.message">
Сервис временно недоступен из-за технических работ. Попробуйте позже.
</div>
</div>
<!-- Loading State -->
<div id="loadingState" class="loading">
<div class="spinner"></div>
@@ -5586,6 +5679,8 @@
'values.not_available': 'Not available',
'app.subtitle': 'Secure & Fast Connection',
'app.loading': 'Loading your subscription...',
'maintenance.title': 'Technical maintenance',
'maintenance.message': 'The service is temporarily in maintenance mode. Some actions may be unavailable.',
'error.default.title': 'Subscription Not Found',
'error.default.message': 'Please contact support to activate your subscription.',
'error.user_not_found.title': 'Register in the bot',
@@ -5615,6 +5710,18 @@
'topup.method.yookassa.description': 'Pay securely with a bank card',
'topup.method.mulenpay.title': 'Bank card (Mulen Pay)',
'topup.method.mulenpay.description': 'Fast payment with bank card',
'topup.method.platega.title': 'Platega.io',
'topup.method.platega.description': 'Bank cards and SBP via Platega',
'topup.method.platega.option.2.title': 'SBP (QR)',
'topup.method.platega.option.2.description': 'Pay with Faster Payments QR code.',
'topup.method.platega.option.10.title': 'Bank cards (RUB)',
'topup.method.platega.option.10.description': 'Russian bank cards through Platega.',
'topup.method.platega.option.11.title': 'Bank cards',
'topup.method.platega.option.11.description': 'Local bank cards via Platega.',
'topup.method.platega.option.12.title': 'International cards',
'topup.method.platega.option.12.description': 'International cards supported by Platega.',
'topup.method.platega.option.13.title': 'Cryptocurrency',
'topup.method.platega.option.13.description': 'Top up balance with crypto via Platega.',
'topup.method.wata.title': 'Bank card (Wata)',
'topup.method.wata.description': 'Pay with a bank card via Wata',
'topup.method.pal24.title': 'SBP (PayPalych)',
@@ -5992,6 +6099,8 @@
'values.not_available': 'Закрыто',
'app.subtitle': 'Безопасное и быстрое подключение',
'app.loading': 'Загружаем вашу подписку...',
'maintenance.title': 'Технические работы',
'maintenance.message': 'Сервис находится в режиме технических работ. Некоторые действия могут быть недоступны.',
'error.default.title': 'Подписка не найдена',
'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.',
'error.user_not_found.title': 'Зарегистрируйтесь в боте',
@@ -6021,6 +6130,18 @@
'topup.method.yookassa.description': 'Безопасная оплата банковской картой',
'topup.method.mulenpay.title': 'Банковская карта (Mulen Pay)',
'topup.method.mulenpay.description': 'Мгновенное списание с карты',
'topup.method.platega.title': 'Platega.io',
'topup.method.platega.description': 'Карта или СБП через Platega',
'topup.method.platega.option.2.title': 'СБП (QR)',
'topup.method.platega.option.2.description': 'Оплата по QR-коду через СБП.',
'topup.method.platega.option.10.title': 'Банковские карты (RUB)',
'topup.method.platega.option.10.description': 'Российские карты через Platega.',
'topup.method.platega.option.11.title': 'Банковские карты',
'topup.method.platega.option.11.description': 'Оплата картами через Platega.',
'topup.method.platega.option.12.title': 'Международные карты',
'topup.method.platega.option.12.description': 'Оплата международными картами.',
'topup.method.platega.option.13.title': 'Криптовалюта',
'topup.method.platega.option.13.description': 'Пополнение через криптовалюту в Platega.',
'topup.method.wata.title': 'Банковская карта (Wata)',
'topup.method.wata.description': 'Оплата банковской картой через Wata',
'topup.method.pal24.title': 'СБП (PayPalych)',
@@ -6664,6 +6785,7 @@
let paymentMethodsCache = null;
let paymentMethodsPromise = null;
let activePaymentMethod = null;
let maintenanceState = { isActive: false, message: null };
const paymentMethodSelections = {};
const activePaymentMonitors = new Map();
let paymentStatusPollTimer = null;
@@ -6841,13 +6963,18 @@
return;
}
if (monitor.method.id === 'pal24') {
const option = (monitor.option || 'sbp').toLowerCase();
const optionKey = option === 'card' ? 'card' : 'sbp';
const fallback = optionKey === 'card'
? 'Bank card payment'
: 'Faster Payments (SBP)';
setTopupModalSubtitle(`topup.method.pal24.option.${optionKey}.title`, fallback);
const optionsMap = (Array.isArray(monitor.method.options) ? monitor.method.options : []).reduce((map, item) => {
map[String(item.id)] = item;
return map;
}, {});
if (monitor.method.id === 'pal24' || monitor.method.id === 'platega') {
const option = (monitor.option || monitor.extra?.selected_option || 'sbp').toString();
const optionKey = ['card', 'sbp'].includes(option) ? option : option;
const optionConfig = optionsMap[optionKey];
const fallback = optionConfig?.title || (optionKey === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)');
const titleKey = optionConfig?.titleKey || optionConfig?.title_key || `topup.method.${monitor.method.id}.option.${optionKey}.title`;
setTopupModalSubtitle(titleKey, fallback || monitor.method.id);
return;
}
@@ -6878,6 +7005,11 @@
identifiers.paymentId = extra.payment_id;
}
if (extra.correlation_id && !query.payload) {
query.payload = extra.correlation_id;
identifiers.payload = extra.correlation_id;
}
const payloadValue = extra.payload || extra.invoice_payload;
if (payloadValue) {
query.payload = payloadValue;
@@ -7462,6 +7594,80 @@
label.textContent = t(key);
}
function resolveMaintenanceMessage() {
const resolvedMessage = (typeof maintenanceState.message === 'string'
? maintenanceState.message.trim()
: '')
|| t('maintenance.message');
const translatedFallback = t('maintenance.message');
const messageFallback = translatedFallback === 'maintenance.message'
? 'The service is temporarily unavailable due to maintenance. Please try again later.'
: translatedFallback;
return resolvedMessage === 'maintenance.message'
? messageFallback
: resolvedMessage;
}
function renderMaintenanceBanner() {
const banner = document.getElementById('maintenanceBanner');
const messageElement = document.getElementById('maintenanceMessage');
if (!banner || !messageElement) {
return;
}
if (!maintenanceState?.isActive) {
banner.classList.add('hidden');
return;
}
messageElement.textContent = resolveMaintenanceMessage();
banner.classList.remove('hidden');
}
function renderMaintenanceScreen() {
const screen = document.getElementById('maintenanceState');
const messageElement = document.getElementById('maintenanceStateMessage');
const banner = document.getElementById('maintenanceBanner');
const loadingState = document.getElementById('loadingState');
const errorState = document.getElementById('errorState');
const mainContent = document.getElementById('mainContent');
if (!screen || !messageElement) {
return;
}
if (!maintenanceState?.isActive) {
screen.classList.add('hidden');
return;
}
messageElement.textContent = resolveMaintenanceMessage();
screen.classList.remove('hidden');
if (banner) {
banner.classList.add('hidden');
}
if (loadingState) {
loadingState.classList.add('hidden');
}
if (errorState) {
errorState.classList.add('hidden');
}
if (mainContent) {
mainContent.classList.add('hidden');
}
}
function applyMaintenanceStatus(status) {
const isActive = Boolean(status?.isActive ?? status?.is_active);
const message = typeof status?.message === 'string' ? status.message : null;
maintenanceState = { isActive, message };
renderMaintenanceBanner();
renderMaintenanceScreen();
}
function refreshAfterLanguageChange() {
applyTranslations();
if (userData) {
@@ -7471,6 +7677,7 @@
}
renderApps();
updateActionButtons();
renderMaintenanceBanner();
}
function setLanguage(language, options = {}) {
@@ -7669,6 +7876,22 @@
hasAnimatedCards = true;
}
async function fetchMaintenanceStatus(initData) {
const response = await fetch('/miniapp/maintenance/status', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData })
});
if (response.ok) {
return response.json();
}
throw new Error('Unable to fetch maintenance status');
}
async function fetchSubscriptionPayload(initData) {
const response = await fetch('/miniapp/subscription', {
method: 'POST',
@@ -7910,6 +8133,23 @@
return applySubscriptionData(payload);
}
async function checkMaintenance(initData) {
if (!initData) {
applyMaintenanceStatus({ isActive: false, message: null });
return false;
}
try {
const status = await fetchMaintenanceStatus(initData);
applyMaintenanceStatus(status);
return Boolean(status?.isActive ?? status?.is_active);
} catch (error) {
console.warn('Unable to load maintenance status:', error);
applyMaintenanceStatus({ isActive: false, message: null });
return false;
}
}
async function init() {
try {
const telegramUser = tg.initDataUnsafe?.user;
@@ -7923,6 +8163,11 @@
}
await loadAppsConfig();
const initData = tg.initData || '';
const maintenanceActive = await checkMaintenance(initData);
if (maintenanceActive) {
return;
}
await refreshSubscriptionData();
} catch (error) {
console.error('Initialization error:', error);
@@ -7932,12 +8177,13 @@
async function loadAppsConfig() {
try {
const response = await fetch('/app-config.json', { cache: 'no-cache' });
if (!response.ok) {
throw new Error('Failed to load app config');
const fallbackPaths = getAppConfigCandidatePaths();
const data = await fetchFirstAvailableAppConfig(fallbackPaths);
if (!data) {
throw new Error('No available app config sources');
}
const data = await response.json();
appsConfig = sanitizeAppsConfig(data?.platforms || {});
ensurePlatformFilter();
@@ -8001,9 +8247,53 @@
} catch (error) {
console.warn('Unable to load apps configuration:', error);
appsConfig = {};
ensurePlatformFilter();
}
}
function getAppConfigCandidatePaths() {
const paths = [];
const currentPath = window.location?.pathname || '/';
const basePath = currentPath.endsWith('/')
? currentPath
: currentPath.replace(/[^/]*$/, '');
const normalizedBase = basePath || '/';
const relativePath = `${normalizedBase}app-config.json`;
paths.push(relativePath);
['/miniapp/app-config.json', '/app-config.json'].forEach(path => {
if (!paths.includes(path)) {
paths.push(path);
}
});
return paths;
}
async function fetchFirstAvailableAppConfig(paths) {
if (!Array.isArray(paths) || !paths.length) {
return null;
}
for (const path of paths) {
try {
const response = await fetch(path, { cache: 'no-cache' });
if (!response.ok) {
continue;
}
const data = await response.json();
if (data && typeof data === 'object') {
return data;
}
} catch (error) {
console.warn('App config path failed:', path, error);
}
}
return null;
}
function renderUserData() {
if (!userData?.user) {
return;
@@ -11503,32 +11793,44 @@
form.appendChild(hint);
}
if (method.id === 'pal24') {
const optionsConfig = [
const providedOptions = Array.isArray(method.options) ? method.options : [];
const fallbackOptions = method.id === 'pal24'
? [
{
id: 'sbp',
icon: '🏦',
titleKey: 'topup.method.pal24.option.sbp.title',
descriptionKey: 'topup.method.pal24.option.sbp.description',
fallbackTitle: 'Faster Payments (SBP)',
fallbackDescription: 'Instant SBP transfer with no fees.',
title: 'Faster Payments (SBP)',
description: 'Instant SBP transfer with no fees.',
},
{
id: 'card',
icon: '💳',
titleKey: 'topup.method.pal24.option.card.title',
descriptionKey: 'topup.method.pal24.option.card.description',
fallbackTitle: 'Bank card',
fallbackDescription: 'Pay with a bank card via PayPalych.',
title: 'Bank card',
description: 'Pay with a bank card via PayPalych.',
},
];
]
: [];
const selectedDefault = options.selectedOption
const optionsConfig = (providedOptions.length ? providedOptions : fallbackOptions).map(option => ({
id: String(option.id),
icon: option.icon || '💳',
titleKey: option.titleKey || option.title_key || option.titlekey,
descriptionKey: option.descriptionKey || option.description_key || option.descriptionkey,
fallbackTitle: option.title || option.name || String(option.id),
fallbackDescription: option.description || '',
}));
if (optionsConfig.length) {
const defaultSelection = options.selectedOption
|| paymentMethodSelections[method.id]
|| 'sbp';
let currentOption = optionsConfig.some(option => option.id === selectedDefault)
? selectedDefault
: 'sbp';
|| optionsConfig[0]?.id;
let currentOption = optionsConfig.some(option => option.id === defaultSelection)
? defaultSelection
: optionsConfig[0]?.id;
paymentMethodSelections[method.id] = currentOption;
form.dataset.paymentOption = currentOption;
@@ -11537,7 +11839,7 @@
const optionTitle = document.createElement('div');
optionTitle.className = 'payment-option-title';
const titleKey = 'topup.method.pal24.title';
const titleKey = `topup.method.${method.id}.title`;
const titleValue = t(titleKey);
optionTitle.textContent = titleValue === titleKey ? 'Choose payment type' : titleValue;
optionGroup.appendChild(optionTitle);
@@ -11563,22 +11865,23 @@
const label = document.createElement('div');
label.className = 'payment-option-label';
const labelValue = t(config.titleKey);
label.textContent = labelValue === config.titleKey ? config.fallbackTitle : labelValue;
const labelValue = config.titleKey ? t(config.titleKey) : config.fallbackTitle;
label.textContent = labelValue && labelValue !== config.titleKey
? labelValue
: config.fallbackTitle;
const description = document.createElement('div');
description.className = 'payment-option-description';
const descriptionValue = t(config.descriptionKey);
const finalDescription = descriptionValue === config.descriptionKey
? config.fallbackDescription
: descriptionValue;
description.textContent = finalDescription;
text.appendChild(label);
const descriptionValue = config.descriptionKey ? t(config.descriptionKey) : config.fallbackDescription;
const finalDescription = descriptionValue && descriptionValue !== config.descriptionKey
? descriptionValue
: config.fallbackDescription;
if (finalDescription) {
description.textContent = finalDescription;
text.appendChild(description);
}
text.appendChild(label);
button.appendChild(icon);
button.appendChild(text);
@@ -11816,8 +12119,19 @@
const normalizedAmount = Number.isFinite(amountKopeks) ? Number(amountKopeks) : null;
const monitorExtra = { ...extra };
const methodOptions = Array.isArray(method.options) ? method.options : [];
const optionsMap = methodOptions.reduce((map, item) => {
const key = String(item.id);
map[key] = item;
return map;
}, {});
let option = null;
if (method.id === 'pal24') {
if (methodOptions.length) {
option = (options.providerOption || monitorExtra.selected_option || paymentMethodSelections[method.id] || methodOptions[0]?.id || '').toString();
paymentMethodSelections[method.id] = option;
monitorExtra.selected_option = option;
} else if (method.id === 'pal24') {
option = (options.providerOption || monitorExtra.selected_option || paymentMethodSelections[method.id] || 'sbp').toLowerCase();
if (!['card', 'sbp'].includes(option)) {
option = 'sbp';
@@ -11826,12 +12140,19 @@
monitorExtra.selected_option = option;
}
const titleKey = method.id === 'pal24' && option
? `topup.method.pal24.option.${option}.title`
: `topup.method.${method.id}.title`;
const titleFallback = method.id === 'pal24'
? (option === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)')
: method.id;
const selectedOption = option && (optionsMap[option] || optionsMap[String(option)]);
const optionTitleKey = selectedOption?.titleKey || selectedOption?.title_key;
const optionTitleFallback = selectedOption?.title || selectedOption?.name || option || method.id;
const titleKey = selectedOption && optionTitleKey
? optionTitleKey
: option
? `topup.method.${method.id}.option.${option}.title`
: `topup.method.${method.id}.title`;
const titleFallback = selectedOption
? optionTitleFallback
: method.id === 'pal24'
? (option === 'card' ? 'Bank card payment' : 'Faster Payments (SBP)')
: method.id;
setTopupModalSubtitle(titleKey, titleFallback);
body.innerHTML = '';
@@ -11902,8 +12223,8 @@
summary.appendChild(usdAmount);
}
const descriptionKey = method.id === 'pal24' && option
? `topup.method.pal24.option.${option}.description`
const descriptionKey = option
? `topup.method.${method.id}.option.${option}.description`
: `topup.method.${method.id}.description`;
const descriptionValue = t(descriptionKey);
if (descriptionValue && descriptionValue !== descriptionKey) {
+29
View File
@@ -0,0 +1,29 @@
import pytest
from app.config import settings
from app.database.models import PromoGroup
@pytest.fixture
def base_discount_settings(monkeypatch):
monkeypatch.setattr(settings, "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED", True)
monkeypatch.setattr(settings, "BASE_PROMO_GROUP_PERIOD_DISCOUNTS", "60:15")
yield
def test_base_promo_discount_applies_to_all_categories(base_discount_settings):
promo_group = PromoGroup(name="Default", is_default=True)
assert promo_group.get_discount_percent("period", 60) == 15
assert promo_group.get_discount_percent("servers", 60) == 15
assert promo_group.get_discount_percent("traffic", 60) == 15
assert promo_group.get_discount_percent("devices", 60) == 15
def test_specific_category_discount_overrides_base(base_discount_settings):
promo_group = PromoGroup(
name="Default", is_default=True, server_discount_percent=5
)
assert promo_group.get_discount_percent("servers", 60) == 5
assert promo_group.get_discount_percent("devices", 60) == 15