Compare commits

...

2844 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
Egor c3c105619b Update Dockerfile 2025-11-21 07:02:11 +03:00
Egor 01e575676f Update docker-registry.yml 2025-11-21 07:01:57 +03:00
Egor b53a43fcc6 Update docker-hub.yml 2025-11-21 07:01:41 +03:00
Egor 6a8ff1c1b4 Merge pull request #1976 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:36:35 +03:00
Egor 451ab514cc Merge pull request #1975 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-d1pv7w
Clean up CryptoBot prompt messages
2025-11-21 06:33:34 +03:00
Egor c8b6830704 Clean up CryptoBot amount prompts 2025-11-21 06:32:34 +03:00
Egor 74ee7149c5 Merge pull request #1974 from BEDOLAGA-DEV/revert-1972-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Revert "Fix Tribute payment prompt formatting"
2025-11-21 06:32:23 +03:00
Egor 506b96dec4 Revert "Fix Tribute payment prompt formatting" 2025-11-21 06:32:13 +03:00
Egor 927d4230a2 Merge pull request #1973 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:08:36 +03:00
Egor 69ff737b72 Merge pull request #1972 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Fix Tribute payment prompt formatting
2025-11-21 06:05:50 +03:00
Egor d0114a1f29 Fix Tribute payment prompt editing 2025-11-21 06:03:35 +03:00
Egor f92a0fd88b Merge pull request #1971 from BEDOLAGA-DEV/revert-1969-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Revert "Reject YooKassa webhooks lacking payment id"
2025-11-21 06:03:14 +03:00
Egor e7478cc1e1 Revert "Reject YooKassa webhooks lacking payment id" 2025-11-21 06:03:04 +03:00
Egor 5814f9757d Merge pull request #1970 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 05:40:12 +03:00
Egor cb7b9a9e22 Merge pull request #1969 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Reject YooKassa webhooks lacking payment id
2025-11-21 05:34:23 +03:00
Egor 6dc525dd72 Handle missing YooKassa payment ids gracefully 2025-11-21 05:26:42 +03:00
Egor 409c8c161b Merge pull request #1968 from BEDOLAGA-DEV/revert-1967-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Revert "Handle YooKassa webhook payloads without payment ids"
2025-11-21 05:26:31 +03:00
Egor 78db3a1e77 Revert "Handle YooKassa webhook payloads without payment ids" 2025-11-21 05:26:21 +03:00
Egor d0c5ff19e1 Merge pull request #1967 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Handle YooKassa webhook payloads without payment ids
2025-11-21 05:17:43 +03:00
Egor 6a29765ddf Handle YooKassa webhooks without payment ids 2025-11-21 05:17:09 +03:00
Egor 95147fbe03 Merge pull request #1964 from BEDOLAGA-DEV/revert-1963-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Revert "Clean up Platega and YooKassa invoices after payment"
2025-11-21 04:49:30 +03:00
Egor 0137321d24 Revert "Clean up Platega and YooKassa invoices after payment" 2025-11-21 04:49:17 +03:00
Egor 0521306699 Merge pull request #1963 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Clean up Platega and YooKassa invoices after payment
2025-11-21 04:18:23 +03:00
Egor 2a10e7d2c7 Clean up Platega and YooKassa invoices after payment 2025-11-21 04:18:03 +03:00
Egor e78e9e1a4d Merge pull request #1962 from BEDOLAGA-DEV/revert-1960-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Revert "Clean up Platega and YooKassa prompts"
2025-11-21 04:17:22 +03:00
Egor 29d0b54b04 Revert "Clean up Platega and YooKassa prompts" 2025-11-21 04:17:13 +03:00
Egor 64fe3fdbb7 Merge pull request #1960 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Clean up Platega and YooKassa prompts
2025-11-21 04:02:54 +03:00
Egor 04edca175e Merge pull request #1961 from BEDOLAGA-DEV/revert-1959-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Revert "Clean up Telegram Stars payment flow"
2025-11-21 04:02:38 +03:00
Egor 840091cb05 Revert "Clean up Telegram Stars payment flow" 2025-11-21 04:02:28 +03:00
Egor 473c3704cf Clean up Platega and YooKassa prompts 2025-11-21 04:02:22 +03:00
Egor af630b22c9 Merge pull request #1959 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Clean up Telegram Stars payment flow
2025-11-21 03:27:06 +03:00
Egor 29fe2f2016 Clean up Telegram Stars payment flow 2025-11-21 03:26:34 +03:00
Egor cd5691640e Merge pull request #1958 from BEDOLAGA-DEV/revert-1957-bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:26:03 +03:00
Egor 77c217f9ad Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:25:55 +03:00
Egor 9e963569bc Merge pull request #1957 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Clean up Telegram Stars top-up messages
2025-11-21 03:24:32 +03:00
Egor c7114ec359 Clean up Telegram Stars top-up messages 2025-11-21 03:24:17 +03:00
Egor b9586ad72a Merge pull request #1956 from BEDOLAGA-DEV/revert-1955-bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:18:19 +03:00
Egor b6e333127c Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:18:10 +03:00
Egor 6ea2c2cceb Merge pull request #1955 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Clean up Telegram Stars top-up messages
2025-11-21 03:16:40 +03:00
Egor af42377c3b Clean up Telegram Stars top-up messages 2025-11-21 03:16:24 +03:00
Egor 82881a4c45 Merge pull request #1954 from BEDOLAGA-DEV/revert-1953-bedolaga/remove-unnecessary-messages-after-balance-update
Revert "Clean up Telegram Stars payment messages"
2025-11-21 03:16:12 +03:00
Egor 70d50e9c47 Revert "Clean up Telegram Stars payment messages" 2025-11-21 03:16:04 +03:00
Egor ddb5c01026 Merge pull request #1953 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update
Clean up Telegram Stars payment messages
2025-11-21 03:14:41 +03:00
Egor e287adcb84 Clean up Telegram Stars payment messages 2025-11-21 03:14:25 +03:00
Egor 83d041c13b Merge pull request #1952 from BEDOLAGA-DEV/bedolaga/fix-auto-renewal-calculation-error
Fix autopay renewal pricing for promo groups
2025-11-20 23:13:12 +03:00
Egor 545c5fd749 Eager load promo groups for autopay renewals 2025-11-20 23:12:49 +03:00
Egor 7f0e2de7c4 Merge pull request #1951 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-service-errors
Handle missing channel link in subscription check
2025-11-20 23:06:53 +03:00
Egor 1fdf1e49a3 Handle missing channel link in subscription check 2025-11-20 23:06:34 +03:00
Egor 08f82ee511 Merge pull request #1950 from BEDOLAGA-DEV/bedolaga-q76r4i
Fix trial reset cleanup
2025-11-20 22:58:04 +03:00
Egor fba217b87f Fix trial reset by clearing server links 2025-11-20 22:49:57 +03:00
Egor 6668a18ad3 Merge pull request #1949 from BEDOLAGA-DEV/revert-1948-bedolaga-uhav33
Revert "Add admin trial reset controls"
2025-11-20 22:46:40 +03:00
Egor 11c239f46c Revert "Add admin trial reset controls" 2025-11-20 22:46:30 +03:00
Egor ac62dee279 Merge pull request #1948 from BEDOLAGA-DEV/bedolaga-uhav33
Add admin trial reset controls
2025-11-20 22:34:27 +03:00
Egor 29a17a7876 Add admin trial reset controls 2025-11-20 22:33:43 +03:00
Egor 694c934612 Merge pull request #1947 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 22:11:23 +03:00
Egor c01d2bfcd7 Update README.md 2025-11-20 22:09:07 +03:00
Egor b3708f99c0 Merge pull request #1943 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-error-on-double-click
Handle repeat device selection without redundant updates
2025-11-20 18:39:46 +03:00
Egor f651f921aa Handle repeat device selection without redundant updates 2025-11-20 18:39:25 +03:00
Egor ada6845036 Merge pull request #1938 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 17:58:46 +03:00
Egor d33b259537 Merge pull request #1936 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users-yvuqli
Throttle broadcast sending for large campaigns
2025-11-20 17:31:23 +03:00
Egor 3c2ed147ca Merge pull request #1937 from BEDOLAGA-DEV/revert-1934-bedolaga/fix-email-dispatch-limit-to-100000-users
Revert "Remove broadcast recipient limit"
2025-11-20 17:31:07 +03:00
Egor d46e88c2b5 Revert "Remove broadcast recipient limit" 2025-11-20 17:30:56 +03:00
Egor 1477491e6e Throttle broadcast speed for large campaigns 2025-11-20 17:30:30 +03:00
Egor 647dc0bd7c Merge pull request #1935 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 15:47:45 +03:00
Egor f6a45993e9 Merge pull request #1934 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users
Remove broadcast recipient limit
2025-11-20 15:47:24 +03:00
Egor 0766320411 Fetch all broadcast users in batches 2025-11-20 15:36:38 +03:00
Egor 5ebf113fd6 Merge pull request #1915 from BEDOLAGA-DEV/dev4
Dev4
2025-11-20 01:30:14 +03:00
Egor 2005d103f8 Merge pull request #1914 from BEDOLAGA-DEV/bedolaga/fix-balance-top-up-through-method-10
Handle Platega description byte limit
2025-11-20 01:20:05 +03:00
Egor 3935813336 Trim Platega descriptions by byte length 2025-11-20 01:16:25 +03:00
Egor ec71fa78c0 Merge pull request #1913 from BEDOLAGA-DEV/bedolaga/fix-subscription-channel-error
Handle duplicate channel subscription prompts
2025-11-20 01:12:44 +03:00
Egor cd2b0a2d67 Handle duplicate channel subscription messages 2025-11-20 01:10:28 +03:00
Egor 68d502804f Merge pull request #1911 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 01:17:55 +03:00
Egor afbd343442 Merge pull request #1910 from BEDOLAGA-DEV/bedolaga/fix-subscription-creation-api-error-uklgqk
Preserve autopay and device limits during subscription replacement
2025-11-18 01:17:37 +03:00
Egor 991e5a3112 Preserve zero device limit when replacing trials 2025-11-18 01:14:43 +03:00
Egor 7d9de6ca5d Merge pull request #1906 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 00:28:35 +03:00
Egor 562bb69082 Merge pull request #1905 from BEDOLAGA-DEV/bedolaga/expand-bot-api-functionality
Expand admin API for polls, logs, and tickets
2025-11-18 00:27:49 +03:00
Egor a02416c78b Add poll sending and ticket API enhancements 2025-11-18 00:22:46 +03:00
Egor 81f56008d4 Delete install_bot.sh 2025-11-16 12:04:21 +03:00
Egor 43a7039479 Merge pull request #1904 from Fr1ngg/dev4
Dev4
2025-11-15 01:46:45 +03:00
Egor a9f3904524 Merge pull request #1903 from Fr1ngg/bedolaga/fix-ru-cards-payment-processing-error
Limit Platega description length
2025-11-15 00:59:45 +03:00
Egor 9136c7cfe3 Limit Platega description length 2025-11-15 00:56:11 +03:00
Egor 5595846929 Merge pull request #1902 from Fr1ngg/dev4
Dev4
2025-11-14 01:32:49 +03:00
Egor 9ebe2971bf Merge pull request #1901 from Fr1ngg/bedolaga/fix-402-error-in-subscription-renewal-endpoint
Fix renewal mixed payment logic
2025-11-14 01:31:06 +03:00
Egor fadea75314 Fix renewal mixed payment logic 2025-11-14 01:30:31 +03:00
Egor a2009cae39 Merge pull request #1899 from Fr1ngg/dev4
Allow trial users to renew from miniapp
2025-11-14 01:17:04 +03:00
Egor 0187adb6f9 Merge pull request #1898 from Fr1ngg/bedolaga/fix-trial-subscription-error-in-miniapp
Enable trial subscriptions to renew via miniapp
2025-11-14 01:16:00 +03:00
Egor 302a028873 Allow trial users to renew from miniapp 2025-11-14 00:15:43 +03:00
Egor 77c9c19806 Merge pull request #1897 from Fr1ngg/dev4
Dev4
2025-11-13 23:37:04 +03:00
Egor 724da2545e Merge pull request #1896 from Fr1ngg/main
w
2025-11-13 23:36:46 +03:00
Egor 079af66d9f Merge pull request #1895 from Fr1ngg/bedolaga/fix-subscription-purchase-failure-with-platega
Fix Platega quick amount selection flow
2025-11-13 23:36:23 +03:00
Egor e701d234f0 Fix Platega quick amount selection flow 2025-11-13 23:33:07 +03:00
Egor 1b111975bb Update config.py 2025-11-12 16:41:37 +03:00
Egor c874425e0e Merge pull request #1890 from DOFER998/c0mrade/refactor-subtract-balance-logging
refactor: improve logging level for balance subtraction debug info
2025-11-12 16:28:09 +03:00
c0mrade c6c112fd17 refactor: improve logging level for balance subtraction debug info 2025-11-12 10:21:28 +03:00
Egor 57d924e3ba Merge pull request #1889 from Fr1ngg/dev4
Доп языки + фиксы
2025-11-12 05:38:34 +03:00
Egor 9d5b9ebf82 Add files via upload 2025-11-12 05:28:39 +03:00
Egor 354358e860 Create fail.html 2025-11-12 05:28:25 +03:00
Egor 327c9d52d2 Create succes.html 2025-11-12 05:27:59 +03:00
Egor c03051614d Merge pull request #1888 from Fr1ngg/revert-1887-bedolaga/activate-trial-automatically-after-payment-le7ksb
Revert "Enable automatic trial activation after balance top-up"
2025-11-12 05:24:24 +03:00
Egor ac9e5cd908 Revert "Enable automatic trial activation after balance top-up" 2025-11-12 05:24:14 +03:00
Egor 585bb56a99 Merge pull request #1887 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment-le7ksb
Enable automatic trial activation after balance top-up
2025-11-12 04:45:20 +03:00
Egor 8f33eb0cc6 Enable automatic trial activation after balance top-up 2025-11-12 04:45:03 +03:00
Egor 71515a87de Merge pull request #1886 from Fr1ngg/revert-1885-bedolaga/activate-trial-automatically-after-payment
Revert "feat: auto activate paid trials after balance top-up"
2025-11-12 04:44:54 +03:00
Egor 0a4ede8f79 Revert "feat: auto activate paid trials after balance top-up" 2025-11-12 04:44:46 +03:00
Egor 040b33b541 Merge pull request #1885 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment
feat: auto activate paid trials after balance top-up
2025-11-12 04:34:06 +03:00
Egor 5c3484ee28 feat: auto activate paid trials after balance top-up 2025-11-12 04:27:33 +03:00
Egor ef309c3101 Merge pull request #1884 from Fr1ngg/bedolaga/fix-trial-subscription-display-issue
Fix trial subscription status display in menu
2025-11-12 03:09:46 +03:00
Egor 51dad7936b Fix trial subscription status display in menu 2025-11-12 03:03:34 +03:00
Egor 097734b31d Merge pull request #1883 from Fr1ngg/bedolaga/fix-user-deletion-foreign-key-violation
Handle Platega payments when deleting users
2025-11-12 02:47:54 +03:00
Egor 7185fde9bc Handle Platega payments when deleting users 2025-11-12 02:47:37 +03:00
Egor 75f2cdb04b Update remnawave.py 2025-11-12 02:41:44 +03:00
Egor d3c445ed2d Merge pull request #1882 from Fr1ngg/bedolaga/fix-webhook-issue-with-heleket
Fix Heleket webhook signature verification
2025-11-12 02:40:34 +03:00
Egor 804f088435 Fix Heleket webhook signature verification 2025-11-11 16:41:58 +03:00
Egor 6c1b70e796 Merge pull request #1881 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint-n1nsij
Handle CryptoBot renewal payload fallbacks
2025-11-11 13:13:14 +03:00
Egor a3532e5878 Handle CryptoBot renewal payload fallbacks 2025-11-11 13:06:10 +03:00
Egor a8c1b32912 Merge pull request #1880 from Fr1ngg/revert-1877-bedolaga/fix-403-error-on-subscription-renewal-endpoint
Revert "Allow expired miniapp subscriptions to load renewal options"
2025-11-11 12:22:29 +03:00
Egor 20b0c8a0e2 Revert "Allow expired miniapp subscriptions to load renewal options" 2025-11-11 12:22:15 +03:00
Egor bc18692d75 Merge pull request #1877 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint
Allow expired miniapp subscriptions to load renewal options
2025-11-11 10:46:42 +03:00
Egor c05ecf1ecd Allow expired subscriptions to access renewal options 2025-11-11 10:42:33 +03:00
Egor 8381fc52f1 Merge pull request #1876 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-cen8qi
Fix dynamic traffic price recalculation in localization
2025-11-11 10:33:33 +03:00
Egor a697ffa8f1 Fix dynamic traffic localization prices 2025-11-11 10:28:09 +03:00
Egor 4eb36fab6d Merge pull request #1875 from Fr1ngg/revert-1873-bedolaga/add-default-localization-support-for-flags-ocds8i
Revert "Add dynamic localization support for UA and ZH languages"
2025-11-11 10:27:19 +03:00
Egor 4b0780b549 Revert "Add dynamic localization support for UA and ZH languages" 2025-11-11 10:27:10 +03:00
Egor 1befc2982a Merge pull request #1873 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-ocds8i
Add dynamic localization support for UA and ZH languages
2025-11-11 09:10:15 +03:00
Egor df1f225405 Populate dynamic prices and support text for new locales 2025-11-11 09:07:02 +03:00
Egor df6feca53c Add files via upload 2025-11-11 08:37:03 +03:00
Egor bd41efb974 Merge pull request #1869 from Fr1ngg/main
w
2025-11-11 08:34:18 +03:00
Egor a1216bface Update README.md 2025-11-11 08:32:07 +03:00
Egor e0969b315a Merge pull request #1868 from remnawave-contrib/ivan-nginx-readme-make
Added «make» installation command to Readme
2025-11-11 08:30:06 +03:00
Ivan.Nginx 395bccf4e5 Merge branch 'Fr1ngg:main' into main 2025-11-11 08:23:02 +03:00
Ivan.Nginx f6e5cfce54 Merge pull request #2 from remnawave-contrib/ivan-nginx-readme-add-make-installation
Update installation instructions for 'make' command
2025-11-11 08:22:16 +03:00
Ivan.Nginx a26e89147b Update installation instructions for 'make' command 2025-11-11 08:21:34 +03:00
Egor 4396020177 Merge pull request #1867 from Fr1ngg/dev4
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:16:20 +03:00
Egor e2c89ec19e Merge pull request #1866 from Fr1ngg/bedolaga/extend-api-for-payment-method-integration-types
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:15:06 +03:00
Egor 68c125ba14 feat: expose integration metadata for miniapp payments 2025-11-11 08:11:42 +03:00
Egor 8c794fc91c Update README.md 2025-11-11 07:42:15 +03:00
Egor efbe8475df Update README.md 2025-11-11 07:41:43 +03:00
Egor 7835e02d5c Merge pull request #1860 from Gy9vin/main
Апи поиск в боте по тгID
2025-11-11 02:08:32 +03:00
Mikhail 21a5499af8 Merge branch 'Fr1ngg:main' into main 2025-11-10 13:54:03 +03:00
gy9vin dd85db071a Апи поиск в боте по тгID 2025-11-10 13:53:21 +03:00
Egor 813193102c Update Dockerfile 2025-11-10 06:28:50 +03:00
Egor 42112d6c8a Update docker-registry.yml 2025-11-10 06:28:40 +03:00
Egor dfe5e36fe0 Update docker-hub.yml 2025-11-10 06:28:31 +03:00
Egor b5d4a54e97 Update README.md 2025-11-10 06:22:26 +03:00
Egor 1eaf7204ce Merge pull request #1859 from Fr1ngg/dev4
fix #1849 Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:13:51 +03:00
Egor 8cc37dc439 Merge pull request #1858 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot-i1csym
Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:11:42 +03:00
Egor ccafb18122 Round CryptoBot top-up amounts up to whole rubles 2025-11-10 06:11:18 +03:00
Egor 654b132ae6 Merge pull request #1857 from Fr1ngg/revert-1855-bedolaga/fix-webhook-handler-error-in-cryptobot
Revert "Refactor CryptoBot notifications to avoid transaction rollback"
2025-11-10 06:11:05 +03:00
Egor 815fa0560c Revert "Refactor CryptoBot notifications to avoid transaction rollback" 2025-11-10 06:10:54 +03:00
Egor c4d19810a4 Merge pull request #1856 from Fr1ngg/dev4
fix
2025-11-10 06:05:55 +03:00
Egor 9fbb32eb5b Merge pull request #1855 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot
Refactor CryptoBot notifications to avoid transaction rollback
2025-11-10 05:59:09 +03:00
Egor 3ce3cde859 Refactor CryptoBot notifications to avoid transaction rollback 2025-11-10 05:53:26 +03:00
Egor 4e71dcd2b1 Merge pull request #1853 from Fr1ngg/dev4
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:33:11 +03:00
Egor acf21fa6d2 Merge pull request #1852 from Fr1ngg/bedolaga/update-requirements.txt-and-fix-pal24-webhook
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:29:04 +03:00
Egor f2b724a78d Adjust Pal24 callback handling and SBP link fallback 2025-11-10 05:26:02 +03:00
Egor 03473360c1 Merge pull request #1851 from Fr1ngg/dev4
Dev4
2025-11-10 04:30:16 +03:00
Egor 27dc68be41 Merge pull request #1850 from Fr1ngg/bedolaga/fix-webhook-bot-error-handling
Prevent maintenance monitoring from crashing without RemnaWave URL
2025-11-10 04:26:35 +03:00
Egor b15d78f8af Handle missing RemnaWave config in maintenance monitoring 2025-11-10 04:25:52 +03:00
Egor d232d14937 Merge pull request #1848 from Fr1ngg/dev4
fix migration on new bases
2025-11-10 04:13:19 +03:00
Egor 927d469db2 Merge pull request #1847 from Fr1ngg/bedolaga/fix-database-migration-crash-on-first-bot-launch
Handle missing tables when creating indexes during init_db
2025-11-10 04:06:46 +03:00
Egor e20e9e7cec Fix init_db index creation on fresh databases 2025-11-10 04:06:28 +03:00
Egor 0f097c3108 Merge pull request #1830 from Gy9vin/main
Фикс простой покупки!
2025-11-10 03:37:31 +03:00
gy9vin eaa3c80d59 Фикс простой покупки! 2025-11-09 21:55:58 +03:00
Egor 0c49eddfde Merge pull request #1828 from Gy9vin/main
Фикс проверки зачисления платежа юкассы(защита от дублирования)
2025-11-09 20:09:15 +03:00
gy9vin 33882f1e93 Фикс проверки зачисления платежа юкассы(защита от дублирования) 2025-11-09 18:31:39 +03:00
Egor f94666b68d Merge pull request #1826 from Fr1ngg/dev4
Dev4
2025-11-09 10:29:27 +03:00
Egor d32a49f801 Merge pull request #1825 from Fr1ngg/bedolaga/fix-bulk_update-to-include-where-clause
Fix bulk_update to target records by primary key
2025-11-09 10:29:11 +03:00
Egor 9596f82ea6 Fix bulk_update to target records by primary key 2025-11-09 10:28:47 +03:00
Egor 0e3fdf7d61 Merge pull request #1824 from Fr1ngg/dev4
Razgonchik
2025-11-09 10:24:57 +03:00
Egor 54cfec6029 Merge pull request #1823 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits-y7sxo6
Ensure YooKassa retries finish pending credits
2025-11-09 10:13:20 +03:00
Egor 9e88eae416 Ensure YooKassa retries finish pending credits 2025-11-09 10:13:01 +03:00
Egor 8d32efac73 Merge pull request #1822 from Fr1ngg/revert-1821-bedolaga/fix-repeated-yookassa-deposits
Revert "Prevent double processing of YooKassa payments"
2025-11-09 10:04:02 +03:00
Egor 9f07b6007e Revert "Prevent double processing of YooKassa payments" 2025-11-09 10:03:54 +03:00
Egor 2fb3b6c5dc Merge pull request #1821 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits
Prevent double processing of YooKassa payments
2025-11-09 10:03:38 +03:00
Egor 0637576425 Prevent double processing of YooKassa payments 2025-11-09 10:00:17 +03:00
Egor ee7a445610 Update database.py 2025-11-09 09:46:40 +03:00
Egor ba4c7ee7ac Update database.py 2025-11-09 09:43:26 +03:00
Egor 4da169eb0f Merge pull request #1819 from Fr1ngg/bedolaga/fix-health-endpoints-for-sqlite-nullpool
Handle missing pool metrics for NullPool
2025-11-09 09:40:53 +03:00
Egor 28937abab4 Handle missing pool metrics for NullPool 2025-11-09 09:40:08 +03:00
Egor b1dcf2779e Update database.py 2025-11-09 09:25:59 +03:00
Egor 3219062ce2 Update database.py 2025-11-09 09:22:36 +03:00
Egor 2ac72951f8 Merge pull request #1818 from Fr1ngg/bedolaga/add-health-and-metrics-endpoints-to-fastapi
Add database health and pool metrics endpoints
2025-11-09 09:17:35 +03:00
Egor cefb8cba78 Add database health and pool metrics endpoints 2025-11-09 09:17:18 +03:00
Egor e869655028 Update database.py 2025-11-09 09:11:47 +03:00
Egor ae03ede209 Merge pull request #1817 from Fr1ngg/dev4
Dev
2025-11-09 08:34:49 +03:00
Egor a72bb25ea2 Update database.py 2025-11-09 08:30:38 +03:00
Egor 0b22bc7056 Merge pull request #1815 from Fr1ngg/bedolaga/-nullpool-queuepool-wh494i
Handle optional Pal24 metadata fields
2025-11-09 08:24:26 +03:00
Egor cde84ff20b Handle optional Pal24 metadata fields 2025-11-09 08:23:01 +03:00
Egor f76114d7dc Update database.py 2025-11-09 08:17:43 +03:00
Egor 9708a56fd7 Update database.py 2025-11-09 08:14:41 +03:00
Egor a360337660 Merge pull request #1813 from Fr1ngg/dev4
фикс бекапов/восстановления
2025-11-09 07:39:18 +03:00
Egor 9f78335651 Merge pull request #1812 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-1wazcg
Avoid duplicate association errors during restore
2025-11-09 07:31:17 +03:00
Egor ef8142a99b Skip duplicate squad promo associations during restore 2025-11-09 07:30:54 +03:00
Egor 06d51f9fcf Merge pull request #1811 from Fr1ngg/revert-1810-bedolaga/improve-backup-and-restore-mechanism-gn1h90
Revert "Handle PostgreSQL backups without pg_dump"
2025-11-09 07:30:41 +03:00
Egor 4d94347179 Revert "Handle PostgreSQL backups without pg_dump" 2025-11-09 07:30:31 +03:00
Egor 2f6aa0b1e6 Merge pull request #1810 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-gn1h90
Handle PostgreSQL backups without pg_dump
2025-11-09 07:23:11 +03:00
Egor 536a58d4d3 Handle PostgreSQL backups without pg_dump 2025-11-09 07:22:50 +03:00
Egor 62f7c07059 Merge pull request #1809 from Fr1ngg/revert-1808-bedolaga/improve-backup-and-restore-mechanism
Revert "Refactor backup pipeline to use archive dumps"
2025-11-09 07:22:10 +03:00
Egor 0648edb3c4 Revert "Refactor backup pipeline to use archive dumps" 2025-11-09 07:21:56 +03:00
Egor 9b6c235424 Merge pull request #1808 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism
Refactor backup pipeline to use archive dumps
2025-11-09 07:12:18 +03:00
Egor 2fc35950cf Refactor backup system with archive dumps 2025-11-09 07:12:01 +03:00
Egor d2a5d74239 Merge pull request #1807 from Fr1ngg/dev4
Dev4
2025-11-09 07:06:24 +03:00
Egor 6b04c6748e Merge pull request #1806 from Fr1ngg/bedolaga/add-trial-activation-funds-check
Prefill top-up amount for paid trial activation
2025-11-09 06:58:03 +03:00
Egor 10b44cbdb7 Prefill top-up amount for paid trial activation 2025-11-09 06:51:50 +03:00
Egor dde04c716c Merge pull request #1805 from Fr1ngg/bedolaga/fix-variable-loading-for-minimum-sum
Adjust Platega top-up prompt to show configured limits
2025-11-09 06:44:55 +03:00
Egor 819f19a7ea Update Platega prompt to use configured limits 2025-11-09 06:44:23 +03:00
Egor e7201597ab Merge pull request #1804 from Fr1ngg/bedolaga/fix-pall24-payment-method-issue
Fix Pal24 SBP link handling after status checks
2025-11-09 06:35:54 +03:00
Egor ea61aa7053 Fix Pal24 SBP link handling after status checks 2025-11-09 06:31:02 +03:00
Egor 14546a8485 Merge pull request #1803 from Fr1ngg/bedolaga/add-ip-validation-for-yookassa-webhooks
Handle Cloudflare proxied YooKassa webhooks
2025-11-09 06:22:17 +03:00
Egor f55455761f Trust Cloudflare headers for YooKassa webhooks 2025-11-09 06:21:40 +03:00
Egor b1a7767827 Merge pull request #1802 from Fr1ngg/revert-1801-bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Revert "Fix server category button routing in admin panel"
2025-11-09 05:55:16 +03:00
Egor 43c04b7e64 Revert "Fix server category button routing in admin panel" 2025-11-09 05:55:07 +03:00
Egor add679cf6f Merge pull request #1801 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Fix server category button routing in admin panel
2025-11-09 05:49:00 +03:00
Egor ee173190a0 Fix category edit menu callback 2025-11-09 05:48:45 +03:00
Egor a6e21b47f6 Merge pull request #1800 from Fr1ngg/revert-1799-bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Revert "Fix category pricing to ignore full squads"
2025-11-09 05:40:56 +03:00
Egor cd627e5840 Revert "Fix category pricing to ignore full squads" 2025-11-09 05:40:47 +03:00
Egor fc096e6867 Merge pull request #1799 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Fix category pricing to ignore full squads
2025-11-09 05:33:29 +03:00
Egor b591844c4e Fix category pricing to ignore full squads 2025-11-09 05:33:13 +03:00
Egor 9557acb546 Merge pull request #1798 from Fr1ngg/revert-1797-bedolaga/add-server-category-feature-to-admin-panel
Revert "Ensure server category selection skips full squads"
2025-11-09 05:31:20 +03:00
Egor 1fa6e75bd3 Revert "Ensure server category selection skips full squads" 2025-11-09 05:31:09 +03:00
Egor 8965e43a55 Merge pull request #1797 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel
Ensure server category selection skips full squads
2025-11-09 05:24:50 +03:00
Egor 8e7a6beece Ensure category auto-selection respects capacity 2025-11-09 05:23:58 +03:00
Egor e9f4fd8007 Merge pull request #1796 from Fr1ngg/revert-1793-bedolaga/implement-server-grouping-system-for-vpn-bot
Revert "Add server group backend selection logic"
2025-11-09 04:54:55 +03:00
Egor a991c59d58 Revert "Add server group backend selection logic" 2025-11-09 04:54:47 +03:00
Egor dd0624adf8 Merge pull request #1795 from Fr1ngg/revert-1794-bedolaga/check-functionality-for-server-grouping
Revert "Fix autopurchase server selection and RemnaWave stats handling"
2025-11-09 04:54:31 +03:00
Egor ab51cbe96a Revert "Fix autopurchase server selection and RemnaWave stats handling" 2025-11-09 04:54:24 +03:00
Egor 2f7a08deb0 Merge pull request #1794 from Fr1ngg/bedolaga/check-functionality-for-server-grouping
Fix autopurchase server selection and RemnaWave stats handling
2025-11-09 04:50:50 +03:00
Egor a0fa7f986b Fix autopurchase server selection and RemnaWave stats handling 2025-11-09 04:50:32 +03:00
Egor b2d3eebe39 Merge pull request #1793 from Fr1ngg/bedolaga/implement-server-grouping-system-for-vpn-bot
Add server group backend selection logic
2025-11-09 04:39:54 +03:00
Egor a043fc0e46 feat: add backend support for server groups 2025-11-09 04:36:33 +03:00
Egor d6eec8787e Merge pull request #1792 from Fr1ngg/bedolaga/fix-payment-methods-display-for-platega.io
Add Platega payment option to balance top-up list
2025-11-09 03:31:21 +03:00
Egor 95eee42047 Добавить Platega в список пополнения 2025-11-09 03:30:49 +03:00
Egor c673d6db4f Merge pull request #1790 from Fr1ngg/dev4
Dev4
2025-11-08 18:58:33 +03:00
Egor bb32a63d48 Merge pull request #1789 from Fr1ngg/bedolaga/fix-null-value-constraint-in-promo_groups
Fix default promo group creation with priority column
2025-11-08 18:58:13 +03:00
Egor af5acb272c Fix default promo group creation with priority column 2025-11-08 18:57:53 +03:00
Egor 5cd5ac8710 Merge pull request #1788 from Fr1ngg/revert-1787-dev4
Revert "Dev4"
2025-11-08 12:05:27 +03:00
Egor 8bb58b44b3 Revert "Dev4" 2025-11-08 12:05:12 +03:00
Egor 511ecd506f Merge pull request #1787 from Fr1ngg/dev4
Dev4
2025-11-08 11:38:41 +03:00
Egor 62fc014bc3 Restore json serialization helper for YooKassa webhook tests 2025-11-08 11:38:18 +03:00
Egor 961f564274 Merge pull request #1786 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-lcydgf
Remove YooKassa IP filtering from webhooks
2025-11-08 11:27:53 +03:00
Egor 984870c78c Remove YooKassa IP filtering from webhooks 2025-11-08 11:27:37 +03:00
Egor 6196c135d2 Merge pull request #1785 from Fr1ngg/revert-1782-bedolaga/fix-webhook-ip-verification-for-w66vvd
Revert "Verify YooKassa webhooks against API"
2025-11-08 11:27:23 +03:00
Egor bf1b8315a8 Revert "Verify YooKassa webhooks against API" 2025-11-08 11:27:13 +03:00
Egor b7eabcf7e7 Merge pull request #1784 from Fr1ngg/revert-1783-dev4
Revert "Dev4"
2025-11-08 11:08:55 +03:00
Egor e83ebc4f02 Revert "Dev4" 2025-11-08 11:08:47 +03:00
Egor 1a4758f4cc Merge pull request #1783 from Fr1ngg/dev4
Dev4
2025-11-08 11:04:08 +03:00
Egor b80e8b3804 Merge pull request #1782 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-w66vvd
Verify YooKassa webhooks against API
2025-11-08 11:00:49 +03:00
Egor e036173670 Verify YooKassa webhooks against API 2025-11-08 11:00:28 +03:00
Egor bd728c6466 Merge pull request #1781 from Fr1ngg/revert-1777-bedolaga/fix-webhook-ip-verification-for
Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks"
2025-11-08 10:58:48 +03:00
Egor e3aec310a3 Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks" 2025-11-08 10:58:34 +03:00
Egor 1c59ce601e Merge pull request #1780 from Fr1ngg/revert-1779-dev4
Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu "
2025-11-08 10:58:21 +03:00
Egor 0563f7524f Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu " 2025-11-08 10:58:12 +03:00
Egor cedbe07759 Merge pull request #1779 from Fr1ngg/dev4
da eb tvoyu mat' kakoy pidoras delal etu ukassu
2025-11-08 10:53:02 +03:00
Egor 0746b2a5a7 Merge pull request #1778 from Fr1ngg/main
w
2025-11-08 10:52:15 +03:00
Egor e8a72860dc Merge pull request #1777 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for
Handle Cloudflare forwarded IPs for YooKassa webhooks
2025-11-08 10:51:50 +03:00
Egor abbfe4a7d3 Handle Cloudflare forwarded IPs for YooKassa webhooks 2025-11-08 10:50:56 +03:00
Egor 1ad5d9123b Update Dockerfile 2025-11-08 09:05:30 +03:00
Egor 57031e4afe Update docker-registry.yml 2025-11-08 09:05:14 +03:00
Egor b54d2434c0 Update docker-hub.yml 2025-11-08 09:05:02 +03:00
Egor 7c2d7b6b4f Merge pull request #1776 from Fr1ngg/dev4
платный триал + фиксы вебхука ебучей юкассы(без негатива))(
2025-11-08 08:35:25 +03:00
Egor 9e731a5f18 Merge pull request #1775 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-fo5kxf
Bedolaga/add paid trial activation feature fo5kxf
2025-11-08 08:25:32 +03:00
Egor cd3832fef0 Handle trial provisioning failures by refunding charges 2025-11-08 08:14:49 +03:00
Egor cb6d51ed3d Remove trial squad override setting 2025-11-08 07:55:24 +03:00
Egor 8b75768296 Merge pull request #1773 from Fr1ngg/revert-1772-bedolaga/add-paid-trial-activation-feature-pws6p8
Revert "Rollback trial activation when charge errors occur"
2025-11-08 07:55:14 +03:00
Egor 3f0a26b1c7 Revert "Rollback trial activation when charge errors occur" 2025-11-08 07:55:04 +03:00
Egor de4f608d09 Merge pull request #1772 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-pws6p8
Rollback trial activation when charge errors occur
2025-11-08 07:42:33 +03:00
Egor 618327b9f1 Rollback trial trials when post-charge errors occur 2025-11-08 07:37:21 +03:00
Egor 54fdad8f0c Merge pull request #1771 from Fr1ngg/revert-1770-bedolaga/add-paid-trial-activation-feature-obw312
Revert "Delay trial activation charges until after subscription creation"
2025-11-08 07:27:03 +03:00
Egor e9bbd68aa1 Revert "Delay trial activation charges until after subscription creation" 2025-11-08 07:26:54 +03:00
Egor 5df4837087 Merge pull request #1770 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-obw312
Delay trial activation charges until after subscription creation
2025-11-08 07:18:14 +03:00
Egor 025e74c462 Defer trial activation charges until subscription creation succeeds 2025-11-08 07:17:57 +03:00
Egor 3208303c23 Merge pull request #1767 from Fr1ngg/bedolaga/remove-webhook-secret-references-for-yukassa-n5vl0v
Handle YooKassa cancellations in FastAPI webhook
2025-11-08 05:59:35 +03:00
Egor 4cf0c5a971 Handle YooKassa cancellations in FastAPI webhook 2025-11-08 05:44:53 +03:00
Egor 7519352e79 Merge pull request #1765 from Fr1ngg/main
w
2025-11-08 04:47:01 +03:00
Egor e7f94f9661 Merge pull request #1763 from aleksandryants/patch-1
Add wata payment processing method
2025-11-08 04:46:16 +03:00
Egor cb68a612e9 Merge pull request #1764 from Gy9vin/main
Обновка!
2025-11-08 04:44:25 +03:00
Egor 44d4deb5a6 Update .gitignore 2025-11-08 04:43:58 +03:00
gy9vin 23c73d1f21 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-07 23:56:51 +03:00
gy9vin 607beca837 fix 2025-11-07 23:56:49 +03:00
Egor 31a5f8766f Delete QWEN.md 2025-11-07 23:21:06 +03:00
gy9vin cfdc829f48 update 2025-11-07 22:24:43 +03:00
aleksandryants c44cefb983 Add wata payment processing method
Добавить обработку платежа wata
2025-11-07 19:49:27 +03:00
Egor 79c2307c92 Merge pull request #1762 from Fr1ngg/dev4
ебучая юкасса
2025-11-07 10:55:04 +03:00
Egor 25f126a5cb Merge pull request #1761 from Fr1ngg/bedolaga/disable-webhook-secret-validation-and-check-ip-tr0szb
fix: allow trusted public proxies for YooKassa webhooks
2025-11-07 10:49:17 +03:00
Egor 45bd54e7b0 fix: allow trusted public proxies for YooKassa webhooks 2025-11-07 10:43:19 +03:00
Egor 4ce43f80b8 Merge pull request #1755 from Fr1ngg/dev4
Platega + small fixs
2025-11-07 08:40:52 +03:00
Egor f7a4cedbbf Fix missing logging import in Platega handler 2025-11-07 08:29:29 +03:00
Egor 6f83122e42 Merge pull request #1753 from Fr1ngg/bedolaga/update-readme-with-platega.io-info
docs: document Platega payment integration
2025-11-07 08:18:18 +03:00
Egor dbcf3541a8 docs: document Platega payment integration 2025-11-07 08:16:45 +03:00
Egor b7adf3e971 Merge pull request #1752 from Fr1ngg/main
w
2025-11-07 08:08:01 +03:00
Egor 037b1f876b Merge pull request #1751 from Fr1ngg/bedolaga/fix-display-issue-for-unlimited-traffic
Fix unlimited traffic display in payment methods
2025-11-07 08:01:35 +03:00
Egor 4495658fad Fix unlimited traffic display in payment methods 2025-11-07 07:58:54 +03:00
Egor 017b758e6c Merge pull request #1750 from Fr1ngg/bedolaga/move-platega-settings-to-payments-section
Add Platega payment tests
2025-11-07 07:52:37 +03:00
Egor edff6d5102 Add tests for Platega payment flows and method parsing 2025-11-07 07:49:38 +03:00
Egor 979fbe305a Merge pull request #1749 from Fr1ngg/bedolaga/fix-trial-access-issue-for-users
Handle missing devices placeholder in trial offer message
2025-11-07 07:24:34 +03:00
Egor d1edb8f892 Fix trial offer formatting for device limit 2025-11-07 07:17:53 +03:00
Egor 0eeda6b9ac Merge pull request #1748 from Fr1ngg/bedolaga/integrate-new-payment-method-for-bot
Integrate Platega balance top-ups
2025-11-07 07:05:45 +03:00
Egor 7c31c3616e Guard Platega finalization against duplicate callbacks 2025-11-07 07:05:22 +03:00
Egor fba80b1a0d Integrate Platega balance top-ups 2025-11-07 06:52:57 +03:00
Egor 86dbb317f3 Delete redirect directory 2025-11-07 05:10:03 +03:00
Egor 7432d43b55 Update README.md 2025-11-07 03:32:54 +03:00
Egor a61fb10784 Update Dockerfile 2025-11-07 03:04:36 +03:00
Egor 8d83881cf0 Update docker-registry.yml 2025-11-07 03:04:25 +03:00
Egor d624df5f65 Update docker-hub.yml 2025-11-07 03:04:06 +03:00
Egor a96a7de784 Update README.md 2025-11-07 02:55:42 +03:00
Egor fa188e04c5 Add files via upload 2025-11-07 02:32:46 +03:00
Egor e8c95ece8d Update README.md 2025-11-07 02:24:13 +03:00
Egor bc0f4adb7a Update README.md 2025-11-07 02:23:10 +03:00
Egor 3eb5a71e8d Merge pull request #1747 from Fr1ngg/dev4
Бот на Вебхуке Без смс и регистрации
2025-11-07 01:47:53 +03:00
Egor 37c97de5d6 Update README.md 2025-11-07 01:37:42 +03:00
Egor 3624832ed8 Update README.md 2025-11-07 01:37:06 +03:00
Egor 9ee39d5f71 Update README.md 2025-11-07 01:36:17 +03:00
Egor 3a0e558b6f Update README.md 2025-11-07 01:34:05 +03:00
Egor ca083c216e Merge pull request #1746 from Fr1ngg/bedolaga/fix-notification-sending-error
Fix trial expiration notification loading for monitoring service
2025-11-07 01:12:17 +03:00
Egor 8b9c1d1cc6 Fix trial notification promo group loading 2025-11-07 01:01:33 +03:00
Egor 78830a7884 Merge pull request #1745 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode-agf2n2
Expose unified health status alongside admin API
2025-11-06 18:09:19 +03:00
Egor 9fe7d0592f Expose unified health status alongside admin API 2025-11-06 18:02:08 +03:00
Egor 6a3e3c6fc4 Merge pull request #1741 from Fr1ngg/revert-1740-bedolaga/add-telegram-webhook-and-both-mode
Revert "Restore FastAPI docs endpoints for webhook mode"
2025-11-06 17:30:04 +03:00
Egor 54bfff0ed6 Revert "Restore FastAPI docs endpoints for webhook mode" 2025-11-06 17:28:06 +03:00
Egor 6ee305cbba Merge pull request #1740 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode
Restore FastAPI docs endpoints for webhook mode
2025-11-06 17:27:56 +03:00
Egor 340041ddae Restore FastAPI docs endpoints for webhook mode 2025-11-06 17:27:39 +03:00
Egor 8a3628a6c5 Merge pull request #1738 from Fr1ngg/dev4
Dev4
2025-11-06 13:54:27 +03:00
Egor 8ac3350ac6 Merge pull request #1735 from reshifter1/main
Проверка промокода при вводе реферального кода + фикс ебучего lazy loading
2025-11-06 13:46:52 +03:00
Pavel Stryuk a507ef9966 Промокоды на место реферального кода 2025-11-06 11:03:50 +01:00
Pavel Stryuk 113405ed71 Промокоды на место реферального кода 2025-11-06 09:26:24 +01:00
Egor 1bdf1a0f4c Update README.md 2025-11-06 09:46:33 +03:00
Egor ff7883e33c Update Dockerfile 2025-11-06 09:09:50 +03:00
Egor 9bd909c1b6 Update docker-registry.yml 2025-11-06 09:09:33 +03:00
Egor 408b4e8a4d Update docker-hub.yml 2025-11-06 09:09:21 +03:00
Egor 0ee9708b4d Merge pull request #1734 from Fr1ngg/dev4
Dev4
2025-11-06 08:45:07 +03:00
Egor 44fadf6709 Merge pull request #1733 from Fr1ngg/bedolaga/fix-high-priority-bugs-in-remnawave_service.py
Reset queued UUID mutations after rollbacks
2025-11-06 08:36:45 +03:00
Egor 7384763466 Reset pending UUID mutations on rollback 2025-11-06 08:32:21 +03:00
Egor 9b6f3b10c6 Fix UUID mutation cleanup ordering 2025-11-06 07:48:00 +03:00
Egor 0e4007bde6 Merge pull request #1728 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot-4srv7j
Restore RemnaWave UUID map after sync rollbacks
2025-11-06 07:31:11 +03:00
Egor 9fcffc7df1 Merge pull request #1729 from Fr1ngg/revert-1726-bedolaga/fix-user-visibility-issue-in-remnawave-bot
Revert "Fix RemnaWave user identifier refresh during sync"
2025-11-06 07:21:21 +03:00
Egor 8e1a88c5cd Revert "Fix RemnaWave user identifier refresh during sync" 2025-11-06 07:21:12 +03:00
Egor 7bb21c9f77 Protect RemnaWave UUID map updates during rollbacks 2025-11-06 07:20:34 +03:00
Egor 7333379fcf Merge pull request #1726 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot
Fix RemnaWave user identifier refresh during sync
2025-11-06 04:14:49 +03:00
Egor fc040007b4 Fix remnawave user mapping after sync 2025-11-06 04:05:04 +03:00
Egor b30a3c7961 Merge pull request #1725 from Fr1ngg/dev4
fix
2025-11-06 03:45:46 +03:00
Egor 62e18156f5 Merge pull request #1724 from Fr1ngg/main
w
2025-11-06 03:40:19 +03:00
Egor 853666af6f Merge pull request #1723 from Fr1ngg/bedolaga/fix-typeerror-for-none-connected_squads
Guard payment methods handler against missing connected squads
2025-11-06 03:39:29 +03:00
Egor 3416f7b56c Guard payment methods handler against missing connected squads 2025-11-06 03:39:12 +03:00
Egor d78d82c6ce Update main.py 2025-11-06 03:22:16 +03:00
Egor 47bd1be714 Update system_settings_service.py 2025-11-06 03:21:48 +03:00
Egor 1eec919b88 Update main.py 2025-11-06 03:17:08 +03:00
Egor 777ad52bad Update system_settings_service.py 2025-11-06 03:02:31 +03:00
Egor 5fcadb5544 Update system_settings_service.py 2025-11-06 02:48:36 +03:00
Egor 0a8af20b58 Update system_settings_service.py 2025-11-06 02:23:32 +03:00
Egor 95163a0e35 Update monitoring_service.py 2025-11-06 01:59:53 +03:00
Egor ac58466c1a Update promo_offers.py 2025-11-06 01:45:26 +03:00
Egor 5ce14dcd9b Update promo_offers.py 2025-11-06 01:39:08 +03:00
Egor 21d700e16d Add files via upload 2025-11-06 01:35:18 +03:00
Egor 5f2d4a5e04 Update poll_service.py 2025-11-06 01:30:17 +03:00
Egor 7b90d73007 Update poll_service.py 2025-11-06 01:26:18 +03:00
Egor 68c90aec44 Update poll_service.py 2025-11-06 01:22:07 +03:00
Egor 9280c9a39a Update poll_service.py 2025-11-06 01:17:49 +03:00
Egor 245e31e262 Update purchase.py 2025-11-06 01:12:27 +03:00
Egor c00d7ef4d3 Update poll_service.py 2025-11-05 22:03:38 +03:00
Egor 2a652c562b Refactor promo offer sending with asyncio semaphore
Refactor promo offer sending to use asyncio semaphore for concurrent processing and batch sending.
2025-11-05 21:52:04 +03:00
Egor 1cc31729eb Implement semaphore and batch processing for broadcasts
Refactor broadcast message sending to use asyncio semaphore for concurrency control and batch processing.
2025-11-05 21:42:03 +03:00
Egor 34e5c20c1a Update broadcast_service.py 2025-11-05 21:33:48 +03:00
Egor c8545fe725 Refactor import path for SubscriptionService 2025-11-05 21:06:18 +03:00
Egor a8339d376d Update main.py 2025-11-05 20:59:42 +03:00
Egor 47150ccbd0 Update main.py 2025-11-05 20:52:28 +03:00
Egor 0a0890d8bb Update monitoring.py 2025-11-05 20:41:59 +03:00
Egor 6bcf24e3d8 Update welcome_text.py 2025-11-05 20:34:48 +03:00
Egor 898008a1bd Update remnawave_api.py 2025-11-05 20:21:52 +03:00
Egor 09af2e1d41 Update main.py 2025-11-05 20:21:21 +03:00
Egor 045d44f451 Update app.py 2025-11-05 20:09:34 +03:00
Egor 04b2325cb9 Update pricing.py 2025-11-05 19:57:39 +03:00
Egor 504581b0f8 Merge pull request #1721 from DOFER998/c0mrade/makefile-refactor
feat(makefile): refactor structure and add auto-generated help command
2025-11-05 19:45:55 +03:00
c0mrade 0572f414d4 feat(makefile): refactor structure and add auto-generated help command 2025-11-05 19:42:00 +03:00
Egor 50736dc005 Update price_display.py 2025-11-05 19:36:38 +03:00
Egor fe4bdb6a04 Merge pull request #1720 from Fr1ngg/dev4
Update user.py
2025-11-05 18:56:24 +03:00
Egor 7d7c5f3972 Update user.py 2025-11-05 18:51:35 +03:00
Egor 131a2d18cc Merge pull request #1719 from Fr1ngg/dev4
Dev4
2025-11-05 18:26:53 +03:00
Egor 47296c5c08 Merge pull request #1718 from Fr1ngg/bedolaga/fix-user-creation-integrity-issue
Ensure create_user_no_commit flushes new users
2025-11-05 18:22:10 +03:00
Egor c15b7a63fb Flush user before returning from create_user_no_commit 2025-11-05 18:19:22 +03:00
Egor e476afe208 Merge pull request #1716 from Fr1ngg/bedolaga/fix-remnawave-sync-error
Ensure RemnaWave service exposes create_user_no_commit
2025-11-05 18:13:45 +03:00
Egor b59d422fb0 Ensure RemnaWave service exposes create_user_no_commit 2025-11-05 18:13:31 +03:00
Egor 8b105c2396 Merge pull request #1715 from Fr1ngg/dev4
Раъебочка от Паши @reshifter1
2025-11-05 18:04:21 +03:00
Egor 9b6e0b26c3 Ensure autopurchase sets paid flag for converted trials 2025-11-05 18:02:07 +03:00
Egor 4ca038e186 Merge pull request #1714 from Fr1ngg/bedolaga/keep-user_promo_groups-in-sync-on-delete
Fix promo group deletion to maintain user_promo_groups
2025-11-05 17:55:28 +03:00
Egor 3e185e7ffe Reassign promo group memberships when deleting groups 2025-11-05 17:49:47 +03:00
Egor 5e27dcdcfb Merge pull request #1712 from Fr1ngg/bedolaga/fix-sync-for-legacy-promo_group_id-qofcyl
Recalculate primary promo group when reusing auto assignment
2025-11-05 17:39:09 +03:00
Egor 8730a68b40 Recalculate primary promo group instead of forcing target 2025-11-05 17:35:09 +03:00
Egor d88f13784a Merge pull request #1708 from reshifter1/main
Пачка правок
2025-11-05 17:19:32 +03:00
Pavel Stryuk 6e471c9094 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-05 13:45:54 +01:00
reshifter1 3201b60ff2 Merge branch 'dev4' into main 2025-11-04 22:57:38 +03:00
Pavel Stryuk d30d1e2a29 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:52:17 +01:00
Pavel Stryuk bd73ad069f 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:36:01 +01:00
Egor 2186780c32 Merge pull request #1706 from Fr1ngg/dev4
Dev4
2025-11-04 16:40:27 +03:00
Egor fbb2e561c5 Merge pull request #1705 from Fr1ngg/bedolaga/fix-mulenpay-sbp-callback-error
Fix MulenPay callback lazy loading error
2025-11-04 16:39:57 +03:00
Egor c8aba42d3d Reload user after MulenPay callback commit 2025-11-04 16:33:34 +03:00
Pavel Stryuk 05d495e874 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 14:13:21 +01:00
Pavel Stryuk 427011fe41 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) К промокодам можно будет добавить промогруппу. Все активировавшие промокод получат её
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
2025-11-04 13:05:02 +01:00
Egor 2feacccb13 Merge pull request #1703 from Fr1ngg/dev4
fix
2025-11-04 11:40:21 +03:00
Egor dc196fb478 Optimize user promo_group and subscription access
Refactor user attribute access to avoid lazy loading.
2025-11-04 09:30:21 +03:00
Egor 71c219e6ea Update user.py 2025-11-04 09:29:46 +03:00
Egor e250812463 Update simple_subscription.py 2025-11-04 08:02:03 +03:00
Egor 477b0b26c4 Update docker-compose.yml 2025-11-04 07:53:53 +03:00
Egor 06323f7168 Enhance search functionality for telegram_id
Added error handling for converting search input to int for telegram_id.
2025-11-04 07:39:12 +03:00
Egor 6b471caa22 Merge pull request #1700 from Fr1ngg/main
w
2025-11-04 06:21:51 +03:00
Egor 6fb28dd363 Merge pull request #1697 from remnawave-contrib/ivan-nginx-tg-stars-disabled-by-default
fix: update Telegram Stars rate & disable by default in `.env`
2025-11-04 06:21:08 +03:00
Ivan.Nginx 22dcff47d1 fix: update Telegram Stars rate & disable by default in .env 2025-11-03 23:42:21 +03:00
Egor 1d9b5ea3c6 Merge pull request #1689 from Fr1ngg/7iu0vl-bedolaga/expand-bot-api-for-server-management
Prevent partial server updates when promo groups are invalid
2025-11-03 07:19:25 +03:00
Egor 08e0b3a657 Validate promo groups before updating server 2025-11-03 07:19:03 +03:00
Egor 10b80981b2 Merge pull request #1688 from Fr1ngg/revert-1687-txzghv-bedolaga/expand-bot-api-for-server-management
Revert "Fix RemnaWave panel user creation defaults"
2025-11-03 07:18:33 +03:00
Egor 348f02f34c Revert "Fix RemnaWave panel user creation defaults" 2025-11-03 07:18:24 +03:00
Egor 9e9e984f36 Merge pull request #1687 from Fr1ngg/txzghv-bedolaga/expand-bot-api-for-server-management
Fix RemnaWave panel user creation defaults
2025-11-03 07:01:38 +03:00
Egor d47ca550ae Fix RemnaWave user sync tests by restoring create_user usage 2025-11-03 06:59:29 +03:00
Egor 16f4285948 Update README.md 2025-11-03 06:09:14 +03:00
Egor 23cb6dd8d0 Merge pull request #1685 from Fr1ngg/main
w
2025-11-02 22:25:17 +03:00
Egor dfbb6b0058 Merge pull request #1686 from Fr1ngg/revert-1684-qt986v-bedolaga/fix-user-search-logic-for-large-integers
Revert "Fix user search when numeric query exceeds INT32 range"
2025-11-02 22:25:10 +03:00
Egor 62f8fdd9bb Revert "Fix user search when numeric query exceeds INT32 range" 2025-11-02 22:24:56 +03:00
Egor fc114d15a0 Merge pull request #1684 from Fr1ngg/qt986v-bedolaga/fix-user-search-logic-for-large-integers
Fix user search when numeric query exceeds INT32 range
2025-11-02 22:22:11 +03:00
Egor a9fb8a069d Fix user search when query exceeds int32 2025-11-02 22:21:50 +03:00
Egor 9c6f738382 Merge pull request #1678 from Gy9vin/main
массовая синхронизация
2025-11-02 21:09:02 +03:00
gy9vin 1a7b2eb908 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-02 16:08:09 +03:00
gy9vin b108cc6b60 Фикс синхронизации 2025-11-02 16:08:05 +03:00
Mikhail 92cb1e3971 Merge branch 'Fr1ngg:main' into main 2025-11-02 15:08:01 +03:00
gy9vin 005fc95433 Массовая синхронизация пользователей с ремнем! 2025-11-02 15:07:28 +03:00
Egor f09771bc1c Merge pull request #1677 from Fr1ngg/dev4
Dev4
2025-11-02 06:31:39 +03:00
Egor d5b72198d7 Merge pull request #1676 from Fr1ngg/main
w
2025-11-02 06:30:31 +03:00
Egor 991bf1161c Merge pull request #1675 from Fr1ngg/sq2b4y-bedolaga/fix
Handle repeated return-to-cart callbacks without redundant edits
2025-11-02 06:13:27 +03:00
Egor 1a955d920a Handle repeated return-to-cart callbacks without redundant edits 2025-11-02 06:13:16 +03:00
Egor d575c914a6 Merge pull request #1674 from Fr1ngg/revert-1673-i0kn8d-bedolaga/fix
Revert "Prevent duplicate edits when resuming saved subscription carts"
2025-11-02 06:12:09 +03:00
Egor 90a054e942 Revert "Prevent duplicate edits when resuming saved subscription carts" 2025-11-02 06:11:59 +03:00
Egor aac5fabbca Merge pull request #1673 from Fr1ngg/i0kn8d-bedolaga/fix
Prevent duplicate edits when resuming saved subscription carts
2025-11-02 06:08:17 +03:00
Egor 3ce5d1650b Avoid redundant edits when reopening saved subscription cart 2025-11-02 06:08:05 +03:00
Egor 24879b9cf2 Merge pull request #1672 from Fr1ngg/revert-1671-eup68i-bedolaga/fix
Revert "Fix saved subscription cart resume button when returning from balance top-up"
2025-11-02 06:07:36 +03:00
Egor 37f27df062 Revert "Fix saved subscription cart resume button when returning from balance top-up" 2025-11-02 06:07:27 +03:00
Egor b78ed6797a Merge pull request #1671 from Fr1ngg/eup68i-bedolaga/fix
Fix saved subscription cart resume button when returning from balance top-up
2025-11-02 05:33:03 +03:00
Egor f73dcd6f66 Fix saved cart resume button for subscription checkout 2025-11-02 05:32:47 +03:00
Egor 0634d01336 Merge pull request #1670 from Fr1ngg/9xjrx8-bedolaga/update-user-status-logic-in-bot
Stop disabling RemnaWave users when subscriptions expire
2025-11-02 04:52:32 +03:00
Egor b43d6a83ee Stop disabling panel users when subscriptions expire 2025-11-02 04:52:00 +03:00
Egor 93144eea0e Merge pull request #1669 from Fr1ngg/ulngpf-bedolaga/fix-user-sync-timezone-issue
Normalize RemnaWave sync times to UTC
2025-11-02 03:13:29 +03:00
Egor 7d078a58e1 Normalize RemnaWave sync times to UTC 2025-11-02 03:13:13 +03:00
Egor 224c346bc0 Merge pull request #1657 from remnawave-contrib/ivan-nginx-timezone
patch: add missed timezone parameter to .env.example
2025-11-02 03:08:21 +03:00
Egor 51e937ae0c Merge pull request #1662 from Fr1ngg/dev4
Dev4
2025-11-01 16:41:24 +03:00
Egor 04073815ca Merge pull request #1661 from Fr1ngg/main
w
2025-11-01 16:41:05 +03:00
Egor ff556ba347 Merge pull request #1660 from Fr1ngg/qrn17i-bedolaga/deactivate-active-subscriptions
Fix monitoring to only deactivate trial subscriptions after channel unsubscribe
2025-11-01 16:39:21 +03:00
Egor 2d751c238c Fix trial subscription monitoring not to disable paid plans 2025-11-01 16:39:04 +03:00
Ivan.Nginx 5d27fa2963 patch: add missed timezone parameter to .env.example 2025-11-01 09:10:39 +03:00
Ivan.Nginx fe71935d58 Merge branch 'Fr1ngg:main' into main 2025-11-01 08:59:41 +03:00
Egor d0d1b7617a Update Python version to 2.5.7 in Dockerfile 2025-11-01 06:15:32 +03:00
Egor 310990ae32 Update versioning to 2.5.7 in workflow 2025-11-01 06:15:15 +03:00
Egor b565c32e77 Update version number to 2.5.7 in workflow 2025-11-01 06:14:58 +03:00
Egor f3edb4344b Merge pull request #1655 from Fr1ngg/dev4
Фиксы
2025-11-01 05:45:50 +03:00
Egor ccdcff6a7b Merge pull request #1654 from Fr1ngg/7sqsqp-bedolaga/fix-user-deletion-errors
Handle Heleket payments when removing users
2025-11-01 05:43:19 +03:00
Egor 1d4090ea7a Handle Heleket payments on user deletion 2025-11-01 05:43:03 +03:00
Egor 68c7d35165 Merge pull request #1653 from Fr1ngg/fifw2h-bedolaga/move-configuration-sections-for-the-bot
Group timezone and payment verification settings
2025-11-01 05:28:12 +03:00
Egor 221cc05af3 Group timezone and payment verification settings 2025-11-01 05:27:56 +03:00
Egor 1524970401 Merge pull request #1652 from Fr1ngg/revert-1651-bw734r-bedolaga/move-configuration-sections-for-the-bot
Revert "Reorganize admin configuration categories"
2025-11-01 05:27:47 +03:00
Egor 32a03c5e4b Revert "Reorganize admin configuration categories" 2025-11-01 05:27:37 +03:00
Egor a03036c099 Merge pull request #1651 from Fr1ngg/bw734r-bedolaga/move-configuration-sections-for-the-bot
Reorganize admin configuration categories
2025-11-01 05:18:46 +03:00
Egor 811e8a97b3 Reorganize bot configuration categories 2025-11-01 05:18:31 +03:00
Egor c7625643c6 Merge pull request #1650 from Fr1ngg/h9h52u-bedolaga/fix-log-time-discrepancy-with-server-time
Fix subscription timestamps to use configured timezone
2025-11-01 04:54:29 +03:00
Egor 5ce87238c8 Fix subscription timestamps to use configured timezone 2025-11-01 04:54:12 +03:00
Egor 7d12a327ca Merge pull request #1649 from Fr1ngg/revert-1648-es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Revert "Improve timezone-aware logging"
2025-11-01 04:54:03 +03:00
Egor 8c93637d79 Revert "Improve timezone-aware logging" 2025-11-01 04:53:53 +03:00
Egor 2d0ea02b4b Merge pull request #1648 from Fr1ngg/es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Improve timezone-aware logging
2025-11-01 04:31:10 +03:00
Egor 98692ac3bd Improve timezone-aware logging 2025-11-01 04:30:32 +03:00
Egor 940b6b93b2 Merge pull request #1647 from Fr1ngg/d76wg3-bedolaga/modify-parameter-priority-logic-in-bot
Respect .env configuration priority
2025-11-01 02:50:43 +03:00
Egor 17768c303f Ensure env configuration takes priority over DB overrides 2025-11-01 02:50:24 +03:00
Egor 441fcd2a36 Merge pull request #1646 from Fr1ngg/dev4
Фиксы и приколы
2025-11-01 02:27:46 +03:00
Egor c2b6b4e370 Merge pull request #1644 from Fr1ngg/w96px9-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:58:47 +03:00
Egor 2f8d27b1ec Merge pull request #1645 from Fr1ngg/revert-1643-81vlci-bedolaga/add-referrals-button-to-user-management
Revert "Add admin user referrals management"
2025-11-01 01:58:37 +03:00
Egor dc32f53585 Revert "Add admin user referrals management" 2025-11-01 01:58:29 +03:00
Egor 165691412b Clear referral edit state when returning to list 2025-11-01 01:58:08 +03:00
Egor 824d4b3a23 Merge pull request #1643 from Fr1ngg/81vlci-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:35:36 +03:00
Egor 060f0ca199 Add admin user referrals management 2025-11-01 01:35:20 +03:00
Egor ac43f17a43 Merge pull request #1642 from Fr1ngg/zvatmo-bedolaga/add-referral-percentage-for-all-deposits
Allow referral commissions on any top-up amount
2025-11-01 01:19:53 +03:00
Egor 54fc1c029c Allow referral commissions on any top-up amount 2025-11-01 01:19:31 +03:00
Egor d259bcf169 Merge pull request #1641 from Fr1ngg/revert-1640-qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Revert "Add automatic Telegram Stars rate fetching and toggle"
2025-11-01 00:56:04 +03:00
Egor da46c18210 Revert "Add automatic Telegram Stars rate fetching and toggle" 2025-11-01 00:55:55 +03:00
Egor ace7121a4e Merge pull request #1640 from Fr1ngg/qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Add automatic Telegram Stars rate fetching and toggle
2025-11-01 00:26:11 +03:00
Egor 61a93f86b3 Add dynamic Telegram Stars rate with auto refresh 2025-11-01 00:25:55 +03:00
Egor 49bcd18a9a Merge pull request #1638 from Fr1ngg/4vtqjj-bedolaga/fix-500-error-in-/promo-groups-due-to-date-validation
Fix promo group timestamp serialization issues
2025-10-31 22:52:24 +03:00
Egor 7df64d8edf Fix admin notification service f-string syntax 2025-10-31 22:52:11 +03:00
Egor ca63b4d758 Merge pull request #1637 from Fr1ngg/5j1ved-bedolaga/specify-existing-parameters-for-payment-verification
Add payment verification auto-check options to env template
2025-10-31 22:31:42 +03:00
Egor 3cbb304e9b Document payment verification auto-check settings 2025-10-31 22:31:22 +03:00
Egor ef5192234a Merge pull request #1636 from Fr1ngg/dev4
Dev4
2025-10-31 22:18:24 +03:00
Egor f7bb9ac4d9 Merge pull request #1635 from Fr1ngg/main
w
2025-10-31 22:17:49 +03:00
Egor b8bb4f3c3f Merge pull request #1633 from Gy9vin/main
СРОЧНЫЙ ФИКС
2025-10-31 22:15:52 +03:00
Egor e43ff27639 Merge pull request #1634 from Fr1ngg/cw91a0-bedolaga/add-attempts-parameter-to-maintenance-check
Add configurable retry attempts for maintenance panel checks
2025-10-31 22:15:25 +03:00
Egor e02123c1ed Add configurable retry attempts for maintenance checks 2025-10-31 22:15:05 +03:00
gy9vin f26df389b4 СРОЧНЫЙ ФИКС 2025-10-31 22:13:23 +03:00
Egor 10d36a829d Merge pull request #1632 from Fr1ngg/p7wjzx-bedolaga/fix-ad-campaign-notification-bug
Fix admin notification for first advertising campaign clicks
2025-10-31 22:04:04 +03:00
Egor 61e936dd49 Fix campaign visit notifications on initial link click 2025-10-31 22:01:03 +03:00
Egor d0f9a04d5f Merge pull request #1631 from Fr1ngg/ishx2m-bedolaga/fix-maintenance-mode-persistence-issue
Add configuration flag to disable automatic maintenance monitoring
2025-10-31 21:46:26 +03:00
Egor cf6bbffd9b Respect maintenance monitoring setting 2025-10-31 21:45:58 +03:00
Egor 5ad45addfa Merge pull request #1630 from Fr1ngg/s4pdi7-bedolaga/add-placeholder-for-user-name
Add configurable template for RemnaWave panel usernames
2025-10-31 21:32:13 +03:00
Egor 2af013b75b Add template support for RemnaWave usernames 2025-10-31 21:31:54 +03:00
Egor 74bda5c986 Merge pull request #1629 from Fr1ngg/l1utns-bedolaga/fix-pal24-payment-status-error
Fix Pal24 payment status retrieval crash
2025-10-31 21:17:57 +03:00
Egor b290ccfff2 Add Pal24 bill payments proxy and tests 2025-10-31 21:17:34 +03:00
Egor 904d2883e8 Merge pull request #1628 from Fr1ngg/gvkfjc-bedolaga/fix-duplicate-messages-in-operation-history
Fix duplicate MulenPay transactions in history
2025-10-31 21:08:29 +03:00
Egor 1a54beed17 Fix duplicate MulenPay transactions in history 2025-10-31 21:07:48 +03:00
Egor 701a8d33af Merge pull request #1627 from Fr1ngg/ibnw5b-bedolaga/remove-unused-referred_user_reward-parameter
Remove referred user reward setting
2025-10-31 20:38:58 +03:00
Egor aa03384012 Remove referred user reward setting 2025-10-31 20:38:38 +03:00
Egor d346c63184 Merge pull request #1626 from Fr1ngg/dev4
fix [BUG] Неправильное отображение статуса подписки #1585
2025-10-31 19:53:27 +03:00
Egor b8005aaad7 Merge pull request #1625 from Fr1ngg/3evicu-bedolaga/fix-subscription-status-display-issue
Improve FakeSession execute stub in payment webhook tests
2025-10-31 19:43:20 +03:00
Egor bfa9490425 Fix FakeSession execute stub for payment webhook tests 2025-10-31 19:42:53 +03:00
Egor 1f4af21a07 Merge pull request #1624 from Fr1ngg/dev4
Отключение лимита устройств
2025-10-31 19:28:58 +03:00
Egor f952c0e38d Merge pull request #1623 from Fr1ngg/main
w
2025-10-31 19:27:52 +03:00
Egor d7dab5d30f Merge pull request #1622 from Fr1ngg/plexxe-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:16:07 +03:00
Egor 8407f7cdb1 Restore device management button in disabled mode 2025-10-31 19:15:48 +03:00
Egor 6c72987201 Merge pull request #1621 from Fr1ngg/revert-1620-7irv2f-bedolaga/add-option-to-disable-device-selection
Revert "Align trial offer with disabled device selection defaults"
2025-10-31 19:15:32 +03:00
Egor 81b6d3f18f Revert "Align trial offer with disabled device selection defaults" 2025-10-31 19:15:20 +03:00
Egor 95754ab224 Merge pull request #1620 from Fr1ngg/7irv2f-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:08:36 +03:00
Egor 949d964a7f Fix trial messaging and limits for disabled device selection 2025-10-31 19:08:21 +03:00
Egor 8eab79434a Merge pull request #1619 from Fr1ngg/revert-1618-rfh1ig-bedolaga/add-option-to-disable-device-selection
Revert "Restore RemnaWave HWID caps when device selection is disabled"
2025-10-31 19:08:06 +03:00
Egor 8ab2351995 Revert "Restore RemnaWave HWID caps when device selection is disabled" 2025-10-31 19:07:58 +03:00
Egor e8802dde55 Merge pull request #1618 from Fr1ngg/rfh1ig-bedolaga/add-option-to-disable-device-selection
Restore RemnaWave HWID caps when device selection is disabled
2025-10-31 18:40:10 +03:00
Egor acc5ace234 Ensure disabled device mode keeps RemnaWave caps aligned 2025-10-31 18:39:45 +03:00
Egor 7b33721305 Merge pull request #1617 from Fr1ngg/revert-1616-c6i24i-bedolaga/add-option-to-disable-device-selection
Revert "Guard disabled-mode device caps when unset"
2025-10-31 18:39:33 +03:00
Egor e94f4598b5 Revert "Guard disabled-mode device caps when unset" 2025-10-31 18:39:21 +03:00
Egor c66c8b7933 Merge pull request #1616 from Fr1ngg/c6i24i-bedolaga/add-option-to-disable-device-selection
Guard disabled-mode device caps when unset
2025-10-31 18:27:08 +03:00
Egor 0b934efa09 Guard disabled-mode device caps when unset 2025-10-31 18:26:50 +03:00
Egor 79e644f6dd Merge pull request #1615 from Fr1ngg/revert-1613-el6wtr-bedolaga/add-option-to-disable-device-selection
Revert "Align simple subscription device caps with disabled mode"
2025-10-31 18:26:39 +03:00
Egor 5c68a1aca7 Revert "Align simple subscription device caps with disabled mode" 2025-10-31 18:26:27 +03:00
Egor bde613c4bf Merge pull request #1613 from Fr1ngg/el6wtr-bedolaga/add-option-to-disable-device-selection
Align simple subscription device caps with disabled mode
2025-10-31 18:05:59 +03:00
Egor 184d8c3ba7 Merge pull request #1614 from Fr1ngg/revert-1612-9j4uf1-bedolaga/add-option-to-disable-device-selection
Revert "Add configurable device cap for disabled selection mode"
2025-10-31 18:05:49 +03:00
Egor d48221d57f Revert "Add configurable device cap for disabled selection mode" 2025-10-31 18:05:40 +03:00
Egor 657fd6d07d Align simple subscription device caps with disabled mode 2025-10-31 18:05:20 +03:00
Egor 1211cd22ad Merge pull request #1612 from Fr1ngg/9j4uf1-bedolaga/add-option-to-disable-device-selection
Add configurable device cap for disabled selection mode
2025-10-31 17:43:01 +03:00
Egor 19ed88009d Add disabled-mode device limit controls 2025-10-31 17:42:40 +03:00
Egor 0f30ee87ab Merge pull request #1611 from Fr1ngg/revert-1610-dvh9qt-bedolaga/add-option-to-disable-device-selection
Revert "Preserve saved cart metadata when normalizing devices"
2025-10-31 17:42:17 +03:00
Egor 4cc6778e45 Revert "Preserve saved cart metadata when normalizing devices" 2025-10-31 17:42:04 +03:00
Egor ab00a0d136 Merge pull request #1610 from Fr1ngg/dvh9qt-bedolaga/add-option-to-disable-device-selection
Preserve saved cart metadata when normalizing devices
2025-10-31 14:01:24 +03:00
Egor 3d4f1af9a9 Preserve saved cart metadata during normalization 2025-10-31 14:01:06 +03:00
Egor 0302176fd2 Merge pull request #1609 from Fr1ngg/revert-1608-0ol61g-bedolaga/add-option-to-disable-device-selection
Revert "Normalize saved cart devices when selection disabled"
2025-10-31 14:00:37 +03:00
Egor 8910780332 Revert "Normalize saved cart devices when selection disabled" 2025-10-31 14:00:28 +03:00
Egor 8bea64179a Merge pull request #1608 from Fr1ngg/0ol61g-bedolaga/add-option-to-disable-device-selection
Normalize saved cart devices when selection disabled
2025-10-31 13:48:02 +03:00
Egor 777f90eab1 Normalize saved cart devices when selection disabled 2025-10-31 13:47:42 +03:00
Egor ad0b0f475a Merge pull request #1607 from Fr1ngg/revert-1606-3jxg23-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:47:01 +03:00
Egor 499292eab6 Revert "Hide device limits when device selection disabled" 2025-10-31 13:46:52 +03:00
Egor 21b2f1bd2a Merge pull request #1606 from Fr1ngg/3jxg23-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 13:21:58 +03:00
Egor 3c025b536e Preserve default devices when selection disabled 2025-10-31 13:21:14 +03:00
Egor bded6fe59f Merge pull request #1605 from Fr1ngg/revert-1603-ej06m7-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:21:01 +03:00
Egor 344ad8f3c0 Revert "Hide device limits when device selection disabled" 2025-10-31 13:20:52 +03:00
Egor b43dd3167f Merge pull request #1603 from Fr1ngg/ej06m7-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 12:51:49 +03:00
Egor 79ed9dbab2 Merge pull request #1604 from Fr1ngg/revert-1602-n5lhq1-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection during subscription checkout"
2025-10-31 12:51:41 +03:00
Egor 38ff59f71f Revert "Add toggle to disable device selection during subscription checkout" 2025-10-31 12:51:29 +03:00
Egor 65c85e146d Hide device info when device selection disabled 2025-10-31 12:50:44 +03:00
Egor 467955e7b3 Merge pull request #1602 from Fr1ngg/n5lhq1-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection during subscription checkout
2025-10-31 12:19:29 +03:00
Egor 1a4250a7ed Add toggle to disable device selection 2025-10-31 12:19:10 +03:00
Egor e181e4325b Merge pull request #1601 from Fr1ngg/revert-1600-o0l191-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection"
2025-10-31 12:18:34 +03:00
Egor 1a35237852 Revert "Add toggle to disable device selection" 2025-10-31 12:18:26 +03:00
Egor 90baf3da52 Merge pull request #1600 from Fr1ngg/o0l191-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection
2025-10-31 12:13:32 +03:00
Egor 348e4dd4dc Add toggle to disable device selection 2025-10-31 12:13:11 +03:00
Egor d2cd96a45c Merge pull request #1599 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 06:32:35 +03:00
Ivan.Nginx 64238e3280 Merge pull request #1 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 05:02:44 +03:00
Ivan.Nginx fcfac2df1f Added issue templates
1. Bug Template — «Bug Bedolage»
2. Teature Template — «Feat Bedolage»
2025-10-31 05:02:00 +03:00
Egor a0734a6eb4 Merge pull request #1596 from Fr1ngg/dev4
Dev4
2025-10-31 03:32:04 +03:00
Egor 0d7a235715 Merge pull request #1595 from Fr1ngg/49rs9y-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket view for users without username
2025-10-31 03:28:11 +03:00
Egor 180788bc7b Fix admin ticket view for users without username 2025-10-31 03:27:55 +03:00
Egor 922681891a Merge pull request #1594 from Fr1ngg/revert-1593-jy8fc9-bedolaga/fix-ticket-system-telegram-error
Revert "Fix admin ticket DM links to support ID-only users"
2025-10-31 03:27:47 +03:00
Egor 257c628cc8 Revert "Fix admin ticket DM links to support ID-only users" 2025-10-31 03:27:38 +03:00
Egor dcee842c92 Merge pull request #1593 from Fr1ngg/jy8fc9-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket DM links to support ID-only users
2025-10-31 03:20:12 +03:00
Egor 5f02471fd5 Fix admin ticket DM links to support ID-only users 2025-10-31 03:19:54 +03:00
Egor 82f0f7ce17 Merge pull request #1575 from Fr1ngg/dev4
Dev4
2025-10-30 03:10:21 +03:00
Egor b805b7eef9 Merge pull request #1574 from Fr1ngg/3fc7wf-bedolaga/update-subscription-period-menu-pricing
Hide zero price in subscription period options
2025-10-30 03:06:34 +03:00
Egor ad03fa070a Hide zero price in subscription period options 2025-10-30 03:06:16 +03:00
Egor 9c354d971f Merge pull request #1573 from Fr1ngg/dev4
Dev4
2025-10-30 01:15:58 +03:00
Egor 5cc70c4bb8 Merge pull request #1572 from Fr1ngg/soqxxz-bedolaga/fix-duplicate-telegram-id-handling
Handle Telegram ID uniqueness during RemnaWave sync
2025-10-30 01:15:36 +03:00
Egor 9db806d387 Handle Telegram ID uniqueness during RemnaWave sync 2025-10-30 01:15:19 +03:00
Egor b7f7621d28 Merge pull request #1571 from Fr1ngg/revert-1569-hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Revert "Deduplicate RemnaWave sync users with freshest subscriptions"
2025-10-30 01:14:40 +03:00
Egor 822ac2417d Revert "Deduplicate RemnaWave sync users with freshest subscriptions" 2025-10-30 01:14:32 +03:00
Egor b3c80a2a82 Merge pull request #1570 from Fr1ngg/dev4
Dev4
2025-10-30 01:04:37 +03:00
Egor 3e9d606647 Merge pull request #1569 from Fr1ngg/hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Deduplicate RemnaWave sync users with freshest subscriptions
2025-10-30 01:04:13 +03:00
Egor 0ab89f4956 Deduplicate RemnaWave sync users by telegram id 2025-10-30 01:03:58 +03:00
Egor 0090f1c409 Merge pull request #1568 from Fr1ngg/revert-1567-tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Revert "Handle duplicate RemnaWave panel users during sync"
2025-10-30 01:03:44 +03:00
Egor 1cc37a57c1 Revert "Handle duplicate RemnaWave panel users during sync" 2025-10-30 01:03:35 +03:00
Egor 7809efa556 Merge pull request #1567 from Fr1ngg/tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Handle duplicate RemnaWave panel users during sync
2025-10-30 01:03:10 +03:00
Egor b4a69e3c85 Filter RemnaWave sync duplicates by latest subscription 2025-10-30 01:02:53 +03:00
Egor 67c975d9c2 Merge pull request #1566 from Fr1ngg/main
w
2025-10-30 00:55:05 +03:00
Egor c0812a7ee9 Merge pull request #1565 from Gy9vin/main
Очередной фикс юкассы
2025-10-29 11:32:10 +03:00
gy9vin 8ed755a339 Очередной фикс юкассы 2025-10-29 11:13:55 +03:00
Egor 1aae8783ca Update Dockerfile 2025-10-26 22:09:13 +03:00
Egor 5997a741fc Update docker-registry.yml 2025-10-26 22:09:01 +03:00
Egor c5d522e6b8 Update docker-hub.yml 2025-10-26 22:08:51 +03:00
Egor fa1d128d76 Merge pull request #1560 from Fr1ngg/dev4
Clear saved cart when cancelling subscription checkout
2025-10-26 21:36:44 +03:00
Egor f30516a6c2 Merge pull request #1559 from Fr1ngg/dh2k0d-bedolaga/fix-button-visibility-after-order-cancellation
Clear saved cart when cancelling subscription checkout
2025-10-26 21:32:30 +03:00
Egor 1feb0538cf Clear saved cart after subscription cancellation 2025-10-26 21:32:16 +03:00
Egor 3a791d624f Merge pull request #1558 from Fr1ngg/dev4
Dev4
2025-10-26 20:28:12 +03:00
Egor fe0f1f7641 Merge pull request #1557 from Fr1ngg/9scuix-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle YooKassa auto-purchase subscription refresh
2025-10-26 20:27:34 +03:00
Egor 212783ae3d Fix auto-purchase subscription refresh after YooKassa top-up 2025-10-26 20:26:35 +03:00
Egor 7d72fc9555 Merge pull request #1556 from Fr1ngg/revert-1554-0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle auto-purchase for subscription extensions after top-up"
2025-10-26 20:26:25 +03:00
Egor fb4df5cee1 Revert "Handle auto-purchase for subscription extensions after top-up" 2025-10-26 20:26:12 +03:00
Egor 45cd847b44 Merge pull request #1555 from Fr1ngg/dev4
Автопокупка и продление после пополнения баланса
2025-10-26 19:51:25 +03:00
Egor 98fd97dea2 Merge pull request #1554 from Fr1ngg/0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle auto-purchase for subscription extensions after top-up
2025-10-26 19:40:50 +03:00
Egor e334086ad1 Fix auto-purchase for subscription extensions 2025-10-26 19:40:31 +03:00
Egor 2e792911f1 Merge pull request #1553 from Fr1ngg/revert-1552-hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle simple subscription star purchase flow"
2025-10-26 19:40:00 +03:00
Egor 878b666a0b Revert "Handle simple subscription star purchase flow" 2025-10-26 19:39:51 +03:00
Egor 69c3239469 Merge pull request #1552 from Fr1ngg/hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle simple subscription star purchase flow
2025-10-26 19:15:25 +03:00
Egor b8d48745c5 Handle simple subscription stars purchases 2025-10-26 19:15:02 +03:00
Egor 6b64e73d6e Merge pull request #1551 from Fr1ngg/revert-1550-yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Fix auto-purchase after top-up to use miniapp purchase service"
2025-10-26 19:14:21 +03:00
Egor b433a96cd6 Revert "Fix auto-purchase after top-up to use miniapp purchase service" 2025-10-26 19:14:12 +03:00
Egor 0e4c83299d Merge pull request #1550 from Fr1ngg/yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Fix auto-purchase after top-up to use miniapp purchase service
2025-10-26 18:51:54 +03:00
Egor c4d1f3eccc Fix auto-purchase to use miniapp service submit 2025-10-26 18:50:13 +03:00
Egor 22dcf425cb Merge pull request #1547 from Fr1ngg/revert-1544-revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "Revert "feat: auto purchase subscription after top-up""
2025-10-26 18:27:10 +03:00
Egor edf4132c2d Revert "Revert "feat: auto purchase subscription after top-up"" 2025-10-26 18:27:01 +03:00
Egor a56e14c736 Merge pull request #1546 from Fr1ngg/revert-1545-67lhig-bedolaga/add-automatic-subscription-purchase-feature
Revert "Add automatic subscription purchase after balance top-up"
2025-10-26 18:26:41 +03:00
Egor 2e3c707745 Revert "Add automatic subscription purchase after balance top-up" 2025-10-26 18:26:32 +03:00
Egor ebd8a10fd9 Merge pull request #1545 from Fr1ngg/67lhig-bedolaga/add-automatic-subscription-purchase-feature
Add automatic subscription purchase after balance top-up
2025-10-26 18:04:30 +03:00
Egor a681a9ff1b Add automatic subscription purchase after balance top-up 2025-10-26 18:04:13 +03:00
Egor d11458c4a8 Merge pull request #1544 from Fr1ngg/revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "feat: auto purchase subscription after top-up"
2025-10-26 18:03:58 +03:00
Egor 9d6e17b609 Revert "feat: auto purchase subscription after top-up" 2025-10-26 18:03:49 +03:00
Egor cb27487021 Merge pull request #1543 from Fr1ngg/tfkufc-bedolaga/add-automatic-subscription-purchase-feature
feat: auto purchase subscription after top-up
2025-10-26 17:56:15 +03:00
Egor e1e6b40a19 feat: auto purchase subscription after top-up 2025-10-26 17:55:51 +03:00
Egor 2616292f97 Merge pull request #1542 from Fr1ngg/revert-1541-bedolaga-oew5ch
Revert "Add automatic subscription purchase after top-ups"
2025-10-26 17:39:13 +03:00
Egor 2e1dfa6638 Revert "Add automatic subscription purchase after top-ups" 2025-10-26 17:39:04 +03:00
Egor 3a730492cb Merge pull request #1541 from Fr1ngg/bedolaga-oew5ch
Add automatic subscription purchase after top-ups
2025-10-26 17:35:16 +03:00
Egor c35da4b98d Add automatic subscription purchase after top-ups 2025-10-26 17:35:01 +03:00
Egor 5e98c25020 Merge pull request #1539 from Fr1ngg/dev4
Сервис автопроверки транзакций update
2025-10-26 10:47:42 +03:00
Egor 11a4d5b436 Merge pull request #1538 from Fr1ngg/revert-1536-revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Revert "Improve Pal24 and Heleket verification coverage""
2025-10-26 10:46:48 +03:00
Egor 93c733aac0 Revert "Revert "Improve Pal24 and Heleket verification coverage"" 2025-10-26 10:46:38 +03:00
Egor 5b03e7ab8c Merge pull request #1537 from Fr1ngg/main
w
2025-10-26 10:46:24 +03:00
Egor 33912331af Merge pull request #1536 from Fr1ngg/revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:45:44 +03:00
Egor aacc07835e Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:45:35 +03:00
Egor 86df2a1408 Update README.md 2025-10-26 10:44:32 +03:00
Egor 018de17c5b Merge pull request #1534 from Fr1ngg/74yls6-bedolaga/fix-back-button-error-during-payment
Handle back navigation from YooKassa SBP payments
2025-10-26 10:38:11 +03:00
Egor 66afa2b924 Handle back navigation from YooKassa SBP payments 2025-10-26 10:37:49 +03:00
Egor 4ee32fe0de Merge pull request #1533 from Fr1ngg/4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:28:57 +03:00
Egor 5b59ca3c4f Display transaction numbers in admin payment list 2025-10-26 10:28:32 +03:00
Egor 0108b0fc22 Merge pull request #1532 from Fr1ngg/revert-1531-ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:28:18 +03:00
Egor 2e575f82af Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:28:10 +03:00
Egor e8af09041d Merge pull request #1531 from Fr1ngg/ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:15:51 +03:00
Egor 1cf041fb86 Improve Pal24 and Heleket verification coverage 2025-10-26 10:15:31 +03:00
Egor 63074cb009 Merge pull request #1529 from Fr1ngg/revert-1528-3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix CryptoBot admin status checks"
2025-10-26 09:58:22 +03:00
Egor 89b9a6e04f Revert "Fix CryptoBot admin status checks" 2025-10-26 09:58:13 +03:00
Egor 4efa344e43 Merge pull request #1528 from Fr1ngg/3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Fix CryptoBot admin status checks
2025-10-26 09:47:04 +03:00
Egor 2d3d1ba5d3 Fix CryptoBot admin status checks 2025-10-26 09:46:46 +03:00
Egor 849d781e92 Merge pull request #1527 from Fr1ngg/revert-1524-utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:46:33 +03:00
Egor 48a988aa43 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:46:25 +03:00
Egor ec71f733ad Merge pull request #1526 from Fr1ngg/revert-1525-dev4
Revert "Сервис проверки платежей"
2025-10-26 09:46:04 +03:00
Egor 051121a366 Revert "Сервис проверки платежей" 2025-10-26 09:45:56 +03:00
Egor e2c2b9127c Merge pull request #1525 from Fr1ngg/dev4
Сервис проверки платежей
2025-10-26 09:32:29 +03:00
Egor fdf7263bb0 Merge pull request #1524 from Fr1ngg/utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:25:40 +03:00
Egor c8dcde0919 Guard auto verification commits 2025-10-26 09:25:14 +03:00
Egor 9db0cd6328 Merge pull request #1523 from Fr1ngg/revert-1522-z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:24:57 +03:00
Egor 2375bf51b3 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:24:49 +03:00
Egor 3631b49d6d Merge pull request #1522 from Fr1ngg/z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:15:50 +03:00
Egor 3938b56802 Guard YooKassa manual checks from duplicate processing 2025-10-26 09:15:35 +03:00
Egor 3c3b793a9f Merge pull request #1521 from Fr1ngg/revert-1520-vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Revert "Log payment verification service activation during startup"
2025-10-26 09:09:55 +03:00
Egor 8985a89a9f Revert "Log payment verification service activation during startup" 2025-10-26 09:09:46 +03:00
Egor 5750e6a4d2 Merge pull request #1520 from Fr1ngg/vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Log payment verification service activation during startup
2025-10-26 08:54:28 +03:00
Egor 8d7f73edec Log payment verification service activation 2025-10-26 08:54:05 +03:00
Egor 4853d5b19b Merge pull request #1519 from Fr1ngg/revert-1518-x45isk-bedolaga/add-auto-check-service-for-user-deposits
Revert "Prevent admin payment detail handler from handling manual check callbacks"
2025-10-26 08:52:53 +03:00
Egor 901f5fd90d Revert "Prevent admin payment detail handler from handling manual check callbacks" 2025-10-26 08:52:46 +03:00
Egor 89e3865019 Merge pull request #1518 from Fr1ngg/x45isk-bedolaga/add-auto-check-service-for-user-deposits
Prevent admin payment detail handler from handling manual check callbacks
2025-10-26 08:36:43 +03:00
Egor 538b2b52d3 Avoid admin payment detail handler catching manual checks 2025-10-26 08:36:27 +03:00
Egor f1c9df8471 Merge pull request #1517 from Fr1ngg/revert-1516-kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Revert "Show CryptoBot and Stars invoices in admin payments"
2025-10-26 08:32:39 +03:00
Egor 9e09e97ed8 Revert "Show CryptoBot and Stars invoices in admin payments" 2025-10-26 08:32:30 +03:00
Egor e50355be23 Merge pull request #1516 from Fr1ngg/kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Show CryptoBot and Stars invoices in admin payments
2025-10-26 08:27:40 +03:00
Egor bd036fb4a9 Show CryptoBot and Stars invoices in admin payments 2025-10-26 08:27:22 +03:00
Egor 0181f8e5fb Merge pull request #1515 from Fr1ngg/revert-1514-vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars invoices in admin checks"
2025-10-26 08:27:12 +03:00
Egor f5eea6798a Revert "Include CryptoBot and Stars invoices in admin checks" 2025-10-26 08:27:04 +03:00
Egor 7c8d0254b2 Merge pull request #1514 from Fr1ngg/vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars invoices in admin checks
2025-10-26 08:26:31 +03:00
Egor 49c3c1c0c9 Include CryptoBot and Stars invoices in admin checks 2025-10-26 08:25:43 +03:00
Egor 38522442d1 Merge pull request #1513 from Fr1ngg/revert-1512-6709dc-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars in admin payment verification"
2025-10-26 08:25:11 +03:00
Egor 0dacd79598 Revert "Include CryptoBot and Stars in admin payment verification" 2025-10-26 08:25:03 +03:00
Egor 2022654055 Merge pull request #1512 from Fr1ngg/6709dc-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars in admin payment verification
2025-10-26 08:18:48 +03:00
Egor f3a1de6335 Include CryptoBot and Stars in admin top-up checks 2025-10-26 08:18:30 +03:00
Egor 0b5b89ea9a Merge pull request #1511 from Fr1ngg/revert-1510-k70a53-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix admin YooKassa manual check lookup"
2025-10-26 08:18:16 +03:00
Egor 8e21c34c8c Revert "Fix admin YooKassa manual check lookup" 2025-10-26 08:18:07 +03:00
Egor 400f3b01d6 Merge pull request #1510 from Fr1ngg/k70a53-bedolaga/add-auto-check-service-for-user-deposits
Fix admin YooKassa manual check lookup
2025-10-26 07:38:34 +03:00
Egor 6a0b2aabaf Add payment service wrapper for YooKassa local lookup 2025-10-26 07:38:17 +03:00
Egor 13a1ddad11 Merge pull request #1509 from Fr1ngg/revert-1508-ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up verification tools"
2025-10-26 07:37:32 +03:00
Egor ed404a0e54 Revert "Add admin top-up verification tools" 2025-10-26 07:37:15 +03:00
Egor 05acfc0733 Merge pull request #1508 from Fr1ngg/ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up verification tools
2025-10-26 07:24:23 +03:00
Egor 969cd6ed90 Add admin top-up verification tools 2025-10-26 07:24:03 +03:00
Egor 939c9b9b09 Merge pull request #1507 from Fr1ngg/revert-1506-6evcye-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin pending payment verification endpoints"
2025-10-26 07:23:33 +03:00
Egor 051c0db106 Revert "Add admin pending payment verification endpoints" 2025-10-26 07:23:24 +03:00
Egor e5fbe005f8 Merge pull request #1506 from Fr1ngg/6evcye-bedolaga/add-auto-check-service-for-user-deposits
Add admin pending payment verification endpoints
2025-10-26 07:20:38 +03:00
Egor 63efb31785 Add admin APIs for pending payment checks 2025-10-26 07:20:23 +03:00
Egor 32586e177b Merge pull request #1505 from Fr1ngg/revert-1504-ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up monitoring and manual payment checks"
2025-10-26 07:19:49 +03:00
Egor 651dba2648 Revert "Add admin top-up monitoring and manual payment checks" 2025-10-26 07:19:41 +03:00
Egor 1bbcf2a95c Merge pull request #1504 from Fr1ngg/ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up monitoring and manual payment checks
2025-10-26 07:16:53 +03:00
Egor 82af176018 Add admin top-up monitoring and manual payment checks 2025-10-26 07:16:35 +03:00
Egor c10a4e9f8a Merge pull request #1503 from Fr1ngg/dev4
Fix mulen/юкасса webhooks
2025-10-26 06:56:37 +03:00
Egor 9727e07464 Merge pull request #1502 from Fr1ngg/9cf17y-bedolaga/improve-webhook-handling-for-payments
Improve YooKassa webhook recovery
2025-10-26 06:52:39 +03:00
Egor b780fe2278 Improve YooKassa webhook recovery 2025-10-26 06:52:18 +03:00
Egor 947d61f2a8 Merge pull request #1501 from Fr1ngg/revert-1500-pfpi1q-bedolaga/improve-webhook-handling-for-payments
Revert "Improve MulenPay hook resilience"
2025-10-26 06:47:15 +03:00
Egor 27725ec1a7 Revert "Improve MulenPay hook resilience" 2025-10-26 06:47:08 +03:00
Egor 93a29463f4 Merge pull request #1500 from Fr1ngg/pfpi1q-bedolaga/improve-webhook-handling-for-payments
Improve MulenPay hook resilience
2025-10-26 06:07:12 +03:00
Egor 84cfcf45fa Improve MulenPay hook resilience 2025-10-26 06:04:16 +03:00
Egor 6651e59f63 Merge pull request #1499 from Fr1ngg/main
ц
2025-10-26 05:55:27 +03:00
Egor ed46b7c919 Update miniapp-setup.md 2025-10-24 13:23:23 +03:00
Egor 88cd9fbc5b Update README.md 2025-10-24 13:14:17 +03:00
Egor 46fc5827ca Merge pull request #1497 from Fr1ngg/dev4
Dev4
2025-10-24 10:05:03 +03:00
Egor 65d386f3b2 Merge pull request #1496 from Fr1ngg/0x48r9-bedolaga/fix-datetime-error-in-payment-status-check
Normalize Heleket payment timestamps before saving
2025-10-24 10:01:40 +03:00
Egor 9db640931e Normalize Heleket paid_at timestamps to UTC 2025-10-24 10:01:23 +03:00
Egor 53e4dec12d Merge pull request #1495 from Fr1ngg/dev4
w
2025-10-24 09:45:03 +03:00
Egor 174d9f9b84 Merge pull request #1494 from Fr1ngg/ur3x81-bedolaga/expand-bot-api-for-surveys
Optimize poll listing response counts
2025-10-24 09:39:57 +03:00
Egor 30ec07f7fe Avoid loading poll responses in list endpoint 2025-10-24 09:39:39 +03:00
Egor dbf1fd8085 Merge pull request #1493 from Fr1ngg/revert-1492-yc9gsm-bedolaga/expand-bot-api-for-surveys
Revert "Document poll management endpoints in admin guide"
2025-10-24 09:36:37 +03:00
Egor 32e1f231d3 Revert "Document poll management endpoints in admin guide" 2025-10-24 09:36:28 +03:00
Egor 96b788693d Merge pull request #1492 from Fr1ngg/yc9gsm-bedolaga/expand-bot-api-for-surveys
Document poll management endpoints in admin guide
2025-10-24 09:32:52 +03:00
Egor a0e00c3735 Document poll administration API 2025-10-24 09:32:38 +03:00
Egor 51e68cf215 Merge pull request #1491 from Fr1ngg/wugnye-bedolaga/expand-bot-api-for-system-logs
Expose system log through admin API
2025-10-24 09:20:48 +03:00
Egor fa3ed3af81 Add system log endpoints to admin API 2025-10-24 09:20:34 +03:00
Egor 6e72cbb5b0 Merge pull request #1490 from Fr1ngg/u14a7y-bedolaga/expand-api-for-bot-logs-visibility
feat: expose monitoring logs through web api
2025-10-24 09:08:07 +03:00
Egor 5f316f85b3 feat: expose monitoring logs through web api 2025-10-24 09:06:08 +03:00
Egor 2abd1fd9df Merge pull request #1489 from Fr1ngg/main
w
2025-10-24 08:56:20 +03:00
Egor fcb5fedf9f Update README.md 2025-10-23 13:31:36 +03:00
Egor d35c9ad87e Merge pull request #1487 from Fr1ngg/dbunp5-bedolaga/fix-double-reward-issue-in-poll-system
Prevent double payout when rewarding poll
2025-10-23 08:21:19 +03:00
Egor 0cc0368b0b Prevent double payout when rewarding poll 2025-10-23 08:17:18 +03:00
Egor 00fb154cb9 Merge pull request #1485 from Fr1ngg/main
w
2025-10-23 08:12:14 +03:00
Egor 4441cf79ff Update Dockerfile 2025-10-23 08:02:41 +03:00
Egor 7428bd5cf7 Update docker-registry.yml 2025-10-23 08:02:29 +03:00
Egor 76c524e980 Update docker-hub.yml 2025-10-23 08:02:18 +03:00
Egor 5a9fa167c2 Merge pull request #1484 from Fr1ngg/dev4
Опросы
2025-10-23 07:52:15 +03:00
Egor c61e9c78ba Merge pull request #1483 from Fr1ngg/bcb1c4-bedolaga/fix-poll-sending-error-in-bot
Refresh poll answers after recording selections
2025-10-23 07:44:34 +03:00
Egor e5e3a9e4b5 Refresh poll answers after recording selections 2025-10-23 07:44:20 +03:00
Egor a1f47e13e3 Merge pull request #1482 from Fr1ngg/revert-1481-rnz085-bedolaga/fix-poll-sending-error-in-bot
Revert "Prevent duplicate taps when answering polls"
2025-10-23 07:43:51 +03:00
Egor 7ff0ed1248 Revert "Prevent duplicate taps when answering polls" 2025-10-23 07:43:43 +03:00
Egor 5eef24fb91 Merge pull request #1481 from Fr1ngg/rnz085-bedolaga/fix-poll-sending-error-in-bot
Prevent duplicate taps when answering polls
2025-10-23 07:40:19 +03:00
Egor 05ba26d3ac Avoid redundant poll edits and acknowledge answers 2025-10-23 07:39:57 +03:00
Egor ef68ce19ce Merge pull request #1480 from Fr1ngg/revert-1479-bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Revert "Handle redundant poll message edits gracefully"
2025-10-23 07:39:30 +03:00
Egor 2fa84f6dc9 Revert "Handle redundant poll message edits gracefully" 2025-10-23 07:39:20 +03:00
Egor 32409d7471 Merge pull request #1479 from Fr1ngg/bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Handle redundant poll message edits gracefully
2025-10-23 07:19:12 +03:00
Egor 270282a340 Gracefully handle poll message updates 2025-10-23 07:18:59 +03:00
Egor 87eaa06652 Merge pull request #1478 from Fr1ngg/revert-1477-9bx38j-bedolaga/fix-poll-sending-error-in-bot
Revert "Streamline poll creation prompts and skip unreachable chats"
2025-10-23 07:18:12 +03:00
Egor 845f03e873 Revert "Streamline poll creation prompts and skip unreachable chats" 2025-10-23 07:18:04 +03:00
Egor 97eabb0604 Merge pull request #1477 from Fr1ngg/9bx38j-bedolaga/fix-poll-sending-error-in-bot
Streamline poll creation prompts and skip unreachable chats
2025-10-23 07:10:27 +03:00
Egor f0d714d787 Improve poll creation prompts and poll delivery 2025-10-23 07:10:13 +03:00
Egor 27a34d6e9f Merge pull request #1476 from Fr1ngg/revert-1475-revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Stabilize poll creation UI and delivery loop"""
2025-10-23 07:10:00 +03:00
Egor a4e97e0317 Revert "Revert "Revert "Stabilize poll creation UI and delivery loop""" 2025-10-23 07:09:51 +03:00
Egor 97dcd0b7cc Merge pull request #1475 from Fr1ngg/revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Stabilize poll creation UI and delivery loop""
2025-10-23 06:58:40 +03:00
Egor 9b57904cf4 Revert "Revert "Stabilize poll creation UI and delivery loop"" 2025-10-23 06:58:29 +03:00
Egor 6ebdae0cae Merge pull request #1474 from Fr1ngg/revert-1469-56bjws-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation flow and poll delivery"
2025-10-23 06:57:45 +03:00
Egor b9d4a8f8e9 Revert "Stabilize poll creation flow and poll delivery" 2025-10-23 06:57:36 +03:00
Egor 707c3c5daf Merge pull request #1469 from Fr1ngg/56bjws-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation flow and poll delivery
2025-10-23 06:55:37 +03:00
Egor d075abfeea Merge pull request #1473 from Fr1ngg/revert-1472-1yvk15-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll creation wizard updates and poll delivery"
2025-10-23 06:55:14 +03:00
Egor d3399c569c Revert "Fix poll creation wizard updates and poll delivery" 2025-10-23 06:55:06 +03:00
Egor e36e3f7f8f Merge pull request #1472 from Fr1ngg/1yvk15-bedolaga/fix-poll-sending-error-in-bot
Fix poll creation wizard updates and poll delivery
2025-10-23 06:52:24 +03:00
Egor 583e01f5e6 Stabilize poll creation prompts and delivery 2025-10-23 06:51:48 +03:00
Egor 784b5e0af6 Merge pull request #1471 from Fr1ngg/revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation UI and delivery loop"
2025-10-23 06:51:28 +03:00
Egor cc42d33fbe Revert "Stabilize poll creation UI and delivery loop" 2025-10-23 06:51:19 +03:00
Egor 9c3d8fd701 Merge pull request #1470 from Fr1ngg/xvg3de-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation UI and delivery loop
2025-10-23 06:47:12 +03:00
Egor d281eeb69d Stabilize poll creation UI and delivery loop 2025-10-23 06:46:46 +03:00
Egor b510ffb7d8 Stabilize poll creation UI and sending 2025-10-23 06:46:11 +03:00
Egor 6855b7765c Merge pull request #1468 from Fr1ngg/revert-1467-revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts"""
2025-10-23 06:45:44 +03:00
Egor 9ed1129268 Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts""" 2025-10-23 06:45:35 +03:00
Egor 0c4ba3c0d0 Merge pull request #1467 from Fr1ngg/revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Fix poll sending flow and tidy poll creation prompts""
2025-10-23 06:32:55 +03:00
Egor 70337ac310 Revert "Revert "Fix poll sending flow and tidy poll creation prompts"" 2025-10-23 06:32:40 +03:00
Egor e981082713 Merge pull request #1466 from Fr1ngg/revert-1465-bh9v34-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll delivery session usage and tidy poll creation prompts"
2025-10-23 06:32:01 +03:00
Egor 104d98542e Revert "Fix poll delivery session usage and tidy poll creation prompts" 2025-10-23 06:31:20 +03:00
Egor fe63ef5fc5 Merge pull request #1465 from Fr1ngg/bh9v34-bedolaga/fix-poll-sending-error-in-bot
Fix poll delivery session usage and tidy poll creation prompts
2025-10-23 06:30:12 +03:00
Egor b1ff964dd6 Fix poll sending session access and clean poll creation chat 2025-10-23 06:29:59 +03:00
Egor aba1d5dbe5 Merge pull request #1464 from Fr1ngg/revert-1463-hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending DB session usage and clean poll creation chat"
2025-10-23 06:29:35 +03:00
Egor 0af7e4ac68 Revert "Fix poll sending DB session usage and clean poll creation chat" 2025-10-23 06:29:23 +03:00
Egor 7d9a572e6e Merge pull request #1463 from Fr1ngg/hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending DB session usage and clean poll creation chat
2025-10-23 06:27:12 +03:00
Egor de6557d7e7 Fix poll sending session usage and clean creation prompts 2025-10-23 06:26:59 +03:00
Egor a052c7bb80 Merge pull request #1462 from Fr1ngg/revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending flow and tidy poll creation prompts"
2025-10-23 06:26:13 +03:00
Egor 8c47f3c49b Revert "Fix poll sending flow and tidy poll creation prompts" 2025-10-23 06:26:04 +03:00
Egor 323ad903e7 Merge pull request #1461 from Fr1ngg/ahggij-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending flow and tidy poll creation prompts
2025-10-23 06:22:46 +03:00
Egor 8105fe285d Fix poll sending flow and tidy poll creation prompts 2025-10-23 06:22:31 +03:00
Egor 82a19025f1 Merge pull request #1460 from Fr1ngg/revert-1455-revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Revert "Add poll management and delivery system""
2025-10-23 06:03:49 +03:00
Egor e592b3e5c4 Revert "Revert "Add poll management and delivery system"" 2025-10-23 06:03:41 +03:00
Egor 2da12354a1 Merge pull request #1459 from Fr1ngg/revert-1458-itqeed-bedolaga/add-survey-functionality-to-bot
Revert "Fix aiogram Bot usage in poll handlers"
2025-10-23 06:03:17 +03:00
Egor fc65b62d65 Revert "Fix aiogram Bot usage in poll handlers" 2025-10-23 06:03:09 +03:00
Egor 28603a87e1 Merge pull request #1458 from Fr1ngg/itqeed-bedolaga/add-survey-functionality-to-bot
Fix aiogram Bot usage in poll handlers
2025-10-23 05:56:02 +03:00
Egor 8956aafc9a Fix poll handlers Bot type annotations 2025-10-23 05:55:48 +03:00
Egor 2ee9b263b8 Merge pull request #1457 from Fr1ngg/revert-1456-9v7qqh-bedolaga/add-survey-functionality-to-bot
Revert "Add admin-managed polls with user rewards"
2025-10-23 05:55:15 +03:00
Egor 3a5d838be3 Revert "Add admin-managed polls with user rewards" 2025-10-23 05:55:06 +03:00
Egor bf25fcfda6 Merge pull request #1456 from Fr1ngg/9v7qqh-bedolaga/add-survey-functionality-to-bot
Add admin-managed polls with user rewards
2025-10-23 05:38:12 +03:00
Egor ecdf2fcae4 feat: add admin polls with rewards and delivery 2025-10-23 05:38:01 +03:00
Egor 6fec14de4b Merge pull request #1455 from Fr1ngg/revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Add poll management and delivery system"
2025-10-23 05:37:48 +03:00
Egor 7b4cbb27b7 Revert "Add poll management and delivery system" 2025-10-23 05:37:40 +03:00
Egor d7d7795257 Merge pull request #1454 from Fr1ngg/k4r4kv-bedolaga/add-survey-functionality-to-bot
Add poll management and delivery system
2025-10-23 05:28:28 +03:00
Egor 93554c7034 Add poll management and delivery system 2025-10-23 05:28:13 +03:00
Egor fbe7febdde Merge pull request #1453 from Fr1ngg/g16e9c-bedolaga/check-referral-income-on-balance-top-up
Ensure MulenPay top-ups trigger referral rewards
2025-10-23 05:05:31 +03:00
Egor f091b3ea5e Add referral rewards for MulenPay top-ups 2025-10-23 05:05:16 +03:00
Egor aae469853e Merge pull request #1452 from Fr1ngg/main
ц
2025-10-23 04:55:09 +03:00
Egor dac68ccd8c Merge pull request #1451 from Gy9vin/main
ФИКС ПО ЮКАССЕ
2025-10-23 03:13:21 +03:00
gy9vin fb140e82f6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-22 13:59:24 +03:00
gy9vin 8cac442de1 ФИКС ПРОСТОЙ ПОКУПКИ 2025-10-22 13:59:20 +03:00
Egor 8d8a465c43 Merge pull request #1450 from Gy9vin/main
фикс простой покупки кнопка всегда отображается
2025-10-22 10:01:50 +03:00
gy9vin 51f2c660e8 фикс простой покупки кнопка все отображается 2025-10-22 09:35:22 +03:00
Egor 39ecca306d Merge pull request #1449 from Fr1ngg/dev4
Dev4
2025-10-22 03:06:09 +03:00
Egor 7896a24e13 Merge pull request #1448 from Fr1ngg/beb27u-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:36:40 +03:00
Egor c21325f260 Skip unlimited traffic surcharge for simple plans 2025-10-22 02:36:23 +03:00
Egor 18df7395a2 Merge pull request #1447 from Fr1ngg/revert-1446-qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:36:12 +03:00
Egor f139251333 Revert "Fix simple subscription price calculations" 2025-10-22 02:36:04 +03:00
Egor 369e384b7d Merge pull request #1446 from Fr1ngg/qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:23:05 +03:00
Egor 008f62e02b Add async test runner shim and fix Pal24 adapter test 2025-10-22 02:22:50 +03:00
Egor 6e8981a245 Merge pull request #1445 from Fr1ngg/revert-1444-z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:22:41 +03:00
Egor bb17cf7361 Revert "Fix simple subscription price calculations" 2025-10-22 02:22:33 +03:00
Egor d13c6767e6 Merge pull request #1444 from Fr1ngg/z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:12:40 +03:00
Egor bf2ee37f33 Fix simple subscription pricing to include squad costs 2025-10-22 02:12:23 +03:00
Egor b82929ac3d Merge pull request #1443 from Fr1ngg/dev4
Dev4
2025-10-21 20:23:34 +03:00
Egor 9b0ea9bf2a Merge pull request #1442 from Fr1ngg/0opqnu-bedolaga/fix-balance-update-issue-with-heleket
Improve Heleket payment status synchronisation
2025-10-21 20:05:17 +03:00
Egor 5bde7196dc Improve Heleket payment status synchronisation 2025-10-21 20:04:59 +03:00
Egor 71c3e7fe0d Merge pull request #1441 from Fr1ngg/dev4
Dev4
2025-10-21 19:29:43 +03:00
Egor 201543c263 Merge pull request #1440 from Fr1ngg/revert-1439-revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Revert "Fix YooKassa webhook paid flag persistence""
2025-10-21 19:28:16 +03:00
Egor 5ce7417c80 Revert "Revert "Fix YooKassa webhook paid flag persistence"" 2025-10-21 19:28:01 +03:00
Egor e98564b5ab Merge pull request #1438 from Fr1ngg/main
ww
2025-10-21 19:27:13 +03:00
Egor 37dd48cc20 Merge pull request #1439 from Fr1ngg/revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Fix YooKassa webhook paid flag persistence"
2025-10-21 19:26:28 +03:00
Egor 9dff6e8045 Revert "Fix YooKassa webhook paid flag persistence" 2025-10-21 19:26:18 +03:00
Egor 572094050e Merge pull request #1434 from Gy9vin/main
Доработки
2025-10-21 19:25:27 +03:00
Egor 2b41337bbf Merge pull request #1437 from Fr1ngg/tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Fix YooKassa webhook paid flag persistence
2025-10-21 19:20:17 +03:00
Egor ca94c402bd Fix YooKassa webhook paid flag 2025-10-21 19:19:50 +03:00
gy9vin 6fdc46c3c6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-21 15:24:38 +03:00
gy9vin 34fc568f97 простую покупку можно сделать с активированной подпиской добавит n дней к текущему количеству 2025-10-21 15:24:35 +03:00
Egor 38664ff853 Merge pull request #1433 from Fr1ngg/dev4
Dev4
2025-10-21 12:01:56 +03:00
Egor e44284d4ba Merge pull request #1432 from Fr1ngg/main
w
2025-10-21 12:01:10 +03:00
Egor d54dc822cc Merge pull request #1431 from Fr1ngg/uum2gm-bedolaga/fix-unresolved-resolved_squad_uuid-error
Ensure resolved squad uuid is set for simple subscription payments
2025-10-21 11:59:59 +03:00
Egor dbe9c2d5e9 Ensure resolved squad is set for simple subscription payments 2025-10-21 11:57:41 +03:00
Egor b036d090c6 Update Dockerfile 2025-10-21 11:31:44 +03:00
Egor d44b303dd3 Update docker-registry.yml 2025-10-21 11:31:34 +03:00
Egor e69bfdac20 Update docker-hub.yml 2025-10-21 11:31:20 +03:00
Egor 53f6c4e71b Merge pull request #1429 from Fr1ngg/dev4
Heleket
2025-10-21 11:22:48 +03:00
Egor c67223a332 Merge pull request #1428 from Fr1ngg/7mhcv8-bedolaga/move-heleket-to-payment-systems
Move Heleket settings into payment systems group
2025-10-21 11:21:57 +03:00
Egor 2c253382d5 Move Heleket settings into payment systems group 2025-10-21 11:21:37 +03:00
Egor 02b65cb1c9 Merge pull request #1427 from Fr1ngg/kbxpuv-bedolaga/add-heleket-balance-top-up-method
Polish Heleket payment integration
2025-10-21 11:13:09 +03:00
Egor 49ace78a68 Polish Heleket payment integration 2025-10-21 11:12:47 +03:00
Egor 0669086742 Merge pull request #1426 from Fr1ngg/k9cky4-bedolaga/add-balance-refill-method-for-heleket
Add Heleket payment provider integration
2025-10-21 10:59:36 +03:00
Egor 2f2d9bc1af Add Heleket payment provider integration 2025-10-21 10:59:19 +03:00
Egor 9b2b11ee44 Merge pull request #1425 from Fr1ngg/1n7xv6-bedolaga/update-simple-subscription-logic
Fix simple subscription start state data usage
2025-10-21 10:09:57 +03:00
Egor d318738596 Fix state data lookup during simple subscription start 2025-10-21 10:09:43 +03:00
Egor 6ae41369e9 Merge pull request #1424 from Fr1ngg/revert-1423-v20afv-bedolaga/update-simple-subscription-logic
Revert "Fix simple subscription button visibility and random squad assignment"
2025-10-21 10:09:19 +03:00
Egor ce9f2e0d41 Revert "Fix simple subscription button visibility and random squad assignment" 2025-10-21 10:09:08 +03:00
Egor aa7bd42375 Merge pull request #1423 from Fr1ngg/v20afv-bedolaga/update-simple-subscription-logic
Fix simple subscription button visibility and random squad assignment
2025-10-21 10:01:17 +03:00
Egor a6b19e4b95 Adjust simple subscription menu and squad assignment 2025-10-21 10:01:01 +03:00
Egor 7a21518ce2 Merge pull request #1422 from Fr1ngg/dev4
Dev4
2025-10-21 09:45:23 +03:00
Egor ba92f7da6c Merge pull request #1421 from Fr1ngg/52n798-bedolaga/escape-referrer-names-in-html-report
Escape referrer names in HTML report
2025-10-21 09:36:43 +03:00
Egor f62f37581f Escape referrer names in HTML report 2025-10-21 09:36:30 +03:00
Egor 0d08c2c7f0 Merge pull request #1420 from Fr1ngg/8jof80-bedolaga/fix-syntaxerror-in-purchase.py
Fix syntax issues in subscription purchase handler
2025-10-21 09:31:32 +03:00
Egor 078e59f09c Fix syntax issues in subscription purchase handler 2025-10-21 09:31:18 +03:00
Egor ab708a78a8 Merge pull request #1419 from Fr1ngg/mjma6t-bedolaga/integrate-reporting-service-code
feat: enhance reporting statistics
2025-10-21 09:26:09 +03:00
Egor 630688ecf5 feat: enhance reporting statistics 2025-10-21 09:25:53 +03:00
Egor 2b6901e87a Merge pull request #1418 from Fr1ngg/xfpbps-bedolaga/fix-button-in-subscription-renewal-section
Fix autopay day button label
2025-10-21 09:10:41 +03:00
Egor 0e43daf48b Fix autopay day button label 2025-10-21 09:10:26 +03:00
Egor e9badff1d8 Merge pull request #1417 from Fr1ngg/x4oldj-bedolaga/fix-subscription_config_back-button-behavior
Fix back navigation during subscription confirmation
2025-10-21 09:06:03 +03:00
Egor f588191931 Fix subscription back navigation restoring configuration 2025-10-21 09:05:44 +03:00
Egor e17dfbff35 Merge pull request #1415 from Fr1ngg/uq97y3-bedolaga/fix-back-button-issue-in-subscription-purchase
Handle back navigation after simple subscription QR message
2025-10-21 09:00:24 +03:00
Egor b7e9770588 Handle back navigation after simple subscription QR 2025-10-21 09:00:08 +03:00
Egor 9371524257 Merge pull request #1414 from Fr1ngg/main
w
2025-10-21 07:42:49 +03:00
Egor c1f8fe69cf Update requirements.txt 2025-10-21 00:04:51 +03:00
Egor 87e3449444 Update Dockerfile 2025-10-21 00:04:31 +03:00
Egor e8ef1bc255 Update requirements.txt 2025-10-21 00:00:50 +03:00
Egor fe3a593da0 Update requirements.txt 2025-10-20 23:58:29 +03:00
Egor bf96d85edb Update requirements.txt 2025-10-20 23:56:30 +03:00
Egor 700d718da6 Update Dockerfile 2025-10-20 23:52:48 +03:00
Egor 0c9ad3cec8 Merge pull request #1413 from Fr1ngg/revert-1295-dependabot/pip/python-dependencies-e109b03380
Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates"
2025-10-20 23:48:46 +03:00
Egor 73d789d3f7 Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates" 2025-10-20 23:48:35 +03:00
Egor 0a09ae4290 Update requirements.txt 2025-10-20 23:44:04 +03:00
Egor 0758988b5f Update requirements.txt 2025-10-20 23:42:11 +03:00
Egor 089f668e14 Merge pull request #1295 from Fr1ngg/dependabot/pip/python-dependencies-e109b03380
deps(deps): bump the python-dependencies group across 1 directory with 10 updates
2025-10-20 23:37:14 +03:00
dependabot[bot] 1e5bf143c4 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pyyaml
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: fastapi
  dependency-version: 0.119.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: flask
  dependency-version: 3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 07:03:13 +00:00
Egor d1132c82f8 Merge pull request #1309 from Fr1ngg/dependabot/docker/python-3.14-slim
docker(deps): bump python from 3.13-slim to 3.14-slim
2025-10-20 00:40:11 +03:00
Egor 62545276e4 Merge pull request #1296 from Fr1ngg/dependabot/pip/aiofiles-25.1.0
deps(deps): bump aiofiles from 23.2.1 to 25.1.0
2025-10-20 00:40:01 +03:00
Egor 2878bc3c3d Merge pull request #1412 from Gy9vin/main
Локализация и перенос кнопки простая покупка в главное меню на постоя…
2025-10-20 00:38:41 +03:00
gy9vin eed0fe39e7 Фикс расположения кнопок в главном меню 2025-10-19 16:23:40 +03:00
Mikhail 6bbee5586f Merge branch 'Fr1ngg:main' into main 2025-10-19 16:09:12 +03:00
gy9vin 7ac2354197 Локализация и перенос кнопки простая покупка в главное меню на постоянку! 2025-10-19 16:08:23 +03:00
Egor 3463f08162 Merge pull request #1411 from Gy9vin/main
Фимк мули в простой подписке
2025-10-19 16:01:14 +03:00
gy9vin 4b0aaa02c7 Фимк мули в простой подписке 2025-10-19 15:54:01 +03:00
Egor a946f7b501 Merge pull request #1410 from Gy9vin/main
Фикс простой покупки
2025-10-19 15:53:44 +03:00
gy9vin 7f2e4615ee Фикс простой покупки 2025-10-19 15:52:10 +03:00
Egor 28ab7719f5 Merge pull request #1409 from Gy9vin/main
Добовление всех платежек у простую покупку
2025-10-19 15:37:01 +03:00
gy9vin 31b5101f64 Исправление локализации 2025-10-19 15:23:09 +03:00
gy9vin 62777f8995 Возможность настройки простой покупки 2025-10-19 15:10:22 +03:00
gy9vin f9cff3e169 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-19 13:38:28 +03:00
gy9vin 04c255d748 Добовление оставшихся платежек в простую подписку 2025-10-19 13:33:56 +03:00
dependabot[bot] aae771764e docker(deps): bump python from 3.13-slim to 3.14-slim
Bumps python from 3.13-slim to 3.14-slim.

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-18 23:42:42 +00:00
Egor d4e1544fc8 Update Python version to v2.5.3 in Dockerfile 2025-10-19 02:41:35 +03:00
Egor 6db5cae013 Update docker-registry.yml 2025-10-19 02:41:28 +03:00
Egor 61089a9f30 Update docker-hub.yml 2025-10-19 02:41:19 +03:00
Egor 3ad1f0fc97 Merge pull request #1408 from Fr1ngg/dev4
Dev4
2025-10-19 02:29:38 +03:00
Egor 67ac3c933f Merge pull request #1407 from Fr1ngg/g0v1v5-bedolaga/add-button-to-close-all-open-tickets
Add admin bulk close control for support tickets
2025-10-19 02:27:39 +03:00
Egor d6f4751274 Add bulk close control for admin tickets 2025-10-19 02:27:19 +03:00
Egor 6fdb02af10 Merge pull request #1406 from Fr1ngg/qbwjrp-bedolaga/fix-mulenpay-callback-handling
Handle alternative MulenPay webhook status fields
2025-10-19 02:25:09 +03:00
Egor ce456c4f7a Fix MulenPay webhook status parsing 2025-10-19 02:24:55 +03:00
Egor 314b17ce79 Merge pull request #1405 from Fr1ngg/revert-1404-zcb4ql-bedolaga/fix-mulenpay-callback-handling
Revert "Fix MulenPay callback paid status handling"
2025-10-19 02:24:41 +03:00
Egor 598d9ad0a1 Revert "Fix MulenPay callback paid status handling" 2025-10-19 02:23:39 +03:00
Egor 734711dab7 Merge pull request #1404 from Fr1ngg/zcb4ql-bedolaga/fix-mulenpay-callback-handling
Fix MulenPay callback paid status handling
2025-10-19 02:21:10 +03:00
Egor c4c5d6a018 Fix MulenPay callback marking payments as paid 2025-10-19 02:20:55 +03:00
Egor 883cd5a952 Merge pull request #1403 from Fr1ngg/p642y1-bedolaga/fix-paid-status-check-expiry-validation
Validate subscription expiry before treating paid status as active
2025-10-19 02:18:49 +03:00
Egor 8cf7445b99 Ensure paid subscriptions respect expiration dates 2025-10-19 02:18:32 +03:00
Egor 4c11af4644 Merge pull request #1402 from Fr1ngg/h2w24i-bedolaga/add-subscription-prompt-after-expiry
Fix subscription purchase availability after expiry
2025-10-19 02:12:11 +03:00
Egor 47036d5616 Fix subscription purchase availability after expiry 2025-10-19 02:11:55 +03:00
Egor 025330e052 Merge pull request #1401 from Fr1ngg/hc9da4-bedolaga/fix-typeerror-in-stage.skip-call
Fix stage skip message formatting
2025-10-19 02:04:14 +03:00
Egor 8d18c78fb2 Fix stage skip message format 2025-10-19 02:03:58 +03:00
Egor d93a2a0682 Merge pull request #1400 from Fr1ngg/7xyspp-bedolaga/add-urlpay-payment-method-for-mulenpay
feat: make MulenPay display name configurable
2025-10-19 02:01:48 +03:00
Egor 0c93516056 feat: make MulenPay display name configurable 2025-10-19 01:55:10 +03:00
Egor bf126bdadb Merge pull request #1399 from Fr1ngg/m7z0jw-bedolaga/fix-datetime-error-in-payment-processing
Fix WATA datetime timezone handling
2025-10-19 01:25:15 +03:00
Egor d421d43522 Fix WATA datetime timezone handling 2025-10-19 01:24:57 +03:00
Egor 29cda4dcd7 Merge pull request #1398 from Gy9vin/main
Простая покупка подписки!
2025-10-19 01:06:51 +03:00
gy9vin dde90f9ca4 продление подписки после пополнения баланса 2025-10-18 21:10:02 +03:00
gy9vin 8415c68063 Отправка конкретному пользователю сообщения непосредственно из карточки пользователя 2025-10-18 20:56:22 +03:00
gy9vin 9edde19b8a Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-18 19:14:28 +03:00
gy9vin 83a473606f Простая покупка подписки 2025-10-18 18:42:03 +03:00
Egor 849f06ad2b Merge pull request #1397 from Fr1ngg/dev4
Dev4
2025-10-18 02:17:57 +03:00
Egor e27e1299f7 Merge pull request #1396 from Fr1ngg/hppcmp-bedolaga/fix-wata-webhook-not-triggering
Allow WATA webhook generator to commit before responding
2025-10-18 02:17:27 +03:00
Egor 03a45e39d4 Let WATA webhook session generator finish before responding 2025-10-18 02:09:50 +03:00
Egor 4663464ea4 Merge pull request #1394 from Fr1ngg/revert-1392-94xvf3-bedolaga/fix-wata-webhook-not-triggering
Revert "Ensure WATA webhook lets session commit before responding"
2025-10-18 02:09:28 +03:00
Egor 338b98bae3 Revert "Ensure WATA webhook lets session commit before responding" 2025-10-18 02:09:19 +03:00
Egor 7326d64fbe Merge pull request #1393 from Fr1ngg/dev4
Dev4
2025-10-18 01:59:58 +03:00
Egor bee94bcad0 Merge pull request #1392 from Fr1ngg/94xvf3-bedolaga/fix-wata-webhook-not-triggering
Ensure WATA webhook lets session commit before responding
2025-10-18 01:59:22 +03:00
Egor 414b796700 Ensure WATA webhook session commits before responding 2025-10-18 01:58:33 +03:00
Egor f76abce939 Merge pull request #1391 from Fr1ngg/revert-1388-qnq9d9-bedolaga/fix-wata-webhook-not-triggering
Revert "Fix WATA webhook session handling"
2025-10-18 01:58:19 +03:00
Egor 99460665f1 Revert "Fix WATA webhook session handling" 2025-10-18 01:58:06 +03:00
Egor 730a6ae185 Merge pull request #1390 from Fr1ngg/dev4
Dev4
2025-10-18 01:51:14 +03:00
Egor ab8a5ca336 Merge pull request #1388 from Fr1ngg/qnq9d9-bedolaga/fix-wata-webhook-not-triggering
Fix WATA webhook session handling
2025-10-18 01:50:47 +03:00
Egor d1f0cf5854 Merge pull request #1389 from Fr1ngg/revert-1385-wigsy6-bedolaga/fix-wata-webhook-not-triggering
Revert "Expose payment webhook ports in docker-compose"
2025-10-18 01:50:38 +03:00
Egor 1f3c85e182 Revert "Expose payment webhook ports in docker-compose" 2025-10-18 01:50:30 +03:00
Egor bfcfe0de3d Fix WATA webhook session handling 2025-10-18 01:50:04 +03:00
Egor f55f745080 Merge pull request #1386 from Fr1ngg/dev4
Dev4
2025-10-18 01:40:35 +03:00
Egor a26524e6ea Merge pull request #1385 from Fr1ngg/wigsy6-bedolaga/fix-wata-webhook-not-triggering
Expose payment webhook ports in docker-compose
2025-10-18 01:39:51 +03:00
Egor b5f57d4d0e Expose payment webhook ports in compose 2025-10-18 01:39:32 +03:00
Egor 5aa9c1316e Merge pull request #1384 from Fr1ngg/revert-1383-gb3ntc-bedolaga/fix-wata-webhook-not-triggering
Revert "Add WATA webhook listener with RSA signature verification"
2025-10-18 01:39:18 +03:00
Egor 524b4f5e64 Revert "Add WATA webhook listener with RSA signature verification" 2025-10-18 01:39:08 +03:00
Egor a2bb0b8341 Merge pull request #1383 from Fr1ngg/gb3ntc-bedolaga/fix-wata-webhook-not-triggering
Add WATA webhook listener with RSA signature verification
2025-10-18 01:21:19 +03:00
Egor 9cabbf72d2 Add WATA webhook support with signature verification 2025-10-18 01:20:51 +03:00
Egor 5f368f84ce Merge pull request #1382 from Fr1ngg/bedolaga-3a5xcg
Fix quick top-up buttons for Stars and CryptoBot
2025-10-18 01:02:15 +03:00
Egor ed01d2bc6f Fix quick top-up buttons for stars and cryptobot 2025-10-18 01:01:56 +03:00
Egor 7107070624 Merge pull request #1381 from Fr1ngg/dev4
Dev4
2025-10-17 17:23:05 +03:00
Egor c361f0c1d4 Merge pull request #1379 from Fr1ngg/noejf1-bedolaga/fix-bot-not-recognizing-payment-webhook
Fix Pal24 cart reminder callback
2025-10-17 17:22:21 +03:00
Egor ab95bc2192 Merge pull request #1380 from Fr1ngg/revert-1377-gkxf4k-bedolaga/fix-bot-not-recognizing-payment-webhook
Revert "Handle Pal24 payments when webhooks are missing"
2025-10-17 17:22:09 +03:00
Egor bf6a68ae27 Revert "Handle Pal24 payments when webhooks are missing" 2025-10-17 17:21:58 +03:00
Egor 1083651591 Fix Pal24 cart reminder callback 2025-10-17 17:21:11 +03:00
Egor b35fb824ec Merge pull request #1378 from Fr1ngg/dev4
Рефактор палыча
2025-10-17 17:14:29 +03:00
Egor ad839fb63d Merge pull request #1377 from Fr1ngg/gkxf4k-bedolaga/fix-bot-not-recognizing-payment-webhook
Handle Pal24 payments when webhooks are missing
2025-10-17 17:09:25 +03:00
Egor 377a731ca2 Handle Pal24 payments when webhooks are missing 2025-10-17 17:08:59 +03:00
Egor 3395021fcf Merge pull request #1376 from Fr1ngg/main
w
2025-10-17 16:48:45 +03:00
Egor a4ce1a83be Merge pull request #1375 from Gy9vin/main
QR-код в ЮКассе для оплаты с другого телефона!
2025-10-17 16:48:06 +03:00
gy9vin 8b2f9e1803 Оплата по qr с другого телефона по СБП через ЮКасса 2025-10-17 11:20:08 +03:00
Mikhail a40c07a6af Merge branch 'Fr1ngg:main' into main 2025-10-17 09:20:11 +03:00
Egor 2dbc23fa80 Merge pull request #1374 from Fr1ngg/dev4
Dev4
2025-10-16 17:24:54 +03:00
Egor 233401c057 Merge branch 'main' into dev4 2025-10-16 17:23:52 +03:00
Egor 66f17f90fd Merge pull request #1371 from Fr1ngg/v2kjyk-bedolaga/fix-paypal-balance-top-up-error
Fix Pal24 payment method values
2025-10-16 17:21:51 +03:00
Egor 9748cc6b3e Merge pull request #1373 from Fr1ngg/revert-1369-58400a-bedolaga/fix-paypal-balance-top-up-error
Revert "Fix Pal24 custom payload serialization"
2025-10-16 17:21:29 +03:00
Egor bcab53cfe3 Revert "Fix Pal24 custom payload serialization" 2025-10-16 17:21:20 +03:00
Egor 1af387de2b Merge pull request #1372 from Fr1ngg/revert-1370-dev4
Revert "Dev4"
2025-10-16 17:21:08 +03:00
Egor 25c54c588b Revert "Dev4" 2025-10-16 17:20:58 +03:00
Egor 6ba7c6ed5e Fix Pal24 payment method values 2025-10-16 17:20:25 +03:00
Egor 222c1a488f Merge pull request #1370 from Fr1ngg/dev4
Dev4
2025-10-16 16:55:24 +03:00
Egor c1a92adf41 Merge pull request #1369 from Fr1ngg/58400a-bedolaga/fix-paypal-balance-top-up-error
Fix Pal24 custom payload serialization
2025-10-16 16:54:58 +03:00
Egor 972bb18344 Serialize Pal24 custom payloads 2025-10-16 16:54:37 +03:00
Egor de11de4cd0 Merge pull request #1368 from Fr1ngg/dev4
Dev4
2025-10-16 16:37:47 +03:00
Egor f8c3daac23 Merge pull request #1367 from Fr1ngg/gpcn99-bedolaga/fix-balance-top-up-via-pal24
Fix Pal24 payment method mapping
2025-10-16 16:37:24 +03:00
Egor 91a3b14a1f Fix Pal24 payment method mapping 2025-10-16 16:36:56 +03:00
Egor d310896168 Merge pull request #1366 from Fr1ngg/revert-1363-xa5gb6-bedolaga/fix-paypal-payment-creation-error
Revert "Retry Pal24 bill creation with multiple method identifiers"
2025-10-16 16:29:15 +03:00
Egor d4df56b173 Revert "Retry Pal24 bill creation with multiple method identifiers" 2025-10-16 16:29:07 +03:00
Egor 3eab0b3848 Merge pull request #1365 from Fr1ngg/revert-1361-revert-1360-dev4
Revert "Revert "Dev4""
2025-10-16 16:28:03 +03:00
Egor 609a557502 Revert "Revert "Dev4"" 2025-10-16 16:25:53 +03:00
Egor 6a4ebad89f Merge pull request #1363 from Fr1ngg/xa5gb6-bedolaga/fix-paypal-payment-creation-error
Retry Pal24 bill creation with multiple method identifiers
2025-10-16 16:22:13 +03:00
Egor e229b625f6 Retry Pal24 bill creation with multiple method identifiers 2025-10-16 16:19:24 +03:00
Egor 3ff5dec4d3 Merge pull request #1362 from Fr1ngg/revert-1359-fc1v04-bedolaga/fix-paypal-payment-creation-error
Revert "Fix Pal24 payment method mapping"
2025-10-16 16:08:37 +03:00
Egor 6dfd7db115 Revert "Fix Pal24 payment method mapping" 2025-10-16 16:08:28 +03:00
Egor 782dff4f82 Merge pull request #1361 from Fr1ngg/revert-1360-dev4
Revert "Dev4"
2025-10-16 16:08:14 +03:00
Egor 1540d2665f Revert "Dev4" 2025-10-16 16:08:03 +03:00
Egor 567d0c1a8d Merge pull request #1360 from Fr1ngg/dev4
Dev4
2025-10-16 01:38:44 +03:00
Egor 6b031d4e9c Merge pull request #1359 from Fr1ngg/fc1v04-bedolaga/fix-paypal-payment-creation-error
Fix Pal24 payment method mapping
2025-10-16 01:36:48 +03:00
Egor 72046d1ccf Fix Pal24 payment method mapping 2025-10-16 01:35:54 +03:00
Egor d41af2cd85 Merge pull request #1358 from Fr1ngg/main
w
2025-10-16 01:29:47 +03:00
Egor 174647c963 Update README.md 2025-10-16 01:21:50 +03:00
Egor e5e52b839c Merge pull request #1357 from Fr1ngg/dev4
Фикс палыча
2025-10-15 23:49:28 +03:00
Egor ab19becdc8 Delete miniapp/new/miniapp directory 2025-10-15 23:47:35 +03:00
Egor f5e9a6f2b9 Merge pull request #1356 from Fr1ngg/4dxbnt-bedolaga/fix-pal24-payment-system-issues
Fix Pal24 webhook and payment flows
2025-10-15 23:39:09 +03:00
Egor b492437b54 Fix Pal24 payment flows and webhook handling 2025-10-15 23:38:51 +03:00
Egor 07546da0fd Merge pull request #1355 from Fr1ngg/main
w
2025-10-15 22:25:35 +03:00
Egor fc4423d9de Merge pull request #1354 from yazhog/main
Снес из репозитория пользовательские локали
2025-10-15 22:24:26 +03:00
Mikhail c9a0f59e28 Merge pull request #1 from Fr1ngg/main
ошибки пофиксил
2025-10-15 21:50:16 +03:00
yazhog 2d31bc6f23 Merge pull request #46 from yazhog/codex/customize-locale-phrases-for-git-pull
Sync default locales with custom keys
2025-10-15 20:33:08 +03:00
yazhog dac2e1c85f docs: document locales directory setup 2025-10-15 20:32:42 +03:00
yazhog f4d9b0d219 Clarify locale directory permission warnings 2025-10-15 20:32:35 +03:00
yazhog 9b485bf822 Handle unwritable locale directories gracefully 2025-10-15 20:21:52 +03:00
yazhog af3e8eab4e Ensure user locales include default templates 2025-10-15 20:15:48 +03:00
yazhog 63d7500398 Remove user locale overrides from repo 2025-10-15 20:09:32 +03:00
yazhog 545f52f207 Sync default locales with custom keys 2025-10-15 20:02:37 +03:00
Egor 7fbb4cf2da Add files via upload 2025-10-15 08:14:17 +03:00
Egor d7aab2c3da Update Dockerfile 2025-10-15 06:48:18 +03:00
Egor f59e04b231 Update docker-registry.yml 2025-10-15 06:48:05 +03:00
Egor a2ac04a57a Update docker-hub.yml 2025-10-15 06:47:51 +03:00
Egor 4526df2342 Merge pull request #1352 from Fr1ngg/dev4
Dev4
2025-10-15 06:34:29 +03:00
Egor d0c65f201f Merge pull request #1351 from Fr1ngg/37rzsp-bedolaga/fix-top-up-balance-functionality
Fix Telegram Stars payment state and add missing YooKassa SBP locales
2025-10-15 06:30:26 +03:00
Egor 4fd736ecbf Fix Telegram Stars state import and add YooKassa SBP locale keys 2025-10-15 06:30:01 +03:00
Egor 351700f886 Merge pull request #1350 from Fr1ngg/dev4
Автосинк с ремной при старте + по таймеру
2025-10-15 06:21:50 +03:00
Egor 453d1dbf92 Merge pull request #1349 from Fr1ngg/lnnftq-bedolaga/add-auto-sync-feature-for-remnawave
Persist RemnaWave auto-sync schedule updates
2025-10-15 06:15:55 +03:00
Egor 0f358a9691 Persist RemnaWave auto-sync schedule updates 2025-10-15 06:10:44 +03:00
Egor ab890ea958 Merge pull request #1347 from Fr1ngg/revert-1346-r66y4z-bedolaga/add-auto-sync-feature-for-remnawave
Revert "Fix RemnaWave auto-sync callback handling"
2025-10-15 05:57:45 +03:00
Egor e0b0498f81 Revert "Fix RemnaWave auto-sync callback handling" 2025-10-15 05:57:34 +03:00
Egor af8dc119a5 Merge pull request #1346 from Fr1ngg/r66y4z-bedolaga/add-auto-sync-feature-for-remnawave
Fix RemnaWave auto-sync callback handling
2025-10-15 05:47:19 +03:00
Egor 7ad98ceebb Fix manual auto-sync callback timing 2025-10-15 05:42:27 +03:00
Egor 004aad4f06 Merge pull request #1345 from Fr1ngg/revert-1344-f5x8i3-bedolaga/add-auto-sync-feature-for-remnawave
Revert "Add coverage for RemnaWave auto-sync schedule helpers"
2025-10-15 05:41:27 +03:00
Egor ec5524d74f Revert "Add coverage for RemnaWave auto-sync schedule helpers" 2025-10-15 05:41:18 +03:00
Egor c1211ee025 Merge pull request #1344 from Fr1ngg/f5x8i3-bedolaga/add-auto-sync-feature-for-remnawave
Add coverage for RemnaWave auto-sync schedule helpers
2025-10-15 05:35:07 +03:00
Egor bf6528128f Add tests for RemnaWave auto-sync scheduling helpers 2025-10-15 05:34:02 +03:00
Egor 34c22b8955 Merge pull request #1343 from Fr1ngg/lpha75-bedolaga/-wata
Add localized labels for Wata payment method in mini app
2025-10-15 05:09:17 +03:00
Egor d48488338e Add localization for Wata top-up method 2025-10-15 05:09:01 +03:00
Egor a37fd89d8b Merge pull request #1342 from Fr1ngg/dev4
Wata
2025-10-15 04:58:19 +03:00
Egor 4553b1e038 Merge pull request #1341 from Fr1ngg/lej6x5-bedolaga/fix-wata-migration-error
Deduplicate Wata payment links before enforcing unique index
2025-10-15 04:51:34 +03:00
Egor 8c39f5aecf Deduplicate Wata payment links before enforcing unique index 2025-10-15 04:51:05 +03:00
Egor ce5ceb2f89 Merge pull request #1340 from Fr1ngg/revert-1339-32ftif-bedolaga/fix-wata-migration-error
Revert "Harden Wata payment link constraints"
2025-10-15 04:50:07 +03:00
Egor 8ca7750d25 Revert "Harden Wata payment link constraints" 2025-10-15 04:49:58 +03:00
Egor 1045cf42f9 Merge pull request #1339 from Fr1ngg/32ftif-bedolaga/fix-wata-migration-error
Harden Wata payment link constraints
2025-10-15 04:34:25 +03:00
Egor 427a8594d5 Harden Wata payment link constraints 2025-10-15 04:32:56 +03:00
Egor e007e8142a Merge pull request #1338 from Fr1ngg/revert-1337-c2jl2y-bedolaga/fix-wata-migration-error
Revert "Fix Wata auto-migration by ensuring required columns exist"
2025-10-15 04:32:31 +03:00
Egor 55e47e8e00 Revert "Fix Wata auto-migration by ensuring required columns exist" 2025-10-15 04:31:09 +03:00
Egor f84c54da03 Merge pull request #1337 from Fr1ngg/c2jl2y-bedolaga/fix-wata-migration-error
Fix Wata auto-migration by ensuring required columns exist
2025-10-15 04:21:05 +03:00
Egor 9e5279454c Fix Wata schema migration to add missing columns 2025-10-15 04:20:28 +03:00
Egor e935d13ccd Merge pull request #1336 from Fr1ngg/revert-1335-gr6o9l-bedolaga/modularize-miniapp-routes-by-functionality
Revert "Restore miniapp helpers and fix mulenpay status"
2025-10-15 04:15:33 +03:00
Egor 2c17ce5c3a Revert "Restore miniapp helpers and fix mulenpay status" 2025-10-15 04:15:24 +03:00
Egor 947ad48acd Merge pull request #1335 from Fr1ngg/gr6o9l-bedolaga/modularize-miniapp-routes-by-functionality
Restore miniapp helpers and fix mulenpay status
2025-10-15 04:13:48 +03:00
Egor aefae5818e Restore miniapp helpers and fix mulenpay status 2025-10-15 04:13:27 +03:00
Egor 47191342f6 Merge pull request #1334 from Fr1ngg/revert-1333-a5pqeb-bedolaga/modularize-miniapp-routes-by-functionality
Revert "Refine miniapp module exports"
2025-10-15 04:12:47 +03:00
Egor 0c841cbf86 Revert "Refine miniapp module exports" 2025-10-15 04:12:39 +03:00
Egor 8ef4c74b4a Merge pull request #1333 from Fr1ngg/a5pqeb-bedolaga/modularize-miniapp-routes-by-functionality
Refine miniapp module exports
2025-10-15 03:55:47 +03:00
Egor feb5e92fc2 Refine miniapp module exports 2025-10-15 03:54:04 +03:00
Egor 27338809c1 Merge pull request #1331 from Fr1ngg/uzp44y-bedolaga/refactor-bot-configuration-settings
Reassign miscellaneous bot settings to proper categories
2025-10-15 02:52:59 +03:00
Egor 53231ca074 Organize other settings into dedicated menu groups 2025-10-15 02:52:35 +03:00
Egor 1c177660c0 Merge pull request #1330 from Fr1ngg/bl2afh-bedolaga/update-bot-menu-configuration
Revamp bot configuration menu structure
2025-10-15 02:40:04 +03:00
Egor 041f1d09d7 Revamp bot configuration menu structure 2025-10-15 02:39:13 +03:00
Egor 8e13f644b0 Merge pull request #1329 from Fr1ngg/revert-1328-6k8mnf-bedolaga/update-bot-menu-configuration
Revert "refactor: regroup config categories"
2025-10-15 02:38:50 +03:00
Egor fc0b7591f9 Revert "refactor: regroup config categories" 2025-10-15 02:38:40 +03:00
Egor 308c645096 Merge pull request #1328 from Fr1ngg/6k8mnf-bedolaga/update-bot-menu-configuration
refactor: regroup config categories
2025-10-15 02:37:00 +03:00
Egor fd41d22322 refactor: regroup config categories 2025-10-15 02:36:11 +03:00
Egor 694d85ec10 Merge pull request #1327 from Fr1ngg/revert-1326-ogoewn-bedolaga/update-bot-menu-configuration
Revert "fix: group WATA payments in the admin menu"
2025-10-15 02:35:45 +03:00
Egor 790ffecade Revert "fix: group WATA payments in the admin menu" 2025-10-15 02:35:37 +03:00
Egor 5c5cc42af7 Merge pull request #1326 from Fr1ngg/ogoewn-bedolaga/update-bot-menu-configuration
fix: group WATA payments in the admin menu
2025-10-15 02:33:46 +03:00
Egor 0784f56f7c fix: organize payment settings and add wata test 2025-10-15 02:33:17 +03:00
Egor 775da62e00 Merge pull request #1325 from Fr1ngg/x280im-bedolaga/verify-wata-payment-method-integration
Add WATA payment support to miniapp API
2025-10-15 01:59:21 +03:00
Egor 436efcdd3e Add WATA payment support to miniapp API and tests 2025-10-15 01:50:34 +03:00
Egor 086660ebef Merge pull request #1324 from Fr1ngg/du2spj-bedolaga/fix-wata-payment-finalization-logic
Handle WATA Paid status during polling
2025-10-15 01:29:49 +03:00
Egor c546d10d93 Handle WATA Paid status during polling 2025-10-15 01:23:50 +03:00
Egor 169bdae0d1 Merge pull request #1323 from Fr1ngg/q1598f-bedolaga/add-new-balance-topping-method-to-bot
Fix missing logging import in WATA CRUD helpers
2025-10-15 01:21:28 +03:00
Egor 56e0ec4cd2 Fix logging import in WATA CRUD module 2025-10-15 01:14:43 +03:00
Egor f62e2794b2 Merge pull request #1322 from Fr1ngg/main
w
2025-10-15 00:43:41 +03:00
Egor f19db8f184 Merge pull request #1320 from Gy9vin/main
Фиксы ошибок запуска
2025-10-15 00:36:24 +03:00
Egor 41d4c9b507 Merge pull request #1321 from Fr1ngg/revert-1319-revert-1318-main
Revert "Revert "да все до кучи""
2025-10-15 00:36:11 +03:00
Egor 73e4abb29f Revert "Revert "да все до кучи"" 2025-10-15 00:35:58 +03:00
gy9vin f7cd8c9fc3 Фиксы ошибок запуска 2025-10-14 23:03:21 +03:00
Egor 6497f52a11 Merge pull request #1319 from Fr1ngg/revert-1318-main
Revert "да все до кучи"
2025-10-14 21:39:30 +03:00
Egor a4de6a8330 Revert "да все до кучи" 2025-10-14 21:39:20 +03:00
Egor d27e625ee6 Merge pull request #1318 from Gy9vin/main
да все до кучи
2025-10-14 21:35:11 +03:00
gy9vin e7a34152fc удаление бэкапов 2025-10-14 21:30:29 +03:00
Egor e332fc41fc Delete app/handlers/balance.py.backup 2025-10-14 21:20:29 +03:00
Egor 6bf0ac7fbf Delete app/handlers/balance.py.backup2 2025-10-14 21:19:27 +03:00
Egor 0effaa5240 Merge pull request #1317 from Fr1ngg/revert-1315-revert-1314-revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Revert "Revert "Complete Wata Pay integration"""
2025-10-14 21:18:03 +03:00
Egor 89b0544f86 Revert "Revert "Revert "Complete Wata Pay integration""" 2025-10-14 21:17:54 +03:00
Egor b1626d32cd Merge pull request #1315 from Fr1ngg/revert-1314-revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Revert "Complete Wata Pay integration""
2025-10-14 21:00:16 +03:00
gy9vin 8cdb4289c9 Модульность balance 2025-10-14 21:00:08 +03:00
gy9vin d4e3adddfc pal24 фиксы с кнопками СБП и карта 2025-10-14 20:21:52 +03:00
gy9vin 1baeb50322 кнопка назад при ручной проверки статуса платежа на палыче 2025-10-14 19:55:24 +03:00
Egor 7ed635600e Revert "Revert "Complete Wata Pay integration"" 2025-10-14 19:14:28 +03:00
Egor cb81fae3d8 Merge pull request #1311 from Fr1ngg/main
w
2025-10-14 19:13:49 +03:00
Egor 93cb99d50c Merge pull request #1314 from Fr1ngg/revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Complete Wata Pay integration"
2025-10-14 19:13:23 +03:00
Egor 5a4f59dfb2 Revert "Complete Wata Pay integration" 2025-10-14 19:13:11 +03:00
Egor 59f39e18a0 Merge pull request #1313 from Fr1ngg/nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Complete Wata Pay integration
2025-10-14 19:12:19 +03:00
Egor d3dd423042 Complete Wata Pay integration 2025-10-14 16:46:00 +03:00
Egor b90b270a9e Merge pull request #1306 from Gy9vin/main
Партнерка активация по env + убрано упоминание о vpn в русской локализации
2025-10-14 16:08:41 +03:00
gy9vin 7846b54a6f вернул докер 2025-10-14 11:16:19 +03:00
gy9vin 77083e8349 Фикс 2025-10-14 11:14:55 +03:00
gy9vin f121a5733b Прячем кнопки пополнения через env. оставляя только введения суммы вручную 2025-10-14 09:45:14 +03:00
Mikhail ad6bb95df7 Merge branch 'Fr1ngg:main' into main 2025-10-14 09:03:02 +03:00
gy9vin 58588e37d0 Реализация корзины пользователя! запоминает настройки подписки после пополнения баланса. 2025-10-14 09:02:16 +03:00
Egor 9717876fe7 Merge pull request #1307 from Fr1ngg/main
w
2025-10-14 00:55:29 +03:00
gy9vin badf44a9e7 Выпиливание упоминание про VPN из русской локализации 2025-10-13 23:02:25 +03:00
gy9vin a320de1cbb Скрытие кнопки партнерки через env 2025-10-13 23:00:06 +03:00
Egor aebfd8933a Update Dockerfile 2025-10-13 22:11:55 +03:00
Egor 482fb26d48 Update docker-registry.yml 2025-10-13 22:11:47 +03:00
Egor 8542473c6a Update docker-hub.yml 2025-10-13 22:11:33 +03:00
Egor 66e56c68cb Merge pull request #1305 from Fr1ngg/dev4
Рефактор subscription разбите на модули
2025-10-13 21:33:44 +03:00
Egor 135f3ccb92 Merge pull request #1304 from Fr1ngg/pfxfpr-bedolaga/refactor-subscription.py-for-modularity
Refactor subscription handlers into modular package
2025-10-13 21:24:58 +03:00
Egor d082e8a61c Refactor subscription handlers into modular package 2025-10-13 21:21:26 +03:00
Egor 23ad622fcd Merge pull request #1303 from Fr1ngg/dev4
фиксы + модульность платежек
2025-10-13 21:01:30 +03:00
Egor 9b639c3e1d Merge pull request #1302 from Fr1ngg/bedolaga-koykgg
Preserve advertising payload when subscription check blocks /start
2025-10-13 20:56:10 +03:00
Egor 75e9b8e27d Fix campaign tracking for users subscribing before /start 2025-10-13 20:51:25 +03:00
Egor 893736b240 Merge pull request #1301 from Fr1ngg/ozlu2i-bedolaga/fix-notification-for-balance-top-up
Handle MissingGreenlet when sending payment success notifications
2025-10-13 17:53:51 +03:00
Egor f5403802cc Fix payment success notification missing greenlet error 2025-10-13 17:26:51 +03:00
Egor 1c2325ca70 Merge pull request #1300 from Fr1ngg/isg6i0-bedolaga/fix-subscription-activation-after-promo-code
Fix reactivation of subscriptions extended via promo codes
2025-10-13 16:53:21 +03:00
Egor e032369110 Reactivate subscriptions when extending from disabled 2025-10-13 16:52:58 +03:00
Egor 9435a82add Merge pull request #1298 from Fr1ngg/bedolaga-8s2c1e
Handle miniapp YooKassa SBP payment status
2025-10-13 16:28:26 +03:00
Egor 7478167065 Handle miniapp YooKassa SBP payment status 2025-10-13 16:28:04 +03:00
Egor de5206ccd0 Merge pull request #1294 from Gy9vin/main
Покрытие кода тестами!
2025-10-13 16:14:18 +03:00
Egor 9d24522fab Merge pull request #1297 from Fr1ngg/main
w
2025-10-13 16:13:48 +03:00
dependabot[bot] 7be8757964 deps(deps): bump aiofiles from 23.2.1 to 25.1.0
Bumps [aiofiles](https://github.com/Tinche/aiofiles) from 23.2.1 to 25.1.0.
- [Release notes](https://github.com/Tinche/aiofiles/releases)
- [Changelog](https://github.com/Tinche/aiofiles/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Tinche/aiofiles/compare/v23.2.1...v25.1.0)

---
updated-dependencies:
- dependency-name: aiofiles
  dependency-version: 25.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 07:22:49 +00:00
gy9vin dec3aefdec Модульная структура платежки 2025-10-12 16:14:00 +03:00
gy9vin 30645d633e Исправление уведомления пользователя о поступлении денег на счет 2025-10-12 14:20:54 +03:00
gy9vin 2dcf3ef087 Покрытие кода тестами! 2025-10-12 10:13:32 +03:00
Egor 6c4aa8246c Update Dockerfile 2025-10-12 07:33:07 +03:00
Egor b08be14c6a Update docker-registry.yml 2025-10-12 07:32:48 +03:00
Egor 14d4166ac9 Update docker-hub.yml 2025-10-12 07:32:32 +03:00
Egor c2e91265b8 Merge pull request #1293 from Fr1ngg/dev4
Фикс кнопок в рассылке для режима MAIN_MENU_MODE=text
2025-10-12 07:19:27 +03:00
Egor edd2809f73 Merge pull request #1292 from Fr1ngg/8xskxx-bedolaga/fix-miniapp-access-in-main_menu_mode=text
Fix broadcast buttons opening miniapp in text menu mode
2025-10-12 07:15:58 +03:00
Egor b657b9e95c Fix broadcast buttons opening miniapp in text menu mode 2025-10-12 07:15:37 +03:00
Egor d112d769b7 Merge pull request #1291 from Fr1ngg/95i1mf-bedolaga/update-miniapp/index.html-for-accurate-traffic-data
Ensure mini app shows fresh subscription usage
2025-10-12 07:07:01 +03:00
Egor 28d26cee17 Sync subscription traffic when opening mini app 2025-10-12 07:06:33 +03:00
Egor e7115461db Merge pull request #1290 from Fr1ngg/dev4
Новый режим работы бота через ЛК MiniApp
2025-10-12 06:54:04 +03:00
Egor c8205bd882 Update README.md 2025-10-12 06:53:02 +03:00
Egor 1314d1c5cb Update .env.example 2025-10-12 06:52:25 +03:00
Egor 138a8920d9 Merge pull request #1289 from Fr1ngg/sdanmv-bedolaga/add-text-only-menu-mode
Route subscription reminders to miniapp in text mode
2025-10-12 06:41:26 +03:00
Egor ac3890598f Route subscription reminders to miniapp in text mode 2025-10-12 06:40:31 +03:00
Egor 2784920fbb Merge pull request #1288 from Fr1ngg/revert-1287-2jiako-bedolaga/add-text-only-menu-mode
Revert "Route promo offer CTA buttons to miniapp in text mode"
2025-10-12 06:20:01 +03:00
Egor 81e5731f34 Revert "Route promo offer CTA buttons to miniapp in text mode" 2025-10-12 06:19:53 +03:00
Egor d637f461f9 Merge pull request #1287 from Fr1ngg/2jiako-bedolaga/add-text-only-menu-mode
Route promo offer CTA buttons to miniapp in text mode
2025-10-12 05:49:54 +03:00
Egor 7fbe5a2bd8 Route promo offer CTAs to miniapp in text mode 2025-10-12 05:47:28 +03:00
Egor 9819897dbb Merge pull request #1286 from Fr1ngg/revert-1285-kluzxc-bedolaga/add-text-only-menu-mode
Revert "Route notification CTAs to miniapp in text mode"
2025-10-12 05:47:16 +03:00
Egor d20f264b59 Revert "Route notification CTAs to miniapp in text mode" 2025-10-12 05:47:08 +03:00
Egor 3d4e8b4d1b Merge pull request #1285 from Fr1ngg/kluzxc-bedolaga/add-text-only-menu-mode
Route notification CTAs to miniapp in text mode
2025-10-12 05:32:33 +03:00
Egor 253df6a60a Route notification CTAs to miniapp in text mode 2025-10-12 05:32:15 +03:00
Egor fae50b2155 Merge pull request #1284 from Fr1ngg/revert-1283-fwlbh2-bedolaga/add-text-only-menu-mode
Revert "Add text-only main menu mode with miniapp profile shortcut"
2025-10-12 05:31:56 +03:00
Egor 0df5fe9844 Revert "Add text-only main menu mode with miniapp profile shortcut" 2025-10-12 05:31:40 +03:00
Egor 522146c054 Merge pull request #1283 from Fr1ngg/fwlbh2-bedolaga/add-text-only-menu-mode
Add text-only main menu mode with miniapp profile shortcut
2025-10-12 05:05:08 +03:00
Egor 9a3301bc38 Add text-only main menu mode with miniapp profile button 2025-10-12 05:02:47 +03:00
Egor c169b8e69a Merge pull request #1282 from Fr1ngg/dev4
Обновление миниапки под актуальный шаблон app-config из ремны + режима guide в боте
2025-10-12 04:47:37 +03:00
Egor dbc9cbbb39 Merge pull request #1281 from Fr1ngg/main
w
2025-10-12 04:45:35 +03:00
Egor ec768d68ef Merge pull request #1280 from Fr1ngg/fozp9n-bedolaga/update-guide-mode-for-app-config.json
Add app selection list to connection guide
2025-10-12 04:32:03 +03:00
Egor 254255fe94 Add Apple TV option to connection guide 2025-10-12 04:31:49 +03:00
Egor c1aaa3acb0 Merge pull request #1279 from Fr1ngg/revert-1277-rsxc5b-bedolaga/update-guide-mode-for-app-config.json
Revert "Add app selection list to connection guide"
2025-10-12 04:30:44 +03:00
Egor 4c2262e0b0 Revert "Add app selection list to connection guide" 2025-10-12 04:30:36 +03:00
Egor 9c4445505b Merge pull request #1278 from Fr1ngg/i89vp3-bedolaga/move-subscription-button-to-instructions
Move connect button into subscription step
2025-10-12 04:28:54 +03:00
Egor b47944ef7b Move connect button into subscription step 2025-10-12 04:28:39 +03:00
Egor 2b268a0e06 Merge pull request #1277 from Fr1ngg/rsxc5b-bedolaga/update-guide-mode-for-app-config.json
Add app selection list to connection guide
2025-10-12 04:23:04 +03:00
Egor 3ba9bddfc5 Add multi-app selection to connection guide 2025-10-12 04:22:47 +03:00
Egor e19f18931f Merge pull request #1276 from Fr1ngg/revert-1275-ht68uj-bedolaga/update-guide-mode-for-app-config.json
Revert "Adapt guide mode to new app config and connect links"
2025-10-12 04:22:37 +03:00
Egor b8cbfc6065 Revert "Adapt guide mode to new app config and connect links" 2025-10-12 04:22:29 +03:00
Egor e7714dc33d Merge pull request #1275 from Fr1ngg/ht68uj-bedolaga/update-guide-mode-for-app-config.json
Adapt guide mode to new app config and connect links
2025-10-12 04:11:03 +03:00
Egor 847d04fa42 Adapt guide mode to new app config and connect links 2025-10-12 04:10:46 +03:00
Egor 164b4f4e33 Merge pull request #1274 from Fr1ngg/owmhgs-bedolaga/update-redirect-link-in-miniapp/index.html
Use configured Happ redirect template in miniapp
2025-10-12 03:09:40 +03:00
Egor e1b7af2990 Use configured Happ redirect template in miniapp 2025-10-12 03:09:25 +03:00
Egor da74c7841d Merge pull request #1273 from Fr1ngg/revert-1272-rkb5qf-bedolaga/update-redirect-link-in-miniapp/index.html
Revert "Use configured Happ redirect template fallback"
2025-10-12 03:08:35 +03:00
Egor 68d83be87a Revert "Use configured Happ redirect template fallback" 2025-10-12 03:08:28 +03:00
Egor 57f820dc35 Merge pull request #1272 from Fr1ngg/rkb5qf-bedolaga/update-redirect-link-in-miniapp/index.html
Use configured Happ redirect template fallback
2025-10-12 03:05:30 +03:00
Egor cd0aba5a6c Use configured Happ redirect template fallback 2025-10-12 03:05:11 +03:00
Egor a43e68846a Update app-config.json 2025-10-12 02:47:56 +03:00
Egor adadeb4b2a Update app-config.json 2025-10-12 02:47:46 +03:00
Egor ad81cff59a Update index.html 2025-10-12 02:45:16 +03:00
Egor bf0c1ce711 Update and rename bedolaga_app2.svg to bedolaga_app3.svg 2025-10-12 02:41:45 +03:00
Egor c71401cad4 Rename bedolaga_app.svg to bedolaga_app2.svg 2025-10-12 02:36:39 +03:00
Egor 81744d3de8 Update bedolaga_app.svg 2025-10-12 02:36:05 +03:00
Egor f49dbeaf3a Update bedolaga_app.svg 2025-10-12 02:35:21 +03:00
Egor 649bd32af3 Create bedolaga_app.svg 2025-10-12 02:18:37 +03:00
Egor 55c37d8860 Merge pull request #1271 from Fr1ngg/2dsocc-bedolaga/fix-scroll-issue-in-modal-instructions
Add platform filter controls to installation modal
2025-10-12 02:07:25 +03:00
Egor c953cc150d Add platform filter controls to installation modal 2025-10-12 02:07:06 +03:00
Egor 59a78f0823 Merge pull request #1270 from Fr1ngg/revert-1269-mbe8rg-bedolaga/fix-scroll-issue-in-modal-instructions
Revert "Fix installation modal truncation for PC app list"
2025-10-12 02:00:49 +03:00
Egor 8ac3d5e826 Revert "Fix installation modal truncation for PC app list" 2025-10-12 02:00:41 +03:00
Egor 744b7d532c Merge pull request #1269 from Fr1ngg/mbe8rg-bedolaga/fix-scroll-issue-in-modal-instructions
Fix installation modal truncation for PC app list
2025-10-12 01:59:05 +03:00
Egor 72ec8d565c Allow installation modal to show full PC app list 2025-10-12 01:58:31 +03:00
Egor 9350868830 Merge pull request #1268 from Fr1ngg/revert-1267-2fc8ey-bedolaga/fix-scroll-issue-in-modal-instructions
Revert "Improve installation modal scroll handling"
2025-10-12 01:58:20 +03:00
Egor effbede310 Revert "Improve installation modal scroll handling" 2025-10-12 01:58:12 +03:00
Egor 4b3be5725c Merge pull request #1267 from Fr1ngg/2fc8ey-bedolaga/fix-scroll-issue-in-modal-instructions
Improve installation modal scroll handling
2025-10-12 01:48:07 +03:00
Egor 22ed539b3a Fix installation modal scrolling 2025-10-12 01:47:50 +03:00
Egor fa46a5b96c Merge pull request #1265 from Fr1ngg/rz4sct-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop redirect to use scheme subscription URLs
2025-10-12 01:32:21 +03:00
Egor 63402474d2 Fix PC connect redirect handling 2025-10-12 01:32:06 +03:00
Egor 93db300dc7 Merge pull request #1264 from Fr1ngg/revert-1263-kdbq2j-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure PC connect links use redirect template scheme payloads"
2025-10-12 01:31:47 +03:00
Egor 76e29314b2 Revert "Ensure PC connect links use redirect template scheme payloads" 2025-10-12 01:31:37 +03:00
Egor 7e5c1d388e Merge pull request #1263 from Fr1ngg/kdbq2j-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure PC connect links use redirect template scheme payloads
2025-10-12 01:28:12 +03:00
Egor 6b14ab70c6 Fix PC connect links to honor selected platform 2025-10-12 01:27:54 +03:00
Egor 5e3cff20b1 Merge pull request #1262 from Fr1ngg/revert-1261-z7k7de-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix desktop redirect to use scheme subscription URLs"
2025-10-12 01:27:21 +03:00
Egor 34c1c0b806 Revert "Fix desktop redirect to use scheme subscription URLs" 2025-10-12 01:27:13 +03:00
Egor e69dca013c Merge pull request #1261 from Fr1ngg/z7k7de-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop redirect to use scheme subscription URLs
2025-10-12 01:06:23 +03:00
Egor d201e07385 Fix desktop redirect to use scheme subscription URLs 2025-10-12 01:06:07 +03:00
Egor 613e239691 Merge pull request #1260 from Fr1ngg/revert-1258-j5y403-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure desktop redirect targets prefer scheme links"
2025-10-12 01:05:51 +03:00
Egor b72e00de9d Revert "Ensure desktop redirect targets prefer scheme links" 2025-10-12 01:05:43 +03:00
Egor 0830461398 Merge pull request #1258 from Fr1ngg/j5y403-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure desktop redirect targets prefer scheme links
2025-10-12 01:01:28 +03:00
Egor 9abf0c1da9 Merge pull request #1259 from Fr1ngg/revert-1257-26yx6h-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure desktop redirects use scheme subscription URLs"
2025-10-12 01:01:11 +03:00
Egor 74148d4e7d Revert "Ensure desktop redirects use scheme subscription URLs" 2025-10-12 01:01:03 +03:00
Egor 8901cad885 Ensure desktop redirect targets prefer scheme links 2025-10-12 01:00:44 +03:00
Egor a800bd4e3c Merge pull request #1257 from Fr1ngg/26yx6h-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure desktop redirects use scheme subscription URLs
2025-10-12 00:50:50 +03:00
Egor 5c3c438fb7 Fix desktop redirect scheme target 2025-10-12 00:50:34 +03:00
Egor 65df7ff58a Merge pull request #1256 from Fr1ngg/revert-1255-qabbzf-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix desktop connect redirect target selection"
2025-10-12 00:50:14 +03:00
Egor 46855439e2 Revert "Fix desktop connect redirect target selection" 2025-10-12 00:49:37 +03:00
Egor 7b3b4a8fb3 Merge pull request #1255 from Fr1ngg/qabbzf-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop connect redirect target selection
2025-10-12 00:45:54 +03:00
Egor 8a90893b40 Fix desktop connect redirect target selection 2025-10-12 00:45:32 +03:00
Egor edf1d3dc29 Merge pull request #1254 from Fr1ngg/revert-1253-qt32vw-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Adjust desktop connect redirects to wrap scheme links"
2025-10-12 00:44:41 +03:00
Egor d5bf7f2749 Revert "Adjust desktop connect redirects to wrap scheme links" 2025-10-12 00:44:32 +03:00
Egor 5939d2d69a Merge pull request #1253 from Fr1ngg/qt32vw-bedolaga/update-miniapp/index.html-for-new-json-format
Adjust desktop connect redirects to wrap scheme links
2025-10-12 00:42:56 +03:00
Egor 90a2fd9fdc Fix PC redirect scheme targeting 2025-10-12 00:42:35 +03:00
Egor 841b183a59 Merge pull request #1252 from Fr1ngg/revert-1251-revert-1248-revert-1247-4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Revert "Revert "Fix desktop connect redirects for scheme subscriptions"""
2025-10-12 00:39:38 +03:00
Egor 9a73621ff4 Revert "Revert "Revert "Fix desktop connect redirects for scheme subscriptions""" 2025-10-12 00:39:29 +03:00
Egor 71e8ceace9 Merge pull request #1251 from Fr1ngg/revert-1248-revert-1247-4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Revert "Fix desktop connect redirects for scheme subscriptions""
2025-10-12 00:27:47 +03:00
Egor 4e8e11fc7b Revert "Revert "Fix desktop connect redirects for scheme subscriptions"" 2025-10-12 00:27:38 +03:00
Egor b3a1a8ddb8 Merge pull request #1250 from Fr1ngg/revert-1249-lhhq28-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Prioritize scheme-based redirect targets on desktop"
2025-10-12 00:27:18 +03:00
Egor 029d162e44 Revert "Prioritize scheme-based redirect targets on desktop" 2025-10-12 00:27:10 +03:00
Egor 86ab3e544d Merge pull request #1249 from Fr1ngg/lhhq28-bedolaga/update-miniapp/index.html-for-new-json-format
Prioritize scheme-based redirect targets on desktop
2025-10-12 00:25:37 +03:00
Egor 587ca35049 Fix desktop redirect target selection 2025-10-12 00:25:20 +03:00
Egor 02c68e5983 Merge pull request #1248 from Fr1ngg/revert-1247-4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix desktop connect redirects for scheme subscriptions"
2025-10-12 00:24:39 +03:00
Egor 9ddc6b8f0c Revert "Fix desktop connect redirects for scheme subscriptions" 2025-10-12 00:24:29 +03:00
Egor eb69b166fe Merge pull request #1247 from Fr1ngg/4egmuh-bedolaga/update-miniapp/index.html-for-new-json-format
Fix desktop connect redirects for scheme subscriptions
2025-10-12 00:22:45 +03:00
Egor 0ea6476834 Fix desktop connect redirects for non-Happ apps 2025-10-12 00:22:28 +03:00
Egor 6994735b0c Merge pull request #1246 from Fr1ngg/revert-1245-wklyjo-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Adjust desktop connect redirects to wrap scheme links"
2025-10-12 00:22:09 +03:00
Egor f504322afd Revert "Adjust desktop connect redirects to wrap scheme links" 2025-10-12 00:22:02 +03:00
Egor a60ff3bdb1 Merge pull request #1245 from Fr1ngg/wklyjo-bedolaga/update-miniapp/index.html-for-new-json-format
Adjust desktop connect redirects to wrap scheme links
2025-10-12 00:20:25 +03:00
Egor 1eec3b71f6 Adjust PC redirect targets for connect links 2025-10-12 00:20:04 +03:00
Egor 31945067c9 Merge pull request #1244 from Fr1ngg/revert-1243-gh1a1w-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Refine desktop connect redirects to use subscription URLs"
2025-10-12 00:19:29 +03:00
Egor fe0da51909 Revert "Refine desktop connect redirects to use subscription URLs" 2025-10-12 00:19:19 +03:00
Egor 9ddf7891aa Merge pull request #1243 from Fr1ngg/gh1a1w-bedolaga/update-miniapp/index.html-for-new-json-format
Refine desktop connect redirects to use subscription URLs
2025-10-12 00:03:50 +03:00
Egor ce4eb3341d Adjust connect link priorities for desktop 2025-10-12 00:03:32 +03:00
Egor f8e68de2bb Merge pull request #1242 from Fr1ngg/revert-1241-4g4jc1-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Adjust connect button links for PC redirect handling"
2025-10-12 00:03:07 +03:00
Egor 6613de548c Revert "Adjust connect button links for PC redirect handling" 2025-10-12 00:02:58 +03:00
Egor 1d3f02d26a Merge pull request #1241 from Fr1ngg/4g4jc1-bedolaga/update-miniapp/index.html-for-new-json-format
Adjust connect button links for PC redirect handling
2025-10-11 23:46:46 +03:00
Egor 77d571980f Refine connect link routing for device-specific flows 2025-10-11 23:46:30 +03:00
Egor db79c538f0 Merge pull request #1240 from Fr1ngg/revert-1239-yooawl-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Ensure desktop connect buttons use Happ redirect"
2025-10-11 23:46:10 +03:00
Egor c066b9929d Revert "Ensure desktop connect buttons use Happ redirect" 2025-10-11 23:46:01 +03:00
Egor ac39a22f61 Merge pull request #1239 from Fr1ngg/yooawl-bedolaga/update-miniapp/index.html-for-new-json-format
Ensure desktop connect buttons use Happ redirect
2025-10-11 23:42:48 +03:00
Egor 814617bd64 Adjust miniapp connect links for device redirects 2025-10-11 23:42:21 +03:00
Egor ba57848b60 Merge pull request #1238 from Fr1ngg/revert-1237-qau9xs-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Enhance installation modal for new config format"
2025-10-11 23:31:27 +03:00
Egor 269cbe6b2c Revert "Enhance installation modal for new config format" 2025-10-11 23:31:19 +03:00
Egor 07d3eff9a0 Merge pull request #1237 from Fr1ngg/qau9xs-bedolaga/update-miniapp/index.html-for-new-json-format
Enhance installation modal for new config format
2025-10-11 23:28:05 +03:00
Egor 4e63a99197 Fix connect links for Happ and platform schemes 2025-10-11 23:27:49 +03:00
Egor fe36737406 Merge pull request #1236 from Fr1ngg/revert-1235-kq2y5m-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Fix Happ connect link handling"
2025-10-11 23:27:12 +03:00
Egor 48917e47db Revert "Fix Happ connect link handling" 2025-10-11 23:27:04 +03:00
Egor 6f348b00d3 Merge pull request #1235 from Fr1ngg/kq2y5m-bedolaga/update-miniapp/index.html-for-new-json-format
Fix Happ connect link handling
2025-10-11 23:21:14 +03:00
Egor ea9da58e35 Fix Happ connect link handling 2025-10-11 23:20:53 +03:00
Egor 036e4d275d Merge pull request #1234 from Fr1ngg/revert-1233-8a5ts1-bedolaga/update-miniapp/index.html-for-new-json-format
Revert "Enhance installation modal for new config format"
2025-10-11 23:20:38 +03:00
Egor cff14989ae Revert "Enhance installation modal for new config format" 2025-10-11 23:20:29 +03:00
Egor 10da004e5d Merge pull request #1233 from Fr1ngg/8a5ts1-bedolaga/update-miniapp/index.html-for-new-json-format
Enhance installation modal for new config format
2025-10-11 21:13:36 +03:00
Egor ed0f62542f Enhance installation modal for new config format 2025-10-11 21:13:17 +03:00
Egor cc2b7c2036 Update app-config.json 2025-10-11 20:34:26 +03:00
Egor d3a87d62b4 Update app-config.json 2025-10-11 20:34:14 +03:00
Egor ef70f176dd Update Dockerfile 2025-10-11 09:06:35 +03:00
Egor 7a111bae6c Update docker-registry.yml 2025-10-11 09:06:18 +03:00
Egor 3132b32545 Update docker-hub.yml 2025-10-11 09:06:07 +03:00
Egor 10da3595b9 Merge pull request #1231 from Fr1ngg/dev4
Личный кабинет в миниапке
2025-10-11 08:47:20 +03:00
Egor f0f3f3508f Merge pull request #1230 from Fr1ngg/jb1s1w-bedolaga/fix-admin-notifications-for-miniapp-index
Add admin notifications to miniapp subscription flows
2025-10-11 08:39:43 +03:00
Egor 60b85911e1 Add admin notifications to miniapp subscription flows 2025-10-11 08:39:19 +03:00
Egor e78d75a440 Merge pull request #1229 from Fr1ngg/r8bqjd-bedolaga/fix-admin-notification-error
Fix admin topup notifications without DB session
2025-10-11 08:25:27 +03:00
Egor de57db14ea Delay first-topup flag until after referral processing 2025-10-11 08:25:11 +03:00
Egor b85d5083a9 Merge pull request #1228 from Fr1ngg/revert-1227-50t4gr-bedolaga/fix-admin-notification-error
Revert "Fix admin topup notifications without DB session"
2025-10-11 08:24:58 +03:00
Egor 862fcc53c4 Revert "Fix admin topup notifications without DB session" 2025-10-11 08:24:50 +03:00
Egor 42f1757508 Merge pull request #1227 from Fr1ngg/50t4gr-bedolaga/fix-admin-notification-error
Fix admin topup notifications without DB session
2025-10-11 08:02:43 +03:00
Egor 50279979ce Fix admin topup notifications without DB session 2025-10-11 08:02:25 +03:00
Egor ec00fe12a0 Merge pull request #1226 from Fr1ngg/0y4863-bedolaga/fix-mobile-modal-to-cover-full-screen
Make installation modal full screen on mobile
2025-10-11 07:46:06 +03:00
Egor 0806ed9f1e Make installation modal full screen on mobile 2025-10-11 07:45:53 +03:00
Egor 11b886d6ca Merge pull request #1225 from Fr1ngg/revert-1224-g7l4tv-bedolaga/fix-mobile-modal-to-cover-full-screen
Revert "Make subscription modal full-screen on mobile"
2025-10-11 07:45:23 +03:00
Egor 291695395f Revert "Make subscription modal full-screen on mobile" 2025-10-11 07:45:15 +03:00
Egor f787dfef04 Merge pull request #1224 from Fr1ngg/g7l4tv-bedolaga/fix-mobile-modal-to-cover-full-screen
Make subscription modal full-screen on mobile
2025-10-11 07:38:17 +03:00
Egor edc6b766cb Adjust mobile modal to full-screen 2025-10-11 07:38:03 +03:00
Egor da2d2c826a Merge pull request #1223 from Fr1ngg/revert-1222-ar94nr-bedolaga/fix-mobile-modal-to-cover-full-screen
Revert "Adjust subscription modal layout on mobile"
2025-10-11 07:37:44 +03:00
Egor 0ad1f58fd4 Revert "Adjust subscription modal layout on mobile" 2025-10-11 07:37:37 +03:00
Egor 9f398dbbe5 Merge pull request #1222 from Fr1ngg/ar94nr-bedolaga/fix-mobile-modal-to-cover-full-screen
Adjust subscription modal layout on mobile
2025-10-11 07:34:21 +03:00
Egor 3a33928c9e Adjust subscription modal layout on mobile 2025-10-11 07:31:34 +03:00
Egor cf77b38fe7 Merge pull request #1221 from Fr1ngg/ql23rs-bedolaga/fix-subscription-block-height-for-mobile
Adjust mini app subscription card height for promo discounts
2025-10-11 07:12:47 +03:00
Egor 3ab2dc0484 Adjust purchase card layout for promo discounts 2025-10-11 07:12:34 +03:00
Egor 27b5f70712 Merge pull request #1220 from Fr1ngg/revert-1219-dhoqjs-bedolaga/fix-layout-for-subscription-block-on-mobile
Revert "Adjust mobile spacing in mini app subscription purchase section"
2025-10-11 07:02:07 +03:00
Egor 05e47e3a1c Revert "Adjust mobile spacing in mini app subscription purchase section" 2025-10-11 07:01:59 +03:00
Egor e85a6dd9e0 Merge pull request #1219 from Fr1ngg/dhoqjs-bedolaga/fix-layout-for-subscription-block-on-mobile
Adjust mobile spacing in mini app subscription purchase section
2025-10-11 06:59:43 +03:00
Egor 595c7ce5bb Adjust mobile spacing for subscription actions 2025-10-11 06:59:27 +03:00
Egor 46e3433b55 Merge pull request #1218 from Fr1ngg/revert-1217-fm92lt-bedolaga/fix-layout-for-subscription-block-on-mobile
Revert "Fix subscription action spacing on narrow screens"
2025-10-11 06:58:55 +03:00
Egor f7577c1acc Revert "Fix subscription action spacing on narrow screens" 2025-10-11 06:58:46 +03:00
Egor 2e57522a58 Merge pull request #1217 from Fr1ngg/fm92lt-bedolaga/fix-layout-for-subscription-block-on-mobile
Fix subscription action spacing on narrow screens
2025-10-11 06:57:08 +03:00
Egor a3ca5725a6 Adjust purchase actions spacing on narrow screens 2025-10-11 06:56:53 +03:00
Egor 3a09240019 Merge pull request #1216 from Fr1ngg/revert-1215-uv8nkp-bedolaga/fix-layout-for-subscription-block-on-mobile
Revert "Adjust mobile spacing for subscription purchase actions"
2025-10-11 06:56:03 +03:00
Egor b3ad09f0a1 Revert "Adjust mobile spacing for subscription purchase actions" 2025-10-11 06:55:54 +03:00
Egor de515862f4 Merge pull request #1215 from Fr1ngg/uv8nkp-bedolaga/fix-layout-for-subscription-block-on-mobile
Adjust mobile spacing for subscription purchase actions
2025-10-11 06:54:18 +03:00
Egor c49e8d6c18 Adjust purchase summary spacing on small screens 2025-10-11 06:53:57 +03:00
Egor decafb1d0e Merge pull request #1214 from Fr1ngg/uw7hjl-bedolaga/update-subscription-block-design-on-miniapp/index.html
Align subscription period buttons with traffic cards
2025-10-11 06:44:09 +03:00
Egor 8f19a5e205 Align subscription period buttons with traffic cards 2025-10-11 06:43:52 +03:00
Egor fc75009e9b Merge pull request #1213 from Fr1ngg/aug6ak-bedolaga/redesign-subscription-section-for-mobile
Improve mobile subscription period layout and move copy link button
2025-10-11 06:20:15 +03:00
Egor c753f2f67e Improve subscription purchase mobile layout 2025-10-11 06:19:49 +03:00
Egor 5a260c00d0 Merge pull request #1212 from Fr1ngg/revert-1211-ddsp1p-bedolaga/redesign-subscription-section-for-mobile
Revert "Improve mobile subscription layout and relocate copy button"
2025-10-11 06:19:20 +03:00
Egor 6f690a672e Revert "Improve mobile subscription layout and relocate copy button" 2025-10-11 06:19:11 +03:00
Egor 3b2c560947 Merge pull request #1211 from Fr1ngg/ddsp1p-bedolaga/redesign-subscription-section-for-mobile
Improve mobile subscription layout and relocate copy button
2025-10-11 06:11:04 +03:00
Egor fb1641c5ba Improve mobile subscription layout and relocate copy button 2025-10-11 06:10:20 +03:00
Egor 91615ec4b9 Merge pull request #1210 from Fr1ngg/yh3i06-bedolaga/modify-auto-payment-feature-in-miniapp
Honor mini app autopay default day
2025-10-11 05:48:27 +03:00
Egor fe98d4391f Allow same-day autopay normalization 2025-10-11 05:48:04 +03:00
Egor 76f8ccdd85 Merge pull request #1209 from Fr1ngg/revert-1208-iyctz5-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Honor mini app autopay default day"
2025-10-11 05:47:44 +03:00
Egor 45bc3bc15c Revert "Honor mini app autopay default day" 2025-10-11 05:47:35 +03:00
Egor 0975fdea4d Merge pull request #1208 from Fr1ngg/iyctz5-bedolaga/modify-auto-payment-feature-in-miniapp
Honor mini app autopay default day
2025-10-11 05:34:45 +03:00
Egor 61bf48b7fa Honor mini app autopay default day 2025-10-11 05:34:05 +03:00
Egor 0ff4dd4321 Merge pull request #1207 from Fr1ngg/revert-1206-y71m00-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Ensure mini app autopay shows all day options"
2025-10-11 05:33:50 +03:00
Egor e0edc6bcdc Revert "Ensure mini app autopay shows all day options" 2025-10-11 05:33:42 +03:00
Egor 14e58421e4 Merge pull request #1206 from Fr1ngg/y71m00-bedolaga/modify-auto-payment-feature-in-miniapp
Ensure mini app autopay shows all day options
2025-10-11 05:19:16 +03:00
Egor b24ba2e75f Ensure mini app autopay shows all day options 2025-10-11 05:19:02 +03:00
Egor d9be872581 Merge pull request #1203 from Fr1ngg/revert-1202-1ji1v8-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Connect mini app autopay UI to backend"
2025-10-11 05:18:29 +03:00
Egor 30ab648784 Merge pull request #1205 from Fr1ngg/revert-1204-y7k7f9-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Fix autopay day parsing in mini app"
2025-10-11 05:18:09 +03:00
Egor b730de4175 Revert "Fix autopay day parsing in mini app" 2025-10-11 05:16:39 +03:00
Egor 6bfb484a9b Merge pull request #1204 from Fr1ngg/y7k7f9-bedolaga/modify-auto-payment-feature-in-miniapp
Fix autopay day parsing in mini app
2025-10-11 05:15:16 +03:00
Egor 0e6f6d0908 Handle autopay day labels when parsing options 2025-10-11 05:14:54 +03:00
Egor 90a495e942 Revert "Connect mini app autopay UI to backend" 2025-10-11 05:14:25 +03:00
Egor 0182e190a8 Merge pull request #1202 from Fr1ngg/1ji1v8-bedolaga/modify-auto-payment-feature-in-miniapp
Connect mini app autopay UI to backend
2025-10-11 05:05:57 +03:00
Egor 30274dce20 Connect mini app autopay UI to backend 2025-10-11 05:05:36 +03:00
Egor 0098cc724f Merge pull request #1201 from Fr1ngg/revert-1200-mt5flx-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Enable mini app autopay controls"
2025-10-11 05:05:23 +03:00
Egor d9cf4008fe Revert "Enable mini app autopay controls" 2025-10-11 05:05:15 +03:00
Egor ca83cf5349 Merge pull request #1200 from Fr1ngg/mt5flx-bedolaga/modify-auto-payment-feature-in-miniapp
Enable mini app autopay controls
2025-10-11 05:02:11 +03:00
Egor bf5e267822 Add mini app autopay endpoints and payloads 2025-10-11 05:01:55 +03:00
Egor b51a0c04bc Merge pull request #1199 from Fr1ngg/revert-1198-c5f5ag-bedolaga/modify-auto-payment-feature-in-miniapp
Revert "Revamp autopay settings UI in mini app"
2025-10-11 05:01:18 +03:00
Egor 42e19de19c Revert "Revamp autopay settings UI in mini app" 2025-10-11 05:01:09 +03:00
Egor 56c5e04c76 Merge pull request #1198 from Fr1ngg/c5f5ag-bedolaga/modify-auto-payment-feature-in-miniapp
Revamp autopay settings UI in mini app
2025-10-11 04:44:56 +03:00
Egor 556f9dbd45 Revamp mini-app autopay controls 2025-10-11 04:44:41 +03:00
Egor 562fe9dccb Merge pull request #1197 from Fr1ngg/revert-1196-qbc98z-bedolaga/redesign-autopayment-feature-in-miniapp/index.html
Revert "Add autopay toggle controls to miniapp UI"
2025-10-11 04:44:29 +03:00
Egor 960063fdca Revert "Add autopay toggle controls to miniapp UI" 2025-10-11 04:44:04 +03:00
Egor 4a4e7406c3 Merge pull request #1196 from Fr1ngg/qbc98z-bedolaga/redesign-autopayment-feature-in-miniapp/index.html
Add autopay toggle controls to miniapp UI
2025-10-11 04:39:33 +03:00
Egor fca88c5e08 feat: redesign autopay controls in miniapp 2025-10-11 04:39:13 +03:00
Egor 55dcb61798 Merge pull request #1195 from Fr1ngg/k66ujv-bedolaga/refactor-subscribe-button-in-miniapp/index.html
fix: restore connect fallback when instructions missing
2025-10-11 04:08:28 +03:00
Egor 3d9cc65336 fix: keep connect action without instructions 2025-10-11 04:08:08 +03:00
Egor 61c69e6827 Merge pull request #1194 from Fr1ngg/revert-1193-2s0o2x-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "fix: hide connect button for inactive users"
2025-10-11 04:07:30 +03:00
Egor 246c9d3935 Revert "fix: hide connect button for inactive users" 2025-10-11 04:07:21 +03:00
Egor 7f6b0f4190 Merge pull request #1193 from Fr1ngg/2s0o2x-bedolaga/refactor-subscribe-button-in-miniapp/index.html
fix: hide connect button for inactive users
2025-10-11 03:53:31 +03:00
Egor 9e495e9f64 fix: hide connect button for inactive users 2025-10-11 03:53:15 +03:00
Egor 58bc8e97ee Merge pull request #1192 from Fr1ngg/revert-1191-revert-1186-revert-1185-tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "Revert "Revert "feat: redesign miniapp connect flow"""
2025-10-11 03:52:43 +03:00
Egor f491a6bf00 Revert "Revert "Revert "feat: redesign miniapp connect flow""" 2025-10-11 03:51:48 +03:00
Egor 8abcd0707d Merge pull request #1191 from Fr1ngg/revert-1186-revert-1185-tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "Revert "feat: redesign miniapp connect flow""
2025-10-11 03:30:25 +03:00
Egor b4ac9cd2bc Revert "Revert "feat: redesign miniapp connect flow"" 2025-10-11 03:30:15 +03:00
Egor b18759b69f Merge pull request #1190 from Fr1ngg/revert-1187-revert-1184-ulw2na-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Hide connection lists until subscription is active""
2025-10-11 03:29:57 +03:00
Egor d327ad9379 Revert "Revert "Hide connection lists until subscription is active"" 2025-10-11 03:29:49 +03:00
Egor cfe86bd8d3 Merge pull request #1189 from Fr1ngg/revert-1188-84cuhz-bedolaga/update-miniapp/index.html-logic
Revert "Add floating connect button for installation guide"
2025-10-11 03:29:42 +03:00
Egor 5cb4479a70 Revert "Add floating connect button for installation guide" 2025-10-11 03:29:35 +03:00
Egor 64664fa30f Merge pull request #1188 from Fr1ngg/84cuhz-bedolaga/update-miniapp/index.html-logic
Add floating connect button for installation guide
2025-10-11 03:27:20 +03:00
Egor 15ed087142 Refine mini app connect entry point 2025-10-11 03:27:06 +03:00
Egor be6d358ed0 Merge pull request #1187 from Fr1ngg/revert-1184-ulw2na-bedolaga/update-miniapp/index.html-logic
Revert "Hide connection lists until subscription is active"
2025-10-11 03:26:20 +03:00
Egor dd77ecae9d Revert "Hide connection lists until subscription is active" 2025-10-11 03:26:12 +03:00
Egor edfb325ccd Merge pull request #1186 from Fr1ngg/revert-1185-tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
Revert "feat: redesign miniapp connect flow"
2025-10-11 03:25:40 +03:00
Egor a4a6685dbb Revert "feat: redesign miniapp connect flow" 2025-10-11 03:25:32 +03:00
Egor ae9d32ceb4 Merge pull request #1185 from Fr1ngg/tl4a1p-bedolaga/refactor-subscribe-button-in-miniapp/index.html
feat: redesign miniapp connect flow
2025-10-11 03:22:14 +03:00
Egor fa12448068 feat: redesign connect button and installation guide 2025-10-11 03:21:58 +03:00
Egor a72b7ce3c8 Merge pull request #1184 from Fr1ngg/ulw2na-bedolaga/update-miniapp/index.html-logic
Hide connection lists until subscription is active
2025-10-11 03:07:02 +03:00
Egor 1bf150f45a Hide connected resources without subscription 2025-10-11 03:06:43 +03:00
Egor e0633c409d Merge pull request #1183 from Fr1ngg/revert-1182-8soheo-bedolaga/update-miniapp/index.html-logic
Revert "Remove purchase action from missing subscription card"
2025-10-11 03:06:30 +03:00
Egor 2621b2f8be Revert "Remove purchase action from missing subscription card" 2025-10-11 03:06:22 +03:00
Egor ba1394c35f Merge pull request #1182 from Fr1ngg/8soheo-bedolaga/update-miniapp/index.html-logic
Remove purchase action from missing subscription card
2025-10-11 02:32:33 +03:00
Egor 1072b64af8 Remove duplicate purchase button from missing subscription card 2025-10-11 02:32:18 +03:00
Egor d3584d52e8 Merge pull request #1181 from Fr1ngg/revert-1180-m51uu7-bedolaga/update-miniapp/index.html-logic
Revert "Restore mini app trial activation and purchase flow"
2025-10-11 02:31:20 +03:00
Egor f32b9242ba Revert "Restore mini app trial activation and purchase flow" 2025-10-11 02:31:12 +03:00
Egor 516a1f3e52 Merge pull request #1180 from Fr1ngg/m51uu7-bedolaga/update-miniapp/index.html-logic
Restore mini app trial activation and purchase flow
2025-10-11 02:21:38 +03:00
Egor 57906b1df9 Fix mini app trial activation and purchase flow 2025-10-11 02:21:22 +03:00
Egor 83865dd0b9 Merge pull request #1179 from Fr1ngg/revert-1178-s7vtov-bedolaga/update-miniapp/index.html-logic
Revert "Fix mini app trial activation and purchase options"
2025-10-11 02:21:04 +03:00
Egor 69553d2551 Revert "Fix mini app trial activation and purchase options" 2025-10-11 02:20:55 +03:00
Egor 02a8b932a3 Merge pull request #1178 from Fr1ngg/s7vtov-bedolaga/update-miniapp/index.html-logic
Fix mini app trial activation and purchase options
2025-10-11 02:14:05 +03:00
Egor b27624021f Fix mini app trial activation and purchase options 2025-10-11 02:13:48 +03:00
Egor 4fde44eefd Merge pull request #1177 from Fr1ngg/revert-1176-revert-1171-revert-1170-2xx781-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Polish mini app subscription state handling"""
2025-10-11 02:13:20 +03:00
Egor 37f5977efd Revert "Revert "Revert "Polish mini app subscription state handling""" 2025-10-11 02:13:13 +03:00
Egor a9cfbb4a4d Merge pull request #1176 from Fr1ngg/revert-1171-revert-1170-2xx781-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Polish mini app subscription state handling""
2025-10-11 02:00:50 +03:00
Egor c7beec1df0 Revert "Revert "Polish mini app subscription state handling"" 2025-10-11 02:00:39 +03:00
Egor 172c3426b9 Merge pull request #1175 from Fr1ngg/revert-1174-cvpz16-bedolaga/update-miniapp/index.html-logic
Revert "Improve miniapp onboarding for unsubscribed users"
2025-10-11 01:58:32 +03:00
Egor a616987eab Revert "Improve miniapp onboarding for unsubscribed users" 2025-10-11 01:58:25 +03:00
Egor 0742d7cdc9 Merge pull request #1174 from Fr1ngg/cvpz16-bedolaga/update-miniapp/index.html-logic
Improve miniapp onboarding for unsubscribed users
2025-10-11 01:57:16 +03:00
Egor f10a84399b Improve miniapp onboarding for unsubscribed users 2025-10-11 01:56:56 +03:00
Egor 6d2eb30704 Merge pull request #1173 from Fr1ngg/revert-1172-3gh1f0-bedolaga/update-miniapp/index.html-logic
Revert "Handle miniapp flows for unregistered and inactive users"
2025-10-11 01:56:37 +03:00
Egor 618a5153f6 Revert "Handle miniapp flows for unregistered and inactive users" 2025-10-11 01:56:28 +03:00
Egor 292378ebbc Merge pull request #1172 from Fr1ngg/3gh1f0-bedolaga/update-miniapp/index.html-logic
Handle miniapp flows for unregistered and inactive users
2025-10-11 01:55:12 +03:00
Egor 645cca088a Handle miniapp flows for unregistered and inactive users 2025-10-11 01:54:46 +03:00
Egor 4159689a4e Merge pull request #1171 from Fr1ngg/revert-1170-2xx781-bedolaga/update-miniapp/index.html-logic
Revert "Polish mini app subscription state handling"
2025-10-11 01:53:55 +03:00
Egor dd2ec37bec Revert "Polish mini app subscription state handling" 2025-10-11 01:53:47 +03:00
Egor 125131c8e6 Merge pull request #1170 from Fr1ngg/2xx781-bedolaga/update-miniapp/index.html-logic
Polish mini app subscription state handling
2025-10-11 01:52:14 +03:00
Egor 3b895b73f9 Polish mini app subscription state handling 2025-10-11 01:51:57 +03:00
Egor 39c915acc4 Merge pull request #1169 from Fr1ngg/revert-1168-74stxg-bedolaga/update-miniapp/index.html-logic
Revert "Update mini app onboarding for unregistered and inactive users"
2025-10-11 01:10:29 +03:00
Egor 8f08489939 Revert "Update mini app onboarding for unregistered and inactive users" 2025-10-11 01:10:22 +03:00
Egor baf69495d6 Merge pull request #1168 from Fr1ngg/74stxg-bedolaga/update-miniapp/index.html-logic
Update mini app onboarding for unregistered and inactive users
2025-10-11 01:08:29 +03:00
Egor 7e9c1bd784 Update mini app states for unregistered and inactive users 2025-10-11 01:08:11 +03:00
Egor 509e7ffe14 Merge pull request #1167 from Fr1ngg/revert-1166-revert-1157-revert-1156-t3tlcj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Handle registration and missing subscription states in mini app"""
2025-10-11 01:07:39 +03:00
Egor e4547b3ca0 Revert "Revert "Revert "Handle registration and missing subscription states in mini app""" 2025-10-11 01:07:31 +03:00
Egor 5d638dbba9 Merge pull request #1166 from Fr1ngg/revert-1157-revert-1156-t3tlcj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Handle registration and missing subscription states in mini app""
2025-10-11 01:06:12 +03:00
Egor d0390bb432 Revert "Revert "Handle registration and missing subscription states in mini app"" 2025-10-11 01:06:00 +03:00
Egor 66eaadd628 Merge pull request #1165 from Fr1ngg/revert-1164-revert-1153-revert-1152-7gq87l-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Improve miniapp onboarding states for unregistered users"""
2025-10-11 01:05:02 +03:00
Egor 8a5b5599cc Revert "Revert "Revert "Improve miniapp onboarding states for unregistered users""" 2025-10-11 01:04:48 +03:00
Egor a7935f3e88 Merge pull request #1164 from Fr1ngg/revert-1153-revert-1152-7gq87l-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Improve miniapp onboarding states for unregistered users""
2025-10-11 01:02:37 +03:00
Egor bbb89633c3 Revert "Revert "Improve miniapp onboarding states for unregistered users"" 2025-10-11 01:02:29 +03:00
Egor 40a690b4bd Merge pull request #1163 from Fr1ngg/revert-1162-revert-1155-revert-1154-c3rvdj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Revert "Handle registration and empty subscription states in mini app"""
2025-10-11 01:01:55 +03:00
Egor 4f06a183be Revert "Revert "Revert "Handle registration and empty subscription states in mini app""" 2025-10-11 01:01:47 +03:00
Egor 6701a63a0a Merge pull request #1162 from Fr1ngg/revert-1155-revert-1154-c3rvdj-bedolaga/update-miniapp/index.html-logic
Revert "Revert "Handle registration and empty subscription states in mini app""
2025-10-11 01:00:52 +03:00
Egor 905e1f1861 Revert "Revert "Handle registration and empty subscription states in mini app"" 2025-10-11 01:00:44 +03:00
Egor fe91fc1c29 Merge pull request #1161 from Fr1ngg/revert-1160-u5iv6j-bedolaga/update-miniapp/index.html-logic
Revert "Handle registration and empty subscription states in miniapp"
2025-10-11 00:59:41 +03:00
Egor df8b85a179 Revert "Handle registration and empty subscription states in miniapp" 2025-10-11 00:59:34 +03:00
Egor 8d08b23d0f Merge pull request #1160 from Fr1ngg/u5iv6j-bedolaga/update-miniapp/index.html-logic
Handle registration and empty subscription states in miniapp
2025-10-11 00:57:30 +03:00
Egor ce087beea6 Add tests for miniapp subscription responses 2025-10-11 00:57:12 +03:00
Egor e63d2158c3 Merge pull request #1159 from Fr1ngg/revert-1158-adv2s5-bedolaga/update-miniapp/index.html-logic
Revert "feat: improve miniapp onboarding states"
2025-10-11 00:56:46 +03:00
Egor eb64d8ed3a Revert "feat: improve miniapp onboarding states" 2025-10-11 00:56:37 +03:00
Egor fc4e17d995 Merge pull request #1158 from Fr1ngg/adv2s5-bedolaga/update-miniapp/index.html-logic
feat: improve miniapp onboarding states
2025-10-11 00:53:54 +03:00
Egor f22fd67b44 feat: improve miniapp onboarding states 2025-10-11 00:53:37 +03:00
Egor e9ba19011a Merge pull request #1157 from Fr1ngg/revert-1156-t3tlcj-bedolaga/update-miniapp/index.html-logic
Revert "Handle registration and missing subscription states in mini app"
2025-10-11 00:53:03 +03:00
Egor 6569e45d9c Revert "Handle registration and missing subscription states in mini app" 2025-10-11 00:52:55 +03:00
Egor bc12bf47d6 Merge pull request #1156 from Fr1ngg/t3tlcj-bedolaga/update-miniapp/index.html-logic
Handle registration and missing subscription states in mini app
2025-10-11 00:51:54 +03:00
Egor e731ccc055 Add mini app states for registration and subscription purchase 2025-10-11 00:51:36 +03:00
Egor c648e78e34 Merge pull request #1155 from Fr1ngg/revert-1154-c3rvdj-bedolaga/update-miniapp/index.html-logic
Revert "Handle registration and empty subscription states in mini app"
2025-10-11 00:51:17 +03:00
Egor ef073bd307 Revert "Handle registration and empty subscription states in mini app" 2025-10-11 00:51:09 +03:00
Egor dde44a8afd Merge pull request #1154 from Fr1ngg/c3rvdj-bedolaga/update-miniapp/index.html-logic
Handle registration and empty subscription states in mini app
2025-10-11 00:48:58 +03:00
Egor 0981aa6efa Adjust mini app states for unregistered and inactive users 2025-10-11 00:47:22 +03:00
Egor 2b71e1701c Merge pull request #1153 from Fr1ngg/revert-1152-7gq87l-bedolaga/update-miniapp/index.html-logic
Revert "Improve miniapp onboarding states for unregistered users"
2025-10-11 00:47:04 +03:00
Egor 2bd5c3e061 Revert "Improve miniapp onboarding states for unregistered users" 2025-10-11 00:46:56 +03:00
Egor 8f39c69f22 Merge pull request #1152 from Fr1ngg/7gq87l-bedolaga/update-miniapp/index.html-logic
Improve miniapp onboarding states for unregistered users
2025-10-11 00:45:42 +03:00
Egor b4301265ec Improve miniapp states for unregistered users 2025-10-11 00:45:24 +03:00
Egor 7d793eff6a Merge pull request #1151 from Fr1ngg/revert-1150-k49afg-bedolaga/update-miniapp/index.html-logic
Revert "feat: improve miniapp onboarding states"
2025-10-11 00:44:49 +03:00
Egor 48e1485cac Revert "feat: improve miniapp onboarding states" 2025-10-11 00:44:41 +03:00
Egor f548f186de Merge pull request #1150 from Fr1ngg/k49afg-bedolaga/update-miniapp/index.html-logic
feat: improve miniapp onboarding states
2025-10-11 00:42:36 +03:00
Egor 97ae7b6b1b feat: improve miniapp onboarding states 2025-10-11 00:42:21 +03:00
Egor ac06b039a9 Merge pull request #1149 from Fr1ngg/9kilqp-bedolaga/fix-layout-for-subscription-button
Stop clipping top-up button in subscription purchase card
2025-10-11 00:24:16 +03:00
Egor 8514f8604a Ensure top-up button stays visible in subscription card 2025-10-11 00:23:59 +03:00
Egor a22b04a91e Merge pull request #1148 from Fr1ngg/revert-1147-hehyjo-bedolaga/fix-layout-for-subscription-button
Revert "Ensure subscription top-up button remains visible"
2025-10-11 00:23:47 +03:00
Egor 11f5289f2d Revert "Ensure subscription top-up button remains visible" 2025-10-11 00:23:29 +03:00
Egor c72886d45a Merge pull request #1147 from Fr1ngg/hehyjo-bedolaga/fix-layout-for-subscription-button
Ensure subscription top-up button remains visible
2025-10-11 00:20:21 +03:00
Egor fbfd59ca83 Prevent purchase top-up button clipping 2025-10-11 00:20:03 +03:00
Egor 1737791659 Merge pull request #1146 from Fr1ngg/revert-1143-t4unz2-bedolaga/fix-layout-for-subscription-button
Revert "Refine subscription period option layout"
2025-10-11 00:19:04 +03:00
Egor 3a8112b4ec Revert "Refine subscription period option layout" 2025-10-11 00:18:56 +03:00
Egor cfdb7b6bdd Merge pull request #1145 from Fr1ngg/revert-1144-q2di6m-bedolaga/fix-button-cropping-in-subscription-section
Revert "Fix subscription top-up button clipping"
2025-10-11 00:18:49 +03:00
Egor 223a112d88 Revert "Fix subscription top-up button clipping" 2025-10-11 00:18:42 +03:00
Egor b72ad3e090 Merge pull request #1144 from Fr1ngg/q2di6m-bedolaga/fix-button-cropping-in-subscription-section
Fix subscription top-up button clipping
2025-10-11 00:16:22 +03:00
Egor e81b555e43 Fix subscription top-up button clipping 2025-10-11 00:16:03 +03:00
Egor 8214775ded Merge pull request #1143 from Fr1ngg/t4unz2-bedolaga/fix-layout-for-subscription-button
Refine subscription period option layout
2025-10-11 00:03:04 +03:00
Egor 3bb5727089 Improve subscription period card layout 2025-10-11 00:02:35 +03:00
Egor 39677eb82e Merge pull request #1141 from Fr1ngg/revert-1140-7tajud-bedolaga/fix-layout-for-subscription-button
Revert "Revamp subscription purchase traffic and device UI"
2025-10-11 00:01:58 +03:00
Egor c56cdf2383 Revert "Revamp subscription purchase traffic and device UI" 2025-10-11 00:01:48 +03:00
Egor a40c4dd7e9 Merge pull request #1140 from Fr1ngg/7tajud-bedolaga/fix-layout-for-subscription-button
Revamp subscription purchase traffic and device UI
2025-10-10 23:48:55 +03:00
Egor a09fd825bf Refine purchase selections layout 2025-10-10 23:48:39 +03:00
Egor 03cf74656e Merge pull request #1139 from Fr1ngg/revert-1137-8hdvnv-bedolaga/fix-layout-for-subscription-button
Revert "Redesign subscription purchase miniapp layout"
2025-10-10 23:48:20 +03:00
Egor 98a301c1b5 Revert "Redesign subscription purchase miniapp layout" 2025-10-10 23:48:12 +03:00
Egor 1c865e3ae8 Merge pull request #1137 from Fr1ngg/8hdvnv-bedolaga/fix-layout-for-subscription-button
Redesign subscription purchase miniapp layout
2025-10-10 23:39:56 +03:00
Egor 01388442b7 Merge pull request #1138 from Fr1ngg/revert-1134-hccafi-bedolaga/fix-layout-for-subscription-button
Revert "Improve subscription purchase CTA spacing on narrow viewports"
2025-10-10 23:39:43 +03:00
Egor 251a940be2 Revamp subscription purchase flow layout 2025-10-10 23:39:36 +03:00
Egor fc725ba9e1 Revert "Improve subscription purchase CTA spacing on narrow viewports" 2025-10-10 23:39:36 +03:00
Egor 9f7e73ae2e Merge pull request #1136 from Fr1ngg/revert-1135-2rmc9c-bedolaga/improve-subscription-purchase-block-design
Revert "Improve subscription purchase UI styling"
2025-10-10 23:38:51 +03:00
Egor d576801676 Revert "Improve subscription purchase UI styling" 2025-10-10 23:38:43 +03:00
Egor 8b82d56635 Merge pull request #1135 from Fr1ngg/2rmc9c-bedolaga/improve-subscription-purchase-block-design
Improve subscription purchase UI styling
2025-10-10 23:37:04 +03:00
Egor b165fafc4d Refine subscription purchase UI 2025-10-10 23:36:47 +03:00
Egor bb9a0b305c Merge pull request #1134 from Fr1ngg/hccafi-bedolaga/fix-layout-for-subscription-button
Improve subscription purchase CTA spacing on narrow viewports
2025-10-10 21:56:05 +03:00
Egor 99b219a307 Improve narrow layout for purchase card 2025-10-10 21:55:49 +03:00
Egor ad2a9b9d14 Merge pull request #1133 from Fr1ngg/revert-1132-mxndh2-bedolaga/fix-layout-for-subscription-button
Revert "Adjust subscription purchase card spacing"
2025-10-10 21:55:27 +03:00
Egor e03bf2a393 Revert "Adjust subscription purchase card spacing" 2025-10-10 21:55:19 +03:00
Egor b6ead34e6c Merge pull request #1132 from Fr1ngg/mxndh2-bedolaga/fix-layout-for-subscription-button
Adjust subscription purchase card spacing
2025-10-10 21:48:42 +03:00
Egor cb180d9c0e Adjust subscription purchase card spacing 2025-10-10 21:48:27 +03:00
Egor 791e26f56a Merge pull request #1131 from Fr1ngg/revert-1130-9a3g3t-bedolaga/fix-layout-for-subscription-button
Revert "Fix spacing around subscription purchase actions"
2025-10-10 21:47:46 +03:00
Egor b0403dab83 Revert "Fix spacing around subscription purchase actions" 2025-10-10 21:47:38 +03:00
Egor e6c3d45991 Merge pull request #1130 from Fr1ngg/9a3g3t-bedolaga/fix-layout-for-subscription-button
Fix spacing around subscription purchase actions
2025-10-10 21:45:13 +03:00
Egor d43597ab2a Improve subscription purchase spacing 2025-10-10 21:44:58 +03:00
Egor b2bf5024a0 Merge pull request #1129 from Fr1ngg/dd0l4x-bedolaga/add-subscription-renewal-section
Ensure subscription purchase actions remain visible
2025-10-10 21:36:59 +03:00
Egor 001f35ed89 Adjust subscription purchase card spacing 2025-10-10 21:36:44 +03:00
Egor 9def85e8e2 Merge pull request #1128 from Fr1ngg/revert-1127-obl5mk-bedolaga/add-subscription-renewal-section
Revert "Improve subscription purchase CTA spacing"
2025-10-10 21:36:27 +03:00
Egor ed92e0cd8e Revert "Improve subscription purchase CTA spacing" 2025-10-10 21:36:19 +03:00
Egor 336cfe440a Merge pull request #1127 from Fr1ngg/obl5mk-bedolaga/add-subscription-renewal-section
Improve subscription purchase CTA spacing
2025-10-10 21:32:36 +03:00
Egor 3c312e089b Improve subscription purchase CTA spacing 2025-10-10 21:32:22 +03:00
Egor d54e42612e Merge pull request #1126 from Fr1ngg/revert-1125-vuqd4o-bedolaga/add-subscription-renewal-section
Revert "Adjust subscription purchase CTA spacing on mobile"
2025-10-10 21:31:50 +03:00
Egor 233520a656 Revert "Adjust subscription purchase CTA spacing on mobile" 2025-10-10 21:31:41 +03:00
Egor aeff150feb Merge pull request #1125 from Fr1ngg/vuqd4o-bedolaga/add-subscription-renewal-section
Adjust subscription purchase CTA spacing on mobile
2025-10-10 21:24:59 +03:00
Egor f0d67e2428 Increase purchase CTA spacing for mobile 2025-10-10 21:24:43 +03:00
Egor 72df1d91d5 Merge pull request #1124 from Fr1ngg/revert-1123-jub1oo-bedolaga/add-subscription-renewal-section
Revert "Handle zero-cost renewals and adjust purchase CTA spacing"
2025-10-10 21:24:22 +03:00
Egor 26811cdcff Revert "Handle zero-cost renewals and adjust purchase CTA spacing" 2025-10-10 21:24:14 +03:00
Egor bc26d8352b Merge pull request #1123 from Fr1ngg/jub1oo-bedolaga/add-subscription-renewal-section
Handle zero-cost renewals and adjust purchase CTA spacing
2025-10-10 21:23:03 +03:00
Egor 4fe2e029bc Tighten subscription purchase CTA spacing 2025-10-10 21:22:48 +03:00
Egor f54bf16ef4 Merge pull request #1122 from Fr1ngg/revert-1121-gy8gds-bedolaga/add-subscription-renewal-section
Revert "Adjust purchase CTA spacing on mobile"
2025-10-10 21:22:14 +03:00
Egor 7dfa5d5a6d Revert "Adjust purchase CTA spacing on mobile" 2025-10-10 21:22:05 +03:00
Egor ac1f6120ab Merge pull request #1121 from Fr1ngg/gy8gds-bedolaga/add-subscription-renewal-section
Adjust purchase CTA spacing on mobile
2025-10-10 21:18:45 +03:00
Egor 26c9896b51 Adjust purchase CTA spacing on mobile 2025-10-10 21:18:22 +03:00
Egor 3b7323c6f4 Merge pull request #1120 from Fr1ngg/revert-1119-byeh3b-bedolaga/add-subscription-renewal-section
Revert "Handle zero-cost renewals and adjust purchase CTA spacing"
2025-10-10 21:18:06 +03:00
Egor 956936185a Revert "Handle zero-cost renewals and adjust purchase CTA spacing" 2025-10-10 21:17:57 +03:00
Egor 435c2d98f1 Merge pull request #1119 from Fr1ngg/byeh3b-bedolaga/add-subscription-renewal-section
Handle zero-cost renewals and adjust purchase CTA spacing
2025-10-10 21:06:22 +03:00
Egor 7f24bd7c8c Tighten renewal promo consumption and polish purchase CTA 2025-10-10 21:06:08 +03:00
Egor b15781ad3b Merge pull request #1118 from Fr1ngg/revert-1117-revert-1114-revert-1113-52037z-bedolaga/add-subscription-renewal-section
Revert "Revert "Revert "Handle zero-cost renewals and polish purchase button layout"""
2025-10-10 21:05:31 +03:00
Egor c598d66ecc Revert "Revert "Revert "Handle zero-cost renewals and polish purchase button layout""" 2025-10-10 21:05:23 +03:00
Egor ffc969daaa Merge pull request #1117 from Fr1ngg/revert-1114-revert-1113-52037z-bedolaga/add-subscription-renewal-section
Revert "Revert "Handle zero-cost renewals and polish purchase button layout""
2025-10-10 21:05:00 +03:00
Egor 116201cca3 Revert "Revert "Handle zero-cost renewals and polish purchase button layout"" 2025-10-10 21:04:52 +03:00
Egor a7592836da Merge pull request #1116 from Fr1ngg/revert-1115-hhoosa-bedolaga/add-subscription-renewal-section
Revert "Ensure free renewal promos are consumed and tweak mobile CTA"
2025-10-10 21:04:44 +03:00
Egor 734c3679ce Revert "Ensure free renewal promos are consumed and tweak mobile CTA" 2025-10-10 21:04:35 +03:00
Egor d350b627fc Merge pull request #1115 from Fr1ngg/hhoosa-bedolaga/add-subscription-renewal-section
Ensure free renewal promos are consumed and tweak mobile CTA
2025-10-10 21:02:55 +03:00
Egor 4fe483da56 Fix free renewal promo consumption and mobile submit layout 2025-10-10 21:02:29 +03:00
Egor 8b4e1c830b Merge pull request #1114 from Fr1ngg/revert-1113-52037z-bedolaga/add-subscription-renewal-section
Revert "Handle zero-cost renewals and polish purchase button layout"
2025-10-10 21:02:13 +03:00
Egor ebfb653d8b Revert "Handle zero-cost renewals and polish purchase button layout" 2025-10-10 21:02:05 +03:00
Egor 8b4f1672dd Merge pull request #1113 from Fr1ngg/52037z-bedolaga/add-subscription-renewal-section
Handle zero-cost renewals and polish purchase button layout
2025-10-10 20:58:47 +03:00
Egor e48a3f178e Fix promo offer consumption and mobile purchase button 2025-10-10 20:56:23 +03:00
Egor a72ed1c2a1 Merge pull request #1112 from Fr1ngg/revert-1111-wti6kr-bedolaga/add-subscription-renewal-section
Revert "Polish subscription purchase configurator"
2025-10-10 20:55:26 +03:00
Egor 15db2d975f Revert "Polish subscription purchase configurator" 2025-10-10 20:55:17 +03:00
Egor 99ff5f8a9d Merge pull request #1111 from Fr1ngg/wti6kr-bedolaga/add-subscription-renewal-section
Polish subscription purchase configurator
2025-10-10 20:07:44 +03:00
Egor 742093e68c Polish subscription purchase configurator 2025-10-10 20:07:27 +03:00
Egor a72f6caf80 Merge pull request #1110 from Fr1ngg/revert-1109-1q0t4b-bedolaga/add-subscription-renewal-section
Revert "Refine subscription purchase configurator UX"
2025-10-10 20:07:12 +03:00
Egor 605d33053d Revert "Refine subscription purchase configurator UX" 2025-10-10 20:07:03 +03:00
Egor 7a2e83f3d1 Merge pull request #1109 from Fr1ngg/1q0t4b-bedolaga/add-subscription-renewal-section
Refine subscription purchase configurator UX
2025-10-10 20:04:57 +03:00
Egor e7fc1b8402 Polish subscription purchase flow 2025-10-10 20:04:42 +03:00
Egor 5fcdcc5878 Merge pull request #1108 from Fr1ngg/revert-1107-ppvgbo-bedolaga/add-subscription-renewal-section
Revert "Refine subscription purchase presentation"
2025-10-10 20:04:28 +03:00
Egor 4212fc0be3 Revert "Refine subscription purchase presentation" 2025-10-10 20:04:19 +03:00
Egor df4ce40739 Merge pull request #1107 from Fr1ngg/ppvgbo-bedolaga/add-subscription-renewal-section
Refine subscription purchase presentation
2025-10-10 20:02:18 +03:00
Egor b761630505 Improve subscription purchase layout and summary 2025-10-10 20:02:02 +03:00
Egor d1c3358dbf Merge pull request #1106 from Fr1ngg/revert-1105-63c4w3-bedolaga/add-subscription-renewal-section
Revert "Improve subscription purchase layout and discount summary"
2025-10-10 20:01:33 +03:00
Egor 1d1fc7dbfb Revert "Improve subscription purchase layout and discount summary" 2025-10-10 20:01:23 +03:00
Egor 25aaa703a6 Merge pull request #1105 from Fr1ngg/63c4w3-bedolaga/add-subscription-renewal-section
Improve subscription purchase layout and discount summary
2025-10-10 19:59:23 +03:00
Egor 46f3ef23ed Refine subscription purchase configurator 2025-10-10 19:59:08 +03:00
Egor 80517f0c53 Merge pull request #1104 from Fr1ngg/revert-1103-u6qc6m-bedolaga/add-subscription-renewal-section
Revert "Update renewal balance warning per selected period"
2025-10-10 19:58:40 +03:00
Egor 66d1f96e32 Revert "Update renewal balance warning per selected period" 2025-10-10 19:58:31 +03:00
Egor a93c5808c0 Merge pull request #1103 from Fr1ngg/u6qc6m-bedolaga/add-subscription-renewal-section
Update renewal balance warning per selected period
2025-10-10 19:40:56 +03:00
Egor f80818c18a Fix renewal balance warning for period switches 2025-10-10 19:40:38 +03:00
Egor 6199716c09 Merge pull request #1102 from Fr1ngg/revert-1101-revert-1097-revert-1096-h2ud8b-bedolaga/add-subscription-renewal-section
Revert "Revert "Revert "Prevent renewal options from showing phantom discounts"""
2025-10-10 19:39:46 +03:00
Egor ab131ed399 Revert "Revert "Revert "Prevent renewal options from showing phantom discounts""" 2025-10-10 19:39:34 +03:00
Egor 43203bfeb3 Merge pull request #1101 from Fr1ngg/revert-1097-revert-1096-h2ud8b-bedolaga/add-subscription-renewal-section
Revert "Revert "Prevent renewal options from showing phantom discounts""
2025-10-10 12:47:12 +03:00
Egor 4873a26880 Revert "Revert "Prevent renewal options from showing phantom discounts"" 2025-10-10 12:47:03 +03:00
Egor 974b049ee0 Merge pull request #1100 from Fr1ngg/revert-1099-mtpzes-bedolaga/add-subscription-renewal-section
Revert "Prevent renewal prices from showing strike-through without discounts"
2025-10-10 12:46:45 +03:00
Egor 3656ec5df5 Revert "Prevent renewal prices from showing strike-through without discounts" 2025-10-10 12:46:37 +03:00
Egor ef4d343e16 Merge pull request #1099 from Fr1ngg/mtpzes-bedolaga/add-subscription-renewal-section
Prevent renewal prices from showing strike-through without discounts
2025-10-10 12:43:07 +03:00
Egor 71171b24b3 Fix renewal option original price display when no discount 2025-10-10 12:42:46 +03:00
Egor 5ce9fa9fac Merge pull request #1098 from Fr1ngg/revert-1095-e369z3-bedolaga/add-subscription-renewal-section
Revert "Fix renewal original price display without discounts"
2025-10-10 12:42:19 +03:00
Egor 85a702b00a Revert "Fix renewal original price display without discounts" 2025-10-10 12:42:10 +03:00
Egor aab8727de7 Merge pull request #1095 from Fr1ngg/e369z3-bedolaga/add-subscription-renewal-section
Fix renewal original price display without discounts
2025-10-10 12:41:02 +03:00
Egor 813bb45cf9 Merge pull request #1097 from Fr1ngg/revert-1096-h2ud8b-bedolaga/add-subscription-renewal-section
Revert "Prevent renewal options from showing phantom discounts"
2025-10-10 12:40:50 +03:00
Egor 3d731e6b06 Revert "Prevent renewal options from showing phantom discounts" 2025-10-10 12:40:41 +03:00
Egor 8e6174ea13 Merge pull request #1096 from Fr1ngg/h2ud8b-bedolaga/add-subscription-renewal-section
Prevent renewal options from showing phantom discounts
2025-10-10 12:37:53 +03:00
Egor 7a5a1fa8b4 Normalize renewal price comparison when no discount 2025-10-10 12:37:28 +03:00
Egor 1a4c8eff19 Fix renewal price display when no discount 2025-10-10 12:36:39 +03:00
Egor b345a4ce25 Merge pull request #1094 from Fr1ngg/revert-1093-ifww7g-bedolaga/add-subscription-renewal-section
Revert "Fix renewal mini app spacing and discount presentation"
2025-10-10 12:36:04 +03:00
Egor d8c37349ca Revert "Fix renewal mini app spacing and discount presentation" 2025-10-10 12:35:53 +03:00
Egor 4df3b2ea8f Merge pull request #1093 from Fr1ngg/ifww7g-bedolaga/add-subscription-renewal-section
Fix renewal mini app spacing and discount presentation
2025-10-10 12:28:19 +03:00
Egor 8bc683e03a Fix renewal discount display in mini app 2025-10-10 12:27:59 +03:00
Egor c176a9abf2 Merge pull request #1092 from Fr1ngg/revert-1091-revert-1088-revert-1087-bfz03a-bedolaga/add-subscription-renewal-section
Revert "Revert "Revert "Add renewal API for mini app subscription extensions"""
2025-10-10 12:27:32 +03:00
Egor 72553e5f8b Revert "Revert "Revert "Add renewal API for mini app subscription extensions""" 2025-10-10 12:27:22 +03:00
Egor de09965876 Merge pull request #1091 from Fr1ngg/revert-1088-revert-1087-bfz03a-bedolaga/add-subscription-renewal-section
Revert "Revert "Add renewal API for mini app subscription extensions""
2025-10-10 11:58:39 +03:00
Egor 7eb14ad036 Revert "Revert "Add renewal API for mini app subscription extensions"" 2025-10-10 11:58:29 +03:00
Egor 0e0c917fdd Merge pull request #1090 from Fr1ngg/revert-1089-64afzg-bedolaga/fix-subscription-renewal-section-in-miniapp
Revert "Implement mini app subscription renewal flow"
2025-10-10 11:58:25 +03:00
Egor 7eb58d1add Revert "Implement mini app subscription renewal flow" 2025-10-10 11:58:05 +03:00
Egor 405cba645a Merge pull request #1089 from Fr1ngg/64afzg-bedolaga/fix-subscription-renewal-section-in-miniapp
Implement mini app subscription renewal flow
2025-10-10 11:57:18 +03:00
Egor 359b7c807c Implement mini app subscription renewal API 2025-10-10 11:56:51 +03:00
Egor dbed5d9e5d Merge pull request #1088 from Fr1ngg/revert-1087-bfz03a-bedolaga/add-subscription-renewal-section
Revert "Add renewal API for mini app subscription extensions"
2025-10-10 11:56:39 +03:00
Egor f999a31664 Revert "Add renewal API for mini app subscription extensions" 2025-10-10 11:56:29 +03:00
Egor 96c795c789 Merge pull request #1087 from Fr1ngg/bfz03a-bedolaga/add-subscription-renewal-section
Add renewal API for mini app subscription extensions
2025-10-10 11:52:08 +03:00
Egor 874c5d8017 Add renewal API for mini app subscription extensions 2025-10-10 11:51:48 +03:00
Egor 2c76751c63 Merge pull request #1086 from Fr1ngg/revert-1083-8qdgqk-bedolaga/add-subscription-renewal-section
Revert "Add subscription renewal UI to mini app"
2025-10-10 11:51:19 +03:00
Egor 483b99e557 Revert "Add subscription renewal UI to mini app" 2025-10-10 11:51:08 +03:00
Egor b653c45320 Merge pull request #1085 from Fr1ngg/revert-1084-nqyfy2-bedolaga/fix-subscription-renewal-section-in-miniapp
Revert "Implement miniapp subscription renewal API"
2025-10-10 11:51:02 +03:00
Egor 3f0f9c3f4b Revert "Implement miniapp subscription renewal API" 2025-10-10 11:50:52 +03:00
Egor d899dc7d86 Merge pull request #1084 from Fr1ngg/nqyfy2-bedolaga/fix-subscription-renewal-section-in-miniapp
Implement miniapp subscription renewal API
2025-10-10 11:49:03 +03:00
Egor 21567088a1 Add miniapp subscription renewal endpoints 2025-10-10 11:48:47 +03:00
Egor 5425210cb0 Merge pull request #1083 from Fr1ngg/8qdgqk-bedolaga/add-subscription-renewal-section
Add subscription renewal UI to mini app
2025-10-10 11:27:28 +03:00
Egor 0484d69538 Guard renewal summary elements when data is missing 2025-10-10 11:27:10 +03:00
Egor ac5c764daa Merge pull request #1082 from Fr1ngg/revert-1081-uiwlsk-bedolaga/add-subscription-renewal-section
Revert "Add subscription renewal section to miniapp"
2025-10-10 11:26:57 +03:00
Egor 15df6b802c Revert "Add subscription renewal section to miniapp" 2025-10-10 11:26:48 +03:00
Egor a1e1267239 Merge pull request #1081 from Fr1ngg/uiwlsk-bedolaga/add-subscription-renewal-section
Add subscription renewal section to miniapp
2025-10-10 11:20:15 +03:00
Egor 5fcaa73d20 Add subscription renewal section to miniapp 2025-10-10 11:19:58 +03:00
Egor c57d273c7e Merge pull request #1080 from Fr1ngg/zros0m-bedolaga/-api
Update miniapp purchase UI to reflect promo pricing and hide inactive servers
2025-10-10 10:52:31 +03:00
Egor 51a1db0680 Hide inactive miniapp servers during purchase 2025-10-10 10:52:11 +03:00
Egor a3d46cd293 Merge pull request #1079 from Fr1ngg/revert-1078-19ztpa-bedolaga/-api
Revert "Update miniapp purchase UI to reflect promo pricing"
2025-10-10 10:50:29 +03:00
Egor 18ec1c1f5f Revert "Update miniapp purchase UI to reflect promo pricing" 2025-10-10 10:50:20 +03:00
Egor a9d4bea9de Merge pull request #1078 from Fr1ngg/19ztpa-bedolaga/-api
Update miniapp purchase UI to reflect promo pricing
2025-10-10 10:26:18 +03:00
Egor 10b05f95ab Enhance miniapp purchase UI for new pricing data 2025-10-10 10:26:00 +03:00
Egor b5be8e5237 Merge pull request #1077 from Fr1ngg/revert-1076-9jagyo-bedolaga/expand-api-for-subscription-purchases
Revert "Add subscription purchase flow for the mini app"
2025-10-10 10:25:04 +03:00
Egor 4117dcb97f Revert "Add subscription purchase flow for the mini app" 2025-10-10 10:24:55 +03:00
Egor 0cb422d33e Merge pull request #1076 from Fr1ngg/9jagyo-bedolaga/expand-api-for-subscription-purchases
Add subscription purchase flow for the mini app
2025-10-10 10:23:20 +03:00
Egor da1ed55fd7 Add subscription purchase flow for mini app 2025-10-10 10:22:50 +03:00
Egor bb17f1c456 Merge pull request #1075 from Fr1ngg/revert-1074-vty20t-bedolaga/expand-api-for-subscription-purchase
Revert "Harden miniapp purchase period resolution"
2025-10-10 09:59:31 +03:00
Egor b2f88e75d5 Revert "Harden miniapp purchase period resolution" 2025-10-10 09:59:22 +03:00
Egor 767e7b653e Merge pull request #1074 from Fr1ngg/vty20t-bedolaga/expand-api-for-subscription-purchase
Harden miniapp purchase period resolution
2025-10-10 09:57:56 +03:00
Egor 2a7969716a Harden miniapp purchase period resolution 2025-10-10 09:57:37 +03:00
Egor 3e10bceb43 Merge pull request #1073 from Fr1ngg/revert-1072-026xmf-bedolaga/expand-api-for-subscription-purchase
Revert "Handle missing subscription period metadata in preview"
2025-10-10 09:57:14 +03:00
Egor 6f293615bb Revert "Handle missing subscription period metadata in preview" 2025-10-10 09:57:06 +03:00
Egor 69ce111281 Merge pull request #1072 from Fr1ngg/026xmf-bedolaga/expand-api-for-subscription-purchase
Handle missing subscription period metadata in preview
2025-10-10 09:55:38 +03:00
Egor 85c8ad018a Handle missing period selections in preview 2025-10-10 09:55:20 +03:00
Egor 385ebd1d39 Merge pull request #1070 from Fr1ngg/revert-1069-h3hzso-bedolaga/expand-api-for-subscription-purchase
Revert "Improve subscription purchase period handling"
2025-10-10 09:54:23 +03:00
Egor 4227a8613e Revert "Improve subscription purchase period handling" 2025-10-10 09:54:15 +03:00
Egor 2dc8947b00 Merge pull request #1069 from Fr1ngg/h3hzso-bedolaga/expand-api-for-subscription-purchase
Improve subscription purchase period handling
2025-10-10 09:53:13 +03:00
Egor 851f4dc58c Handle missing period selections in miniapp preview 2025-10-10 09:52:50 +03:00
Egor 366fc28ec5 Merge pull request #1068 from Fr1ngg/revert-1067-qor8f9-bedolaga/expand-api-for-subscription-purchase
Revert "Fix subscription purchase selection handling"
2025-10-10 09:52:22 +03:00
Egor 277d690686 Revert "Fix subscription purchase selection handling" 2025-10-10 09:52:13 +03:00
Egor 077584fb57 Merge pull request #1067 from Fr1ngg/qor8f9-bedolaga/expand-api-for-subscription-purchase
Fix subscription purchase selection handling
2025-10-10 09:40:17 +03:00
Egor 1bd05de773 Fix subscription purchase selection handling 2025-10-10 09:39:45 +03:00
Egor 1ae84a73dc Merge pull request #1066 from Fr1ngg/revert-1065-a0y28m-bedolaga/expand-api-for-subscription-purchase
Revert "Improve miniapp subscription configurator responsiveness"
2025-10-10 09:39:19 +03:00
Egor f5dad62ab0 Revert "Improve miniapp subscription configurator responsiveness" 2025-10-10 09:39:10 +03:00
Egor 2d90b9783a Merge pull request #1065 from Fr1ngg/a0y28m-bedolaga/expand-api-for-subscription-purchase
Improve miniapp subscription configurator responsiveness
2025-10-10 09:37:21 +03:00
Egor dd174bf6a5 Fix miniapp subscription configurator interactions 2025-10-10 09:37:04 +03:00
Egor fcd20b3aaa Merge pull request #1064 from Fr1ngg/revert-1063-ny2u3l-bedolaga/expand-api-for-subscription-purchase
Revert "Improve miniapp subscription purchase configurator"
2025-10-10 09:36:41 +03:00
Egor d97a71d96a Revert "Improve miniapp subscription purchase configurator" 2025-10-10 09:36:32 +03:00
Egor e7b631a991 Merge pull request #1063 from Fr1ngg/ny2u3l-bedolaga/expand-api-for-subscription-purchase
Improve miniapp subscription purchase configurator
2025-10-10 09:35:12 +03:00
Egor 948e36478b Improve miniapp subscription purchase configurator 2025-10-10 09:34:56 +03:00
Egor ec52741cf8 Merge pull request #1062 from Fr1ngg/revert-1061-revert-1056-revert-1053-9vdvf5-bedolaga/expand-api-for-subscription-purchase
Revert "Revert "Revert "Add mini app subscription purchase endpoints and schemas"""
2025-10-10 09:34:38 +03:00
Egor 08c3a54e6d Revert "Revert "Revert "Add mini app subscription purchase endpoints and schemas""" 2025-10-10 09:34:27 +03:00
Egor 75af19ab83 Merge pull request #1061 from Fr1ngg/revert-1056-revert-1053-9vdvf5-bedolaga/expand-api-for-subscription-purchase
Revert "Revert "Add mini app subscription purchase endpoints and schemas""
2025-10-10 09:13:05 +03:00
Egor 13c89c1d6f Revert "Revert "Add mini app subscription purchase endpoints and schemas"" 2025-10-10 09:12:57 +03:00
Egor c091cb95c8 Merge pull request #1060 from Fr1ngg/revert-1059-0idrid-bedolaga/expand-api-for-subscription-purchase
Revert "Add mini-app subscription purchase endpoints and UI handling"
2025-10-10 09:12:12 +03:00
Egor 65aa0a7c08 Revert "Add mini-app subscription purchase endpoints and UI handling" 2025-10-10 09:12:03 +03:00
Egor 080c019684 Merge pull request #1059 from Fr1ngg/0idrid-bedolaga/expand-api-for-subscription-purchase
Add mini-app subscription purchase endpoints and UI handling
2025-10-10 09:11:03 +03:00
Egor 0e1962c291 Add mini-app subscription purchase endpoints and UI handling 2025-10-10 09:10:44 +03:00
Egor e0aea226a4 Merge pull request #1058 from Fr1ngg/revert-1057-80ize0-bedolaga/extend-api-for-subscription-purchasing
Revert "Add miniapp subscription purchase flow"
2025-10-10 09:09:51 +03:00
Egor 687ea7d1d6 Revert "Add miniapp subscription purchase flow" 2025-10-10 09:09:41 +03:00
Egor b7146268c2 Merge pull request #1057 from Fr1ngg/80ize0-bedolaga/extend-api-for-subscription-purchasing
Add miniapp subscription purchase flow
2025-10-10 09:08:37 +03:00
Egor 0952059c60 Add miniapp subscription purchase flow 2025-10-10 09:08:18 +03:00
Egor bbf5575b49 Merge pull request #1056 from Fr1ngg/revert-1053-9vdvf5-bedolaga/expand-api-for-subscription-purchase
Revert "Add mini app subscription purchase endpoints and schemas"
2025-10-10 09:07:29 +03:00
Egor 6b8e913f91 Revert "Add mini app subscription purchase endpoints and schemas" 2025-10-10 09:07:18 +03:00
Egor 51e5f1c715 Merge pull request #1053 from Fr1ngg/9vdvf5-bedolaga/expand-api-for-subscription-purchase
Add mini app subscription purchase endpoints and schemas
2025-10-10 09:04:08 +03:00
Egor 910f48bff0 Merge pull request #1055 from Fr1ngg/revert-1054-fze3ek-bedolaga/expand-api-for-subscription-purchase
Revert "Add mini app subscription purchase flow"
2025-10-10 09:03:27 +03:00
Egor 62325d7a21 Revert "Add mini app subscription purchase flow" 2025-10-10 09:03:19 +03:00
Egor dc07de9957 Merge pull request #1054 from Fr1ngg/fze3ek-bedolaga/expand-api-for-subscription-purchase
Add mini app subscription purchase flow
2025-10-10 09:02:02 +03:00
Egor d65524d349 Add mini app subscription purchase flow 2025-10-10 09:01:37 +03:00
Egor fd989d0186 Add subscription purchase API for mini app 2025-10-10 09:00:27 +03:00
Egor 390874565e Merge pull request #1052 from Fr1ngg/oz7h91-bedolaga/add-subscription-purchase-configurator-to-miniapp
Implement subscription purchase configurator
2025-10-10 08:21:56 +03:00
Egor ae64dc48d2 Implement subscription purchase configurator 2025-10-10 08:21:31 +03:00
Egor 8a68a0d4b1 Merge pull request #1051 from Fr1ngg/revert-1050-hpiari-bedolaga/add-subscription-purchase-configurator-to-miniapp
Revert "Add mini-app subscription purchase configurator"
2025-10-10 08:21:17 +03:00
Egor 82cdd4442e Revert "Add mini-app subscription purchase configurator" 2025-10-10 08:21:09 +03:00
Egor 112ce528d9 Merge pull request #1050 from Fr1ngg/hpiari-bedolaga/add-subscription-purchase-configurator-to-miniapp
Add mini-app subscription purchase configurator
2025-10-10 08:13:32 +03:00
Egor 950f84d012 Add subscription purchase configurator logic 2025-10-10 08:13:10 +03:00
Egor 025a57a325 Merge pull request #1049 from Fr1ngg/revert-1048-84983m-bedolaga/add-subscription-purchase-feature-to-miniapp
Revert "Add interactive purchase configurator flow to mini app"
2025-10-10 08:12:18 +03:00
Egor 9d374f3ba1 Revert "Add interactive purchase configurator flow to mini app" 2025-10-10 08:12:02 +03:00
Egor d4aec3b8aa Merge pull request #1048 from Fr1ngg/84983m-bedolaga/add-subscription-purchase-feature-to-miniapp
Add interactive purchase configurator flow to mini app
2025-10-10 08:10:59 +03:00
Egor ffe8f70946 Add purchase configurator actions for mini app 2025-10-10 08:10:43 +03:00
Egor febf9ec669 Merge pull request #1047 from Fr1ngg/revert-1046-7hfdjj-bedolaga/add-subscription-purchase-configurator-to-miniapp
Revert "Wire subscription purchase configurator actions"
2025-10-10 08:10:03 +03:00
Egor 6bff990d59 Revert "Wire subscription purchase configurator actions" 2025-10-10 08:09:53 +03:00
Egor d50afd94eb Merge pull request #1046 from Fr1ngg/7hfdjj-bedolaga/add-subscription-purchase-configurator-to-miniapp
Wire subscription purchase configurator actions
2025-10-10 08:06:23 +03:00
Egor d43bab9c88 Wire subscription purchase configurator actions 2025-10-10 08:06:02 +03:00
Egor b6b63cd82d Merge pull request #1045 from Fr1ngg/revert-1044-17u0o4-bedolaga/add-subscription-purchase-feature-to-miniapp
Revert "Add subscription configurator to mini app"
2025-10-10 08:05:25 +03:00
Egor d7534e098c Revert "Add subscription configurator to mini app" 2025-10-10 08:05:17 +03:00
Egor 1af55ec86e Merge pull request #1044 from Fr1ngg/17u0o4-bedolaga/add-subscription-purchase-feature-to-miniapp
Add subscription configurator to mini app
2025-10-10 08:02:30 +03:00
Egor 3b72707fd9 Safely render subscription configurator summary 2025-10-10 08:02:14 +03:00
Egor 55ee06402e Merge pull request #1043 from Fr1ngg/revert-1042-pt7wcl-bedolaga/add-subscription-purchase-feature-to-miniapp
Revert "feat: add subscription purchase configurator to miniapp"
2025-10-10 07:59:04 +03:00
Egor bd19b570e5 Revert "feat: add subscription purchase configurator to miniapp" 2025-10-10 07:58:53 +03:00
Egor d0735aad31 Merge pull request #1042 from Fr1ngg/pt7wcl-bedolaga/add-subscription-purchase-feature-to-miniapp
feat: add subscription purchase configurator to miniapp
2025-10-10 07:57:09 +03:00
Egor 73594b9088 feat: add subscription purchase configurator to miniapp 2025-10-10 07:56:55 +03:00
Egor 6075ed5410 Merge pull request #1041 from Fr1ngg/dev4
Редактирование платной подписки в миниапке
2025-10-10 07:46:30 +03:00
Egor 07ff14c6b1 Merge pull request #1040 from Fr1ngg/5ulvyc-bedolaga/add-subscription-settings-endpoints
Handle Telegram popup confirmations and validate traffic selections
2025-10-10 07:19:56 +03:00
Egor 36ac94468a Fix miniapp confirmations and guard traffic updates 2025-10-10 07:19:38 +03:00
Egor 3c3bd139e3 Merge pull request #1039 from Fr1ngg/revert-1038-bblv1f-bedolaga/add-subscription-settings-endpoints
Revert "Require active subscription and confirm add-on charges"
2025-10-10 07:19:04 +03:00
Egor 82b2127e55 Revert "Require active subscription and confirm add-on charges" 2025-10-10 07:18:41 +03:00
Egor 5f2bb415de Merge pull request #1038 from Fr1ngg/bblv1f-bedolaga/add-subscription-settings-endpoints
Require active subscription and confirm add-on charges
2025-10-10 07:03:46 +03:00
Egor c2a6dfb3b4 Improve subscription settings updates 2025-10-10 07:03:31 +03:00
Egor 04bcc53012 Merge pull request #1037 from Fr1ngg/revert-1036-kn2p4i-bedolaga/add-subscription-settings-endpoints
Revert "Require active subscription for miniapp changes and add confirmations"
2025-10-10 07:03:14 +03:00
Egor d9330bfb7a Revert "Require active subscription for miniapp changes and add confirmations" 2025-10-10 07:03:06 +03:00
Egor 938f96bef8 Merge pull request #1036 from Fr1ngg/kn2p4i-bedolaga/add-subscription-settings-endpoints
Require active subscription for miniapp changes and add confirmations
2025-10-10 06:59:25 +03:00
Egor 4ea0868f6a Ensure active subscription updates and add confirmations 2025-10-10 06:59:07 +03:00
Egor d032b9fcb1 Merge pull request #1035 from Fr1ngg/revert-1034-nlxsql-bedolaga/add-subscription-settings-endpoints
Revert "Require confirmation before paid miniapp subscription updates"
2025-10-10 06:58:53 +03:00
Egor a0efbb105e Revert "Require confirmation before paid miniapp subscription updates" 2025-10-10 06:58:45 +03:00
Egor 6cfd239217 Merge pull request #1034 from Fr1ngg/nlxsql-bedolaga/add-subscription-settings-endpoints
Require confirmation before paid miniapp subscription updates
2025-10-10 06:55:10 +03:00
Egor 8378a2c555 Require active subscription and confirm paid miniapp updates 2025-10-10 06:54:54 +03:00
Egor fd8ced4f1a Merge pull request #1033 from Fr1ngg/revert-1032-keclgm-bedolaga/add-subscription-settings-endpoints
Revert "Add miniapp subscription settings management endpoints"
2025-10-10 06:54:33 +03:00
Egor 261b5b8d14 Revert "Add miniapp subscription settings management endpoints" 2025-10-10 06:54:24 +03:00
Egor 0fb684544f Merge pull request #1032 from Fr1ngg/keclgm-bedolaga/add-subscription-settings-endpoints
Add miniapp subscription settings management endpoints
2025-10-10 06:32:30 +03:00
Egor 22f9c9a48d Add miniapp subscription settings management endpoints 2025-10-10 06:32:12 +03:00
Egor b6c3acfa13 Merge pull request #1031 from Fr1ngg/revert-1030-cy8m6c-bedolaga/add-subscription-settings-endpoints
Revert "Add miniapp subscription settings management endpoints"
2025-10-10 06:31:53 +03:00
Egor 8b94ac7e1d Revert "Add miniapp subscription settings management endpoints" 2025-10-10 06:31:45 +03:00
Egor a676e2818a Merge pull request #1030 from Fr1ngg/cy8m6c-bedolaga/add-subscription-settings-endpoints
Add miniapp subscription settings management endpoints
2025-10-10 06:30:54 +03:00
Egor b5c2ad4c07 Add miniapp subscription settings management endpoints 2025-10-10 06:30:38 +03:00
Egor d3100f6996 Merge pull request #1029 from Fr1ngg/revert-1028-7c9pa7-bedolaga/add-subscription-settings-endpoints
Revert "Add miniapp subscription settings management endpoints"
2025-10-10 06:29:31 +03:00
Egor 3a13c5225b Revert "Add miniapp subscription settings management endpoints" 2025-10-10 06:29:23 +03:00
Egor eab3626848 Merge pull request #1028 from Fr1ngg/7c9pa7-bedolaga/add-subscription-settings-endpoints
Add miniapp subscription settings management endpoints
2025-10-10 06:27:44 +03:00
Egor 7a34a9d370 Add miniapp subscription settings management endpoints 2025-10-10 06:27:27 +03:00
Egor 7045412fe6 Merge pull request #1027 from Fr1ngg/revert-1026-n4b27b-bedolaga/fix-subscription-settings-endpoints-issues
Revert "Add miniapp subscription settings API endpoints"
2025-10-10 06:12:57 +03:00
Egor 7027fa91c3 Revert "Add miniapp subscription settings API endpoints" 2025-10-10 06:12:48 +03:00
Egor 8e9f7f91ef Merge pull request #1026 from Fr1ngg/n4b27b-bedolaga/fix-subscription-settings-endpoints-issues
Add miniapp subscription settings API endpoints
2025-10-10 06:08:34 +03:00
Egor e6923a0d0a Add miniapp subscription settings API endpoints 2025-10-10 06:07:53 +03:00
Egor 3250976709 Merge pull request #1025 from Fr1ngg/revert-1024-h9chhq-bedolaga/fix-subscription-settings-on-page
Revert "Implement miniapp subscription settings endpoints"
2025-10-10 06:07:40 +03:00
Egor cb5b731e92 Revert "Implement miniapp subscription settings endpoints" 2025-10-10 06:07:32 +03:00
Egor 34a7c0dfc6 Merge pull request #1024 from Fr1ngg/h9chhq-bedolaga/fix-subscription-settings-on-page
Implement miniapp subscription settings endpoints
2025-10-10 06:06:33 +03:00
Egor 009b6bf71d Add miniapp subscription settings endpoints 2025-10-10 06:06:17 +03:00
Egor d01da00809 Merge pull request #1023 from Fr1ngg/da8kxk-bedolaga/add-subscription-settings-panel
Add subscription settings management to miniapp
2025-10-10 05:24:10 +03:00
Egor a76ca27f22 feat: add subscription subscription settings management 2025-10-10 05:23:53 +03:00
Egor 1a73b13d50 Merge pull request #1022 from Fr1ngg/dev4
Пополнение баланса на странице миниапки
2025-10-10 04:55:28 +03:00
Egor 073af762c5 Merge pull request #1021 from Fr1ngg/bedolaga-yhudb1
Normalize Stars invoices to matched amounts
2025-10-10 04:49:07 +03:00
Egor b5c8b4218c Preserve normalized Stars invoice amounts 2025-10-10 04:48:50 +03:00
Egor 00605a37ab Merge pull request #1020 from Fr1ngg/revert-1019-bedolaga-8f5lx7
Revert "Normalize Stars invoices to matched amounts"
2025-10-10 04:48:11 +03:00
Egor 0f61df80e2 Revert "Normalize Stars invoices to matched amounts" 2025-10-10 04:48:01 +03:00
Egor d1777b48ea Merge pull request #1019 from Fr1ngg/bedolaga-8f5lx7
Normalize Stars invoices to matched amounts
2025-10-10 04:31:39 +03:00
Egor fae4036670 Normalize Stars invoices to matched amounts 2025-10-10 04:31:21 +03:00
Egor 57a32ea52e Merge pull request #1018 from Fr1ngg/revert-1017-bedolaga-6dyjgo
Revert "Fix miniapp stars status reuse"
2025-10-10 04:30:13 +03:00
Egor 5412ad4202 Revert "Fix miniapp stars status reuse" 2025-10-10 04:30:05 +03:00
Egor ab9bc8b9e1 Merge pull request #1017 from Fr1ngg/bedolaga-6dyjgo
Fix miniapp stars status reuse
2025-10-10 04:06:36 +03:00
Egor 2c4090b63e Fix miniapp stars status reuse 2025-10-10 04:06:17 +03:00
Egor 61287d5dbc Merge pull request #1016 from Fr1ngg/revert-1015-bedolaga-22m19x
Revert "Prevent duplicate miniapp stars payment confirmations"
2025-10-10 04:05:57 +03:00
Egor 0cc37c1a9e Revert "Prevent duplicate miniapp stars payment confirmations" 2025-10-10 04:05:48 +03:00
Egor c877b327a5 Merge pull request #1015 from Fr1ngg/bedolaga-22m19x
Prevent duplicate miniapp stars payment confirmations
2025-10-10 04:04:01 +03:00
Egor a27c9a7463 Ensure stars payments use unique payloads 2025-10-10 04:03:44 +03:00
Egor 16c99bed06 Merge pull request #1014 from Fr1ngg/revert-1013-revert-1010-revert-1009-bedolaga-y96yta
Revert "Revert "Revert "Handle miniapp payment status refresh"""
2025-10-10 04:01:13 +03:00
Egor 045bc32798 Revert "Revert "Revert "Handle miniapp payment status refresh""" 2025-10-10 04:01:03 +03:00
Egor f1dbd7c784 Merge pull request #1013 from Fr1ngg/revert-1010-revert-1009-bedolaga-y96yta
Revert "Revert "Handle miniapp payment status refresh""
2025-10-10 03:46:22 +03:00
Egor cb9c673af1 Revert "Revert "Handle miniapp payment status refresh"" 2025-10-10 03:46:14 +03:00
Egor 53ed64afa3 Merge pull request #1012 from Fr1ngg/revert-1011-revert-1006-revert-1005-bedolaga-gg86kp
Revert "Revert "Revert "Align miniapp payment limits and status UX"""
2025-10-10 03:45:59 +03:00
Egor 23d313e12d Revert "Revert "Revert "Align miniapp payment limits and status UX""" 2025-10-10 03:45:50 +03:00
Egor 02313fc924 Merge pull request #1011 from Fr1ngg/revert-1006-revert-1005-bedolaga-gg86kp
Revert "Revert "Align miniapp payment limits and status UX""
2025-10-10 03:42:08 +03:00
Egor 860937591d Revert "Revert "Align miniapp payment limits and status UX"" 2025-10-10 03:41:59 +03:00
Egor 9388f1c81a Merge pull request #1010 from Fr1ngg/revert-1009-bedolaga-y96yta
Revert "Handle miniapp payment status refresh"
2025-10-10 03:41:34 +03:00
Egor d7c4f439b9 Revert "Handle miniapp payment status refresh" 2025-10-10 03:41:26 +03:00
Egor 4273d2354c Merge pull request #1009 from Fr1ngg/bedolaga-y96yta
Handle miniapp payment status refresh
2025-10-10 03:37:47 +03:00
Egor ee1c215278 Enhance miniapp payment flows 2025-10-10 03:37:16 +03:00
Egor da2260f55c Merge pull request #1008 from Fr1ngg/revert-1007-bedolaga-kj65b7
Revert "Improve miniapp payment polling for Stars"
2025-10-10 03:36:57 +03:00
Egor bdeff1dbad Revert "Improve miniapp payment polling for Stars" 2025-10-10 03:36:48 +03:00
Egor bd421f23da Merge pull request #1007 from Fr1ngg/bedolaga-kj65b7
Improve miniapp payment polling for Stars
2025-10-10 03:32:00 +03:00
Egor c63b439722 Improve miniapp payment polling for Stars 2025-10-10 03:31:39 +03:00
Egor b501e937de Merge pull request #1006 from Fr1ngg/revert-1005-bedolaga-gg86kp
Revert "Align miniapp payment limits and status UX"
2025-10-10 03:31:15 +03:00
Egor afa65e0fbd Revert "Align miniapp payment limits and status UX" 2025-10-10 03:31:06 +03:00
Egor 16945492e8 Merge pull request #1005 from Fr1ngg/bedolaga-gg86kp
Align miniapp payment limits and status UX
2025-10-10 03:28:15 +03:00
Egor 3c879914ca Align miniapp payment limits and status UI 2025-10-10 03:27:54 +03:00
Egor d4f8d9b94d Merge pull request #1004 from Fr1ngg/revert-1003-bedolaga-d7n0dr
Revert "Implement mini app balance top-up flow"
2025-10-10 02:56:31 +03:00
Egor f44e6aca6f Revert "Implement mini app balance top-up flow" 2025-10-10 02:56:21 +03:00
Egor af273c5762 Merge pull request #1003 from Fr1ngg/bedolaga-d7n0dr
Implement mini app balance top-up flow
2025-10-10 02:54:56 +03:00
Egor c24ba07751 Add balance top-up flow to mini app 2025-10-10 02:54:33 +03:00
Egor 1fb1dadd9d Merge pull request #1002 from Fr1ngg/revert-1001-bedolaga-qilzdq
Revert "Add miniapp balance top up flow"
2025-10-10 02:54:07 +03:00
Egor 83536d7f3d Revert "Add miniapp balance top up flow" 2025-10-10 02:53:56 +03:00
Egor c2be32783f Merge pull request #1001 from Fr1ngg/bedolaga-qilzdq
Add miniapp balance top up flow
2025-10-10 02:39:26 +03:00
Egor 6654608aba feat: add miniapp balance top up flow 2025-10-10 02:39:10 +03:00
Egor 6f29265913 Merge pull request #1000 from Fr1ngg/revert-999-bedolaga-r7wllt
Revert "feat: enable balance top up flow in miniapp"
2025-10-10 02:38:31 +03:00
Egor 95b42d6e9b Revert "feat: enable balance top up flow in miniapp" 2025-10-10 02:38:23 +03:00
Egor e76975388e Merge pull request #999 from Fr1ngg/bedolaga-r7wllt
feat: enable balance top up flow in miniapp
2025-10-10 02:36:16 +03:00
Egor 43ebb11793 feat: enable balance top up flow in miniapp 2025-10-10 02:35:46 +03:00
Egor e6d373d9bd Merge pull request #998 from Fr1ngg/ywf16j-bedolaga/add-squad-migration-feature-to-bot
Add RemnaWave squad migration API endpoints
2025-10-10 02:10:50 +03:00
Egor bea83a3635 Add RemnaWave squad migration API endpoints 2025-10-10 02:10:34 +03:00
Egor ba73236ca1 Merge pull request #997 from Fr1ngg/revert-996-9r5j29-bedolaga/add-squad-migration-feature-to-bot
Revert "Add Remnawave squad migration admin workflow"
2025-10-10 02:09:43 +03:00
Egor 4120d516b4 Revert "Add Remnawave squad migration admin workflow" 2025-10-10 02:09:35 +03:00
Egor 09a46fccad Merge pull request #996 from Fr1ngg/9r5j29-bedolaga/add-squad-migration-feature-to-bot
Add Remnawave squad migration admin workflow
2025-10-10 01:41:41 +03:00
Egor 7b27f39f74 feat: add remnawave squad migration flow 2025-10-10 01:41:20 +03:00
Egor e4b1754e8b Merge pull request #995 from Fr1ngg/dev4
Dev4
2025-10-10 00:47:48 +03:00
Egor 3114bf59de Update config.py 2025-10-10 00:46:57 +03:00
Egor 30b87e13c6 Merge pull request #994 from Fr1ngg/7oguuk-bedolaga/extend-ban-words-filter-in-nicknames
feat: make display name banned keywords configurable
2025-10-10 00:42:37 +03:00
Egor 45d2ca6e25 feat: make display name banned keywords configurable 2025-10-10 00:42:17 +03:00
Egor 42e3fae980 Merge pull request #993 from Fr1ngg/dev4
Dev4
2025-10-09 19:30:54 +03:00
Egor 07c4ab264c Merge pull request #992 from Fr1ngg/b4gp89-bedolaga/fix-rounding-error-for-star-conversion
Fix Telegram Stars rounding and amount formatting
2025-10-09 19:21:28 +03:00
Egor bdaa3b5875 Fix Telegram Stars rounding and formatting 2025-10-09 19:20:44 +03:00
Egor 42696d0564 Merge pull request #991 from Fr1ngg/u9tr9v-bedolaga/fix-balance-top-up-error
Fix handling of Telegram Stars payment callbacks without message text
2025-10-09 19:10:50 +03:00
Egor 3c0c5a3494 Fix topup amount handler for non-text messages 2025-10-09 19:10:33 +03:00
Egor 743c3dabee Merge pull request #990 from Fr1ngg/dev4
w
2025-10-09 19:02:00 +03:00
Egor 3b8708f8e6 Merge pull request #989 from Fr1ngg/v59gno-bedolaga/update-ticket-system-to-use-tg-id
Fix ticket reply notifications to use Telegram IDs
2025-10-09 18:57:08 +03:00
Egor 977042e66c Fix ticket reply notifications to use Telegram IDs 2025-10-09 18:56:43 +03:00
Egor a91f42e9f0 Merge pull request #988 from Fr1ngg/dev4
Dev4
2025-10-09 18:49:42 +03:00
Egor c4d5388081 Merge pull request #987 from Fr1ngg/5ygzlg-bedolaga/update-bot-middleware-to-restrict-users
Ensure display name middleware catches obfuscated keywords
2025-10-09 18:41:55 +03:00
Egor f73a0fcfe8 Tighten keyword detection in display name middleware 2025-10-09 18:41:35 +03:00
Egor 7ff6e42f5b Merge pull request #986 from Fr1ngg/main
w
2025-10-09 18:32:06 +03:00
Egor cdd1e3f107 Update Dockerfile 2025-10-09 08:18:59 +03:00
Egor 6c6ade4f7f Update docker-registry.yml 2025-10-09 08:18:48 +03:00
Egor 30ad692bc2 Update docker-hub.yml 2025-10-09 08:18:37 +03:00
Egor 24b3ea4881 Merge pull request #984 from Fr1ngg/dev4
Апгрейд miniapp с подпиской
2025-10-09 08:13:28 +03:00
Egor 560689d9ee Merge pull request #983 from Fr1ngg/nx6oya-bedolaga/add-device-removal-feature-to-miniapp
Add device removal controls to miniapp
2025-10-09 08:01:30 +03:00
Egor aa763403c6 Add device removal support to miniapp 2025-10-09 08:01:13 +03:00
Egor 96c0c3562d Update index.html 2025-10-09 07:43:37 +03:00
Egor e48eaf584c Merge pull request #982 from Fr1ngg/ybcgxa-bedolaga/add-affiliate-section-to-miniapp
Update referral terms copy
2025-10-09 07:41:13 +03:00
Egor 790cd6bab3 Update referral terms labels 2025-10-09 07:40:57 +03:00
Egor 55ae2bd91c Merge pull request #981 from Fr1ngg/revert-980-jb9c12-bedolaga/add-affiliate-section-to-miniapp
Revert "Normalize referral rewards and adjust mini app spacing"
2025-10-09 07:39:50 +03:00
Egor d431931501 Revert "Normalize referral rewards and adjust mini app spacing" 2025-10-09 07:39:42 +03:00
Egor 425d15c850 Merge pull request #980 from Fr1ngg/jb9c12-bedolaga/add-affiliate-section-to-miniapp
Normalize referral rewards and adjust mini app spacing
2025-10-09 07:28:45 +03:00
Egor 58affda938 Fix referral reward normalization and spacing 2025-10-09 07:28:29 +03:00
Egor c4eb2a60de Merge pull request #979 from Fr1ngg/revert-978-ec880l-bedolaga/add-affiliate-section-to-miniapp
Revert "Fix referral bonus display and layout spacing"
2025-10-09 07:28:05 +03:00
Egor 46dca8c095 Revert "Fix referral bonus display and layout spacing" 2025-10-09 07:27:57 +03:00
Egor b752278398 Merge pull request #978 from Fr1ngg/ec880l-bedolaga/add-affiliate-section-to-miniapp
Fix referral bonus display and layout spacing
2025-10-09 07:24:40 +03:00
Egor 6a659a2131 Fix referral terms display and spacing 2025-10-09 07:24:20 +03:00
Egor 019397b0ea Merge pull request #977 from Fr1ngg/revert-976-tvd4k7-bedolaga/add-affiliate-section-to-miniapp
Revert "Enhance referral mini app card summary"
2025-10-09 07:23:37 +03:00
Egor 947a400909 Revert "Enhance referral mini app card summary" 2025-10-09 07:23:29 +03:00
Egor 7f52beb2a2 Merge pull request #976 from Fr1ngg/tvd4k7-bedolaga/add-affiliate-section-to-miniapp
Enhance referral mini app card summary
2025-10-09 07:06:47 +03:00
Egor 283d42af86 Enhance referral summary in mini app 2025-10-09 07:06:29 +03:00
Egor d36d1e3041 Merge pull request #975 from Fr1ngg/5o8uht-bedolaga/add-promo-code-activation-field
Improve promo code button layout on mini app
2025-10-09 06:32:51 +03:00
Egor a83bc25bd0 Improve promo code button layout on mini app 2025-10-09 06:32:34 +03:00
Egor 3555ada3b5 Merge pull request #974 from Fr1ngg/revert-973-ek2x9x-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation UI to mini app"
2025-10-09 06:27:52 +03:00
Egor 1cb93d9688 Revert "Add promo code activation UI to mini app" 2025-10-09 06:27:44 +03:00
Egor 6f9fc5e071 Merge pull request #973 from Fr1ngg/ek2x9x-bedolaga/add-promo-code-activation-field
Add promo code activation UI to mini app
2025-10-09 06:26:16 +03:00
Egor 96d466328c Add promo code activation flow to mini app 2025-10-09 06:25:47 +03:00
Egor 846e7844db Merge pull request #972 from Fr1ngg/revert-971-f0jtfw-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation support in mini app"
2025-10-09 06:25:19 +03:00
Egor 4e0b0837d9 Revert "Add promo code activation support in mini app" 2025-10-09 06:25:11 +03:00
Egor 8ba5cda095 Merge pull request #971 from Fr1ngg/f0jtfw-bedolaga/add-promo-code-activation-field
Add promo code activation support in mini app
2025-10-09 06:23:08 +03:00
Egor 7ac33bce28 Add promo code activation flow to mini app 2025-10-09 06:22:51 +03:00
Egor d8a0c166ac Merge pull request #970 from Fr1ngg/revert-969-x3v2no-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation UI to mini app"
2025-10-09 06:22:14 +03:00
Egor c1858fe925 Revert "Add promo code activation UI to mini app" 2025-10-09 06:22:06 +03:00
Egor dae8e4c34c Merge pull request #969 from Fr1ngg/x3v2no-bedolaga/add-promo-code-activation-field
Add promo code activation UI to mini app
2025-10-09 06:19:52 +03:00
Egor 3d7c219fd1 feat: add promo code activation to miniapp 2025-10-09 06:19:37 +03:00
Egor 03781463e4 Merge pull request #968 from Fr1ngg/nnkucu-bedolaga/add-faq-section-to-/miniapp/index.html
Expose legal documents in mini app subscription view
2025-10-09 06:02:54 +03:00
Egor dcd9074136 Expose legal documents in mini app 2025-10-09 06:02:37 +03:00
Egor 7fbd26c8c3 Merge pull request #967 from Fr1ngg/revert-966-wq02f5-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "Add FAQ section to mini app"
2025-10-09 06:02:05 +03:00
Egor 9677a55375 Revert "Add FAQ section to mini app" 2025-10-09 06:01:50 +03:00
Egor eb51cda617 Merge pull request #966 from Fr1ngg/wq02f5-bedolaga/add-faq-section-to-/miniapp/index.html
Add FAQ section to mini app
2025-10-09 05:49:20 +03:00
Egor 09bdde6f91 Add FAQ section to mini app 2025-10-09 05:49:01 +03:00
Egor 784b2ed996 Merge pull request #965 from Fr1ngg/revert-964-cg1tpe-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "Add FAQ support to mini app subscription page"
2025-10-09 05:48:47 +03:00
Egor 9bee4a2365 Revert "Add FAQ support to mini app subscription page" 2025-10-09 05:48:39 +03:00
Egor f8946b99f3 Merge pull request #964 from Fr1ngg/cg1tpe-bedolaga/add-faq-section-to-/miniapp/index.html
Add FAQ support to mini app subscription page
2025-10-09 05:47:44 +03:00
Egor a17cbf4448 Add FAQ section to miniapp and expose data 2025-10-09 05:47:27 +03:00
Egor b0422e0c0f Merge pull request #963 from Fr1ngg/revert-962-io42y3-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "feat: show FAQ in mini app"
2025-10-09 05:47:09 +03:00
Egor 9d0bf8b3cf Revert "feat: show FAQ in mini app" 2025-10-09 05:47:00 +03:00
Egor 8c5e8ca019 Merge pull request #962 from Fr1ngg/io42y3-bedolaga/add-faq-section-to-/miniapp/index.html
feat: show FAQ in mini app
2025-10-09 05:45:55 +03:00
Egor 0327adb8da feat: show FAQ in mini app 2025-10-09 05:45:40 +03:00
Egor 5769fa63a5 Merge pull request #961 from Fr1ngg/rkljpj-bedolaga/add-promo-offer-display-on-subscription-page
Display active test access promo offers
2025-10-09 05:20:01 +03:00
Egor 1d09006b7c Display active test access promo offers 2025-10-09 05:19:44 +03:00
Egor e194ab9729 Merge pull request #960 from Fr1ngg/revert-959-36674g-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Fix promo offer timers by normalizing date parsing"
2025-10-09 05:18:46 +03:00
Egor 45a6173916 Revert "Fix promo offer timers by normalizing date parsing" 2025-10-09 05:18:38 +03:00
Egor b21baba006 Merge pull request #959 from Fr1ngg/36674g-bedolaga/add-promo-offer-display-on-subscription-page
Fix promo offer timers by normalizing date parsing
2025-10-09 05:09:51 +03:00
Egor 45c1b4695c Fix promo offer timer timezone handling 2025-10-09 05:09:31 +03:00
Egor 9b2b28423d Merge pull request #958 from Fr1ngg/revert-957-xzisus-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Add promo offer display to subscription miniapp"
2025-10-09 05:07:44 +03:00
Egor 9d00c2ddc6 Revert "Add promo offer display to subscription miniapp" 2025-10-09 05:07:35 +03:00
Egor dc66676663 Merge pull request #957 from Fr1ngg/xzisus-bedolaga/add-promo-offer-display-on-subscription-page
Add promo offer display to subscription miniapp
2025-10-09 04:54:21 +03:00
Egor 1d600e2c63 Add promo offer display to subscription miniapp 2025-10-09 04:54:05 +03:00
Egor 5ce177a7b5 Merge pull request #956 from Fr1ngg/revert-955-4ttjow-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Add promo offer banners to mini app"
2025-10-09 04:53:29 +03:00
Egor d181a333d7 Revert "Add promo offer banners to mini app" 2025-10-09 04:53:20 +03:00
Egor 3694cb4ed7 Merge pull request #955 from Fr1ngg/4ttjow-bedolaga/add-promo-offer-display-on-subscription-page
Add promo offer banners to mini app
2025-10-09 04:51:18 +03:00
Egor 051048e0ae Add promo offer banners to mini app 2025-10-09 04:50:46 +03:00
Egor 61ee6e42cf Merge pull request #954 from Fr1ngg/revert-953-x57z5w-bedolaga/add-promo-offer-display-on-subscription-page
Revert "feat: surface promo offers in mini app"
2025-10-09 04:50:35 +03:00
Egor 5bd618a799 Revert "feat: surface promo offers in mini app" 2025-10-09 04:50:27 +03:00
Egor 7794a4cc7b Merge pull request #953 from Fr1ngg/x57z5w-bedolaga/add-promo-offer-display-on-subscription-page
feat: surface promo offers in mini app
2025-10-09 04:43:18 +03:00
Egor 6d01d32dc2 feat: show promo offers in miniapp 2025-10-09 04:42:58 +03:00
Egor be71def203 Update index.html 2025-10-09 04:22:01 +03:00
Egor 7a2de410bd Update index.html 2025-10-09 04:15:51 +03:00
Egor b019e8b32d Update index.html 2025-10-09 04:11:57 +03:00
Egor cb4f773eb1 Update index.html 2025-10-09 04:09:28 +03:00
Egor 29fe9f4943 Merge pull request #952 from Fr1ngg/hycxed-bedolaga/add-user-promotion-group-display
Refine mini app promo section layout and discount display
2025-10-09 04:03:51 +03:00
Egor 63edfa852d Improve promo section presentation and discounts 2025-10-09 04:03:37 +03:00
Egor 66fabb6836 Merge pull request #951 from Fr1ngg/revert-950-5ob0h8-bedolaga/add-user-promotion-group-display
Revert "Improve promo group UI and period discounts"
2025-10-09 04:03:19 +03:00
Egor f29ea49185 Revert "Improve promo group UI and period discounts" 2025-10-09 04:03:10 +03:00
Egor 7620a3df92 Merge pull request #950 from Fr1ngg/5ob0h8-bedolaga/add-user-promotion-group-display
Improve promo group UI and period discounts
2025-10-09 04:02:20 +03:00
Egor 577bcfdc88 Improve promo group UI and period discounts 2025-10-09 04:02:00 +03:00
Egor 33ec09edd0 Merge pull request #949 from Fr1ngg/revert-948-ess9tg-bedolaga/add-user-promotion-group-display
Revert "Improve promo group UI with collapsible discounts"
2025-10-09 04:01:47 +03:00
Egor 7b3c463fa2 Revert "Improve promo group UI with collapsible discounts" 2025-10-09 04:01:40 +03:00
Egor 0e541af5e1 Merge pull request #948 from Fr1ngg/ess9tg-bedolaga/add-user-promotion-group-display
Improve promo group UI with collapsible discounts
2025-10-09 04:00:32 +03:00
Egor e39822e732 Improve promo group UI with collapsible discounts 2025-10-09 04:00:18 +03:00
Egor c7973e7b58 Merge pull request #947 from Fr1ngg/revert-946-0zyc5p-bedolaga/add-user-promotion-group-display
Revert "Display promo group discounts in mini app"
2025-10-09 03:59:47 +03:00
Egor 4941052d57 Revert "Display promo group discounts in mini app" 2025-10-09 03:59:40 +03:00
Egor 0022abc6e0 Merge pull request #946 from Fr1ngg/0zyc5p-bedolaga/add-user-promotion-group-display
Display promo group discounts in mini app
2025-10-09 03:45:11 +03:00
Egor acef77b0e8 Display promo group discounts in mini app 2025-10-09 03:44:54 +03:00
Egor 4ddd580347 Merge pull request #945 from Fr1ngg/revert-944-nafa9w-bedolaga/add-user-promotion-group-display
Revert "Display promo level discounts in the mini app"
2025-10-09 03:44:44 +03:00
Egor 42cc4db077 Revert "Display promo level discounts in the mini app" 2025-10-09 03:44:35 +03:00
Egor 8e3f976441 Merge pull request #944 from Fr1ngg/nafa9w-bedolaga/add-user-promotion-group-display
Display promo level discounts in the mini app
2025-10-09 03:40:28 +03:00
Egor 190d9ae13e Show promo level discounts when available 2025-10-09 03:39:59 +03:00
Egor d6deec1a29 Merge pull request #943 from Fr1ngg/revert-942-38969u-bedolaga/add-user-promotion-group-display
Revert "Display promo level discounts in mini app"
2025-10-09 03:39:44 +03:00
Egor e8dcbd09ff Revert "Display promo level discounts in mini app" 2025-10-09 03:39:33 +03:00
Egor 382f1eb6f7 Merge pull request #942 from Fr1ngg/38969u-bedolaga/add-user-promotion-group-display
Display promo level discounts in mini app
2025-10-09 03:38:30 +03:00
Egor 2853410ebc Show discount details for auto promo groups 2025-10-09 03:38:14 +03:00
Egor 2bde6a83ed Merge pull request #941 from Fr1ngg/revert-940-gbzjeq-bedolaga/add-user-promotion-group-display
Revert "Show promo group levels in mini app subscription view"
2025-10-09 03:36:29 +03:00
Egor 83b1fa0f0f Revert "Show promo group levels in mini app subscription view" 2025-10-09 03:36:21 +03:00
Egor 3a2a05d307 Merge pull request #940 from Fr1ngg/gbzjeq-bedolaga/add-user-promotion-group-display
Show promo group levels in mini app subscription view
2025-10-09 03:25:40 +03:00
Egor 2460766e30 Show promo group info and auto levels in miniapp 2025-10-09 03:25:25 +03:00
Egor 3eb01eaabc Merge pull request #939 from Fr1ngg/revert-938-pwo411-bedolaga/add-user-promotion-group-display
Revert "Show promo group and auto levels in mini app"
2025-10-09 03:24:36 +03:00
Egor 35bca4166d Revert "Show promo group and auto levels in mini app" 2025-10-09 03:24:28 +03:00
Egor f2db938fdc Merge pull request #938 from Fr1ngg/pwo411-bedolaga/add-user-promotion-group-display
Show promo group and auto levels in mini app
2025-10-09 03:22:18 +03:00
Egor ff0c68afc6 Show promo group and auto levels in mini app 2025-10-09 03:21:40 +03:00
Egor 67babf7566 Update README.md 2025-10-08 20:21:06 +03:00
Egor f71788d2d4 Update README.md 2025-10-08 20:11:24 +03:00
Egor 5eebdcc85c Update README.md 2025-10-08 20:02:57 +03:00
Egor 4b89de9634 Update README.md 2025-10-08 19:57:45 +03:00
Egor b81d5b199b Update README.md 2025-10-08 19:55:33 +03:00
Egor a21559cd0d Merge pull request #931 from Fr1ngg/jehc2b-bedolaga/-readme
docs: expand README functional overview
2025-10-08 19:46:23 +03:00
Egor 6cd3f87318 docs: expand feature overview in README 2025-10-08 19:46:04 +03:00
Egor f9e416341f Update Dockerfile 2025-10-08 08:08:25 +03:00
Egor 8a2149d2f8 Update docker-registry.yml 2025-10-08 08:08:15 +03:00
Egor e0f6e3b1ef Update docker-hub.yml 2025-10-08 08:08:01 +03:00
Egor 8948f4d302 Merge pull request #916 from Fr1ngg/b20uxt-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Prevent double counting server counters on paid subscriptions
2025-10-08 07:54:53 +03:00
Egor d13ff6830b Gate server counter updates for paid subscriptions 2025-10-08 07:54:31 +03:00
Egor b8aee33e88 Merge pull request #915 from Fr1ngg/revert-914-1cynlm-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Revert "Update paid subscription creation to bump server counters"
2025-10-08 07:51:42 +03:00
Egor 17639f3a67 Revert "Update paid subscription creation to bump server counters" 2025-10-08 07:51:32 +03:00
Egor cb77b5a21c Merge pull request #914 from Fr1ngg/1cynlm-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Update paid subscription creation to bump server counters
2025-10-08 07:48:01 +03:00
Egor 012ba2049e Update paid subscription creation to bump server counters 2025-10-08 07:47:40 +03:00
Egor 6acfcc9966 Merge pull request #913 from Fr1ngg/dev2
Рандомные сквады для триала, конфигурация доступных пакетов трафика через админку
2025-10-08 07:36:14 +03:00
Egor f6839c4184 Merge pull request #912 from Fr1ngg/pa8726-bedolaga/add-traffic-package-management-in-admin-panel
Clear admin price input messages after saving
2025-10-08 07:31:53 +03:00
Egor 1fbdcb7391 Remove success message when updating pricing 2025-10-08 07:31:36 +03:00
Egor 50a65d06d4 Merge pull request #911 from Fr1ngg/revert-910-owno31-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin controls for traffic package visibility"
2025-10-08 07:31:10 +03:00
Egor 20855e5bfc Revert "Add admin controls for traffic package visibility" 2025-10-08 07:30:56 +03:00
Egor 2e4317e457 Merge pull request #910 from Fr1ngg/owno31-bedolaga/add-traffic-package-management-in-admin-panel
Add admin controls for traffic package visibility
2025-10-08 07:26:18 +03:00
Egor 89dd41ef43 Add admin controls for traffic package visibility 2025-10-08 07:26:04 +03:00
Egor 7674b0325b Merge pull request #909 from Fr1ngg/revert-908-pn3n44-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin controls for traffic package visibility"
2025-10-08 07:25:45 +03:00
Egor 8a5141b58c Revert "Add admin controls for traffic package visibility" 2025-10-08 07:25:36 +03:00
Egor a819be0b5a Merge pull request #908 from Fr1ngg/pn3n44-bedolaga/add-traffic-package-management-in-admin-panel
Add admin controls for traffic package visibility
2025-10-08 07:23:52 +03:00
Egor 7de4730822 Add admin toggles for traffic packages 2025-10-08 07:23:36 +03:00
Egor b4815b291e Merge pull request #907 from Fr1ngg/revert-906-9sm5by-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin UI toggles for traffic packages"
2025-10-08 07:23:23 +03:00
Egor 68da3d7d30 Revert "Add admin UI toggles for traffic packages" 2025-10-08 07:23:14 +03:00
Egor ca13385dee Merge pull request #906 from Fr1ngg/9sm5by-bedolaga/add-traffic-package-management-in-admin-panel
Add admin UI toggles for traffic packages
2025-10-08 07:20:38 +03:00
Egor 9216128eb6 Add traffic package toggle controls in admin pricing 2025-10-08 07:20:23 +03:00
Egor 3d1fe09540 Merge pull request #905 from Fr1ngg/vkeste-bedolaga/fix-trial-server-availability-issue
Allow trial squads to be issued when inactive
2025-10-08 07:07:21 +03:00
Egor 07b8e04afe Allow trial squad selection when inactive 2025-10-08 07:07:06 +03:00
Egor 01039a969a Merge pull request #904 from Fr1ngg/8pyo0r-bedolaga/fix-user-counter-update-on-servers
Fix server user counts when subscriptions are removed
2025-10-08 06:58:10 +03:00
Egor a020db5bc6 Fix server counters when subscriptions are removed 2025-10-08 06:57:53 +03:00
Egor a91baf2acd Merge pull request #903 from Fr1ngg/revert-902-njiwqq-bedolaga/fix-user-counter-update-on-servers
Revert "Update trial subscription server counts automatically"
2025-10-08 06:57:31 +03:00
Egor 1caa2b2ba2 Revert "Update trial subscription server counts automatically" 2025-10-08 06:57:22 +03:00
Egor a7e9f732a2 Merge pull request #902 from Fr1ngg/njiwqq-bedolaga/fix-user-counter-update-on-servers
Update trial subscription server counts automatically
2025-10-08 06:44:43 +03:00
Egor c8794722a2 Update trial subscription server counts automatically 2025-10-08 06:44:28 +03:00
Egor bbdb3e454e Merge pull request #901 from Fr1ngg/revert-900-zf3oh7-bedolaga/fix-user-counter-update-issue
Revert "Fix server user counters for nullable values"
2025-10-08 06:36:07 +03:00
Egor fa693494d0 Revert "Fix server user counters for nullable values" 2025-10-08 06:35:45 +03:00
Egor 02c38a25ea Merge pull request #900 from Fr1ngg/zf3oh7-bedolaga/fix-user-counter-update-issue
Fix server user counters for nullable values
2025-10-08 06:34:03 +03:00
Egor d5d60a8c7a Fix server user counters for nullable values 2025-10-08 06:33:44 +03:00
Egor 864aaae017 Merge pull request #899 from Fr1ngg/revert-898-kkzy84-bedolaga/fix-user-counter-update-issue
Revert "Expire server cache after counter updates"
2025-10-08 06:33:17 +03:00
Egor 413173270d Revert "Expire server cache after counter updates" 2025-10-08 06:33:09 +03:00
Egor b8eb6606f1 Merge pull request #898 from Fr1ngg/kkzy84-bedolaga/fix-user-counter-update-issue
Expire server cache after counter updates
2025-10-08 06:31:40 +03:00
Egor 83226c8f3d Expire server cache after counter updates 2025-10-08 06:31:16 +03:00
Egor 8a96cddb4f Merge pull request #897 from Fr1ngg/revert-896-z6lcfu-bedolaga/fix-user-counter-update-issue
Revert "Fix server squad user counters on removal"
2025-10-08 06:30:14 +03:00
Egor e32f3191e4 Revert "Fix server squad user counters on removal" 2025-10-08 06:30:05 +03:00
Egor acb5c3c1d7 Merge pull request #896 from Fr1ngg/z6lcfu-bedolaga/fix-user-counter-update-issue
Fix server squad user counters on removal
2025-10-08 06:27:18 +03:00
Egor cf13b49b3c Fix server squad user counters on removal 2025-10-08 06:26:41 +03:00
Egor 2049765969 Merge pull request #895 from Fr1ngg/tkq4hh-bedolaga/refactor-trial-squad-selection-in-bot
feat: randomize trial squad distribution
2025-10-08 06:07:56 +03:00
Egor 6b2af1f4a3 feat: randomize trial squad distribution 2025-10-08 06:07:31 +03:00
Egor 00d080cbfb Merge pull request #894 from Fr1ngg/j4qq3i-bedolaga/add-admin-panel-for-menu-button-management
Add admin tools for configurable main menu buttons
2025-10-08 06:00:00 +03:00
Egor d0ce5438a8 Add admin API and service for dynamic main menu buttons 2025-10-08 05:59:37 +03:00
Egor 05914c4e14 Merge pull request #893 from Fr1ngg/dev2
Включение автоплатежа по дефолту
2025-10-08 05:21:12 +03:00
Egor a040d03df0 Merge pull request #892 from Fr1ngg/bj3dv4-bedolaga/add-default-auto-payment-setting-for-users
Enable configurable default autopay
2025-10-08 05:17:30 +03:00
Egor a29e0d5fb4 Update config.py 2025-10-08 05:17:12 +03:00
Egor ea7697fd66 Enable configurable default autopay 2025-10-08 05:16:11 +03:00
Egor ab86296256 Merge pull request #891 from Fr1ngg/dev2
Возможность уменьшать кол-во дней подписки юзерам
2025-10-08 05:00:57 +03:00
Egor 9bb384788f Merge pull request #890 from Fr1ngg/c9cmzh-bedolaga/add-ability-to-decrease-subscription-days
Allow admins to decrease user subscription days
2025-10-08 04:56:52 +03:00
Egor 6fc4eb033b Allow admins to decrease subscription days 2025-10-08 04:56:33 +03:00
Egor 8a8abf5b96 Merge pull request #889 from Fr1ngg/revert-888-fggclp-bedolaga/adapt-connection-page-for-app-schemes
Revert "feat: support dynamic miniapp app selection"
2025-10-08 04:50:32 +03:00
Egor 01e68bec68 Revert "feat: support dynamic miniapp app selection" 2025-10-08 04:50:22 +03:00
Egor 7b6488d166 Merge pull request #888 from Fr1ngg/fggclp-bedolaga/adapt-connection-page-for-app-schemes
feat: support dynamic miniapp app selection
2025-10-08 04:46:21 +03:00
Egor 6c4e4b366a feat: support dynamic miniapp app selection 2025-10-08 04:46:03 +03:00
Egor 1defa938ba Merge pull request #887 from Fr1ngg/revert-886-4mz7rm-bedolaga/adapt-connection-page-for-app-schemes
Revert "Adapt miniapp connection UX for per-app schemes"
2025-10-08 04:45:23 +03:00
Egor efaac8ff76 Revert "Adapt miniapp connection UX for per-app schemes" 2025-10-08 04:45:13 +03:00
Egor 9246312c68 Merge pull request #886 from Fr1ngg/4mz7rm-bedolaga/adapt-connection-page-for-app-schemes
Adapt miniapp connection UX for per-app schemes
2025-10-08 04:41:21 +03:00
Egor d4bbea616f feat: update miniapp connection flows 2025-10-08 04:41:01 +03:00
Egor 1be8e1a60f Merge pull request #885 from Fr1ngg/dev2
Вывод перечеркнутой исходной цены и скидочной вместе при продлении
2025-10-08 04:33:57 +03:00
Egor 0f1147a226 Merge pull request #884 from Fr1ngg/q6yun8-bedolaga/show-crossed-out-and-discounted-prices
Show strikethrough renewal prices when discounts apply
2025-10-08 04:30:22 +03:00
Egor dc7d0fa8d6 Show original prices when subscription renewal discounts apply 2025-10-08 04:30:05 +03:00
Egor 1fc4686490 Merge pull request #883 from Fr1ngg/revert-882-wu6jus-bedolaga/adapt-connection-page-for-app-schemes
Revert "Update miniapp app selection and connection flows"
2025-10-08 04:29:31 +03:00
Egor 8378020a3e Revert "Update miniapp app selection and connection flows" 2025-10-08 04:29:21 +03:00
Egor bce31ea9a4 Merge pull request #882 from Fr1ngg/wu6jus-bedolaga/adapt-connection-page-for-app-schemes
Update miniapp app selection and connection flows
2025-10-08 04:27:48 +03:00
Egor 5b4337a436 Update miniapp connection flows per app selection 2025-10-08 04:26:16 +03:00
Egor ac0953a221 Merge pull request #881 from Fr1ngg/dev2
Потерянные локали и обновление механизма синхронизации с remnawave (синк со временем из докера)
2025-10-08 04:15:27 +03:00
Egor bfdb17d653 Merge pull request #880 from Fr1ngg/iocwov-bedolaga/fix-subscription-datetime-handling
Fix disabled subscription end date timezone
2025-10-08 03:57:37 +03:00
Egor 3159e80897 Fix disabled subscription end date timezone 2025-10-08 03:57:00 +03:00
Egor 35d3fb1916 Merge pull request #879 from Fr1ngg/revert-878-xg90tu-bedolaga/fix-subscription-datetime-handling
Revert "Normalize subscription timestamps to UTC"
2025-10-08 03:56:41 +03:00
Egor 4be51bbe51 Revert "Normalize subscription timestamps to UTC" 2025-10-08 03:56:31 +03:00
Egor 2416bd6ad5 Merge pull request #878 from Fr1ngg/xg90tu-bedolaga/fix-subscription-datetime-handling
Normalize subscription timestamps to UTC
2025-10-08 03:54:54 +03:00
Egor b3697b1871 Normalize subscription timestamps to UTC 2025-10-08 03:54:37 +03:00
Egor 483fab8427 Merge pull request #877 from Fr1ngg/dor5e8-bedolaga/fix-user-sync-ignoring-time-zone
Fix RemnaWave user sync timezone handling
2025-10-08 03:47:19 +03:00
Egor 95ae04d553 Fix RemnaWave user sync timezone handling 2025-10-08 03:47:03 +03:00
Egor a6d8e77f9e Merge pull request #876 from Fr1ngg/revert-875-t8r8ec-bedolaga/fix-user-sync-ignoring-time-zone
Revert "Normalize RemnaWave expiry dates to UTC"
2025-10-08 03:46:47 +03:00
Egor ae4814d1cb Revert "Normalize RemnaWave expiry dates to UTC" 2025-10-08 03:46:39 +03:00
Egor fef4f81744 Merge pull request #875 from Fr1ngg/t8r8ec-bedolaga/fix-user-sync-ignoring-time-zone
Normalize RemnaWave expiry dates to UTC
2025-10-08 03:43:50 +03:00
Egor 65057348e9 Normalize RemnaWave expiry dates to UTC 2025-10-08 03:43:35 +03:00
Egor 4d32e509e0 Merge pull request #874 from Fr1ngg/revert-873-virkxj-bedolaga/fix-user-sync-ignoring-time-zone
Revert "Fix RemnaWave sync timezone handling"
2025-10-08 03:43:02 +03:00
Egor 8e336e1f7b Revert "Fix RemnaWave sync timezone handling" 2025-10-08 03:42:53 +03:00
Egor ee7078705b Merge pull request #873 from Fr1ngg/virkxj-bedolaga/fix-user-sync-ignoring-time-zone
Fix RemnaWave sync timezone handling
2025-10-08 03:33:46 +03:00
Egor 48fe845ff4 Fix RemnaWave sync timezone handling 2025-10-08 03:33:32 +03:00
Egor 99c882110a Merge pull request #872 from Fr1ngg/bedolaga-g891y6
Add missing Russian privacy policy locale strings
2025-10-08 03:27:31 +03:00
Egor a4f1323a81 Add missing Russian privacy policy translations 2025-10-08 03:27:11 +03:00
Egor aad1bb8805 Merge pull request #871 from Fr1ngg/dev2
Раздел (Скидки за траты) в Инфо
2025-10-08 03:08:37 +03:00
Egor d2eb1d35db Update ru.json 2025-10-08 03:07:39 +03:00
Egor d6fa2a8f3c Update ru.json 2025-10-08 03:06:59 +03:00
Egor 32b074efd4 Merge pull request #870 from Fr1ngg/rm42ap-bedolaga/add-promo-groups-section-to-bot
Add promo group progress overview to Info menu
2025-10-08 02:57:47 +03:00
Egor 787d801722 Add promo groups info section 2025-10-08 02:57:31 +03:00
Egor 91c3f2766a Merge pull request #869 from Fr1ngg/dev2
Dev2
2025-10-08 02:44:38 +03:00
Egor 911a20a4e3 Merge pull request #868 from Fr1ngg/main
w
2025-10-08 02:39:16 +03:00
Egor 8e04a0e2b5 Merge pull request #867 from Fr1ngg/lf0mas-bedolaga/add-external-admin-panel-settings
Очистить токен внешней админки при подмене
2025-10-08 02:31:37 +03:00
Egor 6225463af1 Очистка токена внешней админки при подмене 2025-10-08 02:31:12 +03:00
Egor d4badacd4a Merge pull request #866 from Fr1ngg/revert-865-auudl3-bedolaga/add-external-admin-panel-settings
Revert "Защитить токен внешней админки от подмены"
2025-10-08 02:30:48 +03:00
Egor 63bf04bb70 Revert "Защитить токен внешней админки от подмены" 2025-10-08 02:30:39 +03:00
Egor 2529c9bca2 Merge pull request #865 from Fr1ngg/auudl3-bedolaga/add-external-admin-panel-settings
Защитить токен внешней админки от подмены
2025-10-08 02:18:11 +03:00
Egor bc09d2f610 Защитить токен внешней админки от подмены 2025-10-08 02:17:57 +03:00
Egor d530d7a296 Merge pull request #864 from Fr1ngg/revert-863-mvul3c-bedolaga/add-external-admin-panel-settings
Revert "Усилить генерацию токена внешней админки"
2025-10-08 02:17:23 +03:00
Egor bea0977a86 Revert "Усилить генерацию токена внешней админки" 2025-10-08 02:17:13 +03:00
Egor 634b2782d8 Merge pull request #863 from Fr1ngg/mvul3c-bedolaga/add-external-admin-panel-settings
Усилить генерацию токена внешней админки
2025-10-08 01:56:32 +03:00
Egor 0e28cfa326 Усилить генерацию токена внешней админки 2025-10-08 01:56:16 +03:00
Egor 3d2ca8a02d Merge pull request #862 from Fr1ngg/revert-861-hb3c6g-bedolaga/add-external-admin-panel-settings
Revert "Добавить токен внешней админки и защиту настройки"
2025-10-08 01:55:28 +03:00
Egor e0e673e7bb Revert "Добавить токен внешней админки и защиту настройки" 2025-10-08 01:55:17 +03:00
Egor 0d4cea1e22 Merge pull request #861 from Fr1ngg/hb3c6g-bedolaga/add-external-admin-panel-settings
Добавить токен внешней админки и защиту настройки
2025-10-08 01:46:24 +03:00
Egor b643f45c01 Добавить токен внешней админки и защиту настройки 2025-10-08 01:46:06 +03:00
Egor b5b30a07b8 Merge pull request #860 from Fr1ngg/revert-859-lxh4tz-bedolaga/add-external-admin-panel-settings
Revert "Add external admin token support"
2025-10-08 01:45:47 +03:00
Egor 09ea9b0193 Revert "Add external admin token support" 2025-10-08 01:45:35 +03:00
Egor cf76ac7893 Merge pull request #859 from Fr1ngg/lxh4tz-bedolaga/add-external-admin-panel-settings
Add external admin token support
2025-10-08 01:42:46 +03:00
Egor 83b370669e Add external admin token support 2025-10-08 01:42:30 +03:00
Egor 6735cf17cf Merge pull request #858 from Fr1ngg/revert-857-m9d0zq-bedolaga/-api
Revert "Add user API tokens and verification endpoint"
2025-10-08 01:26:50 +03:00
Egor 422686dd2c Revert "Add user API tokens and verification endpoint" 2025-10-08 01:26:42 +03:00
Egor 147dc0343c Merge pull request #857 from Fr1ngg/m9d0zq-bedolaga/-api
Add user API tokens and verification endpoint
2025-10-08 01:24:30 +03:00
Egor cdff9c4666 Add user API token management and external verification endpoint 2025-10-08 01:24:12 +03:00
Egor 25db434e47 Merge pull request #856 from Fr1ngg/revert-855-iex73b-bedolaga/-api
Revert "Add user-linked API tokens and issuance flow"
2025-10-08 01:23:47 +03:00
Egor 5a8aeadcd9 Revert "Add user-linked API tokens and issuance flow" 2025-10-08 01:23:38 +03:00
Egor bdceb9c3cf Merge pull request #855 from Fr1ngg/iex73b-bedolaga/-api
Add user-linked API tokens and issuance flow
2025-10-08 01:19:44 +03:00
Egor f43e0daa69 Add user API tokens linked to Telegram users 2025-10-08 01:19:24 +03:00
Egor c24c668299 Update Dockerfile 2025-10-07 06:47:33 +03:00
Egor 41df40eed3 Update docker-registry.yml 2025-10-07 06:47:26 +03:00
Egor b1975cc3ff Update docker-hub.yml 2025-10-07 06:47:14 +03:00
Egor 369e2c3c36 Merge pull request #853 from Fr1ngg/dev2
Дополнительные методы API для работы со страницами из ИНФО + вынесение хостов в конфигурации для платежек
2025-10-07 06:35:19 +03:00
Egor e2ec4b05db Merge pull request #852 from Fr1ngg/r6u84x-bedolaga/extract-host-to-bot-configuration
Allow configuring webhook hosts
2025-10-07 06:32:21 +03:00
Egor 7493b13348 Allow configuring YooKassa webhook host 2025-10-07 06:32:03 +03:00
Egor 0a65aa16cd Merge pull request #851 from Fr1ngg/461rmg-bedolaga/add-api-endpoints-for-offer-faq-terms
Allow FAQ creation API to pass display order and status
2025-10-07 06:29:22 +03:00
Egor 64a4ece0fe Allow FAQ creation API to pass display order and status 2025-10-07 06:28:26 +03:00
Egor 71664cca3e Merge pull request #850 from Fr1ngg/revert-847-avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Revert "Add informational pages endpoints to admin web API"
2025-10-07 06:28:09 +03:00
Egor b21177f685 Revert "Add informational pages endpoints to admin web API" 2025-10-07 06:28:00 +03:00
Egor cde6aa4c26 Merge pull request #849 from Fr1ngg/revert-848-5sy6f4-bedolaga/extract-host-to-bot-configuration
Revert "Allow configuring Tribute webhook host"
2025-10-07 06:26:51 +03:00
Egor 5264fc0d90 Revert "Allow configuring Tribute webhook host" 2025-10-07 06:26:37 +03:00
Egor 7aa2004f27 Merge pull request #848 from Fr1ngg/5sy6f4-bedolaga/extract-host-to-bot-configuration
Allow configuring Tribute webhook host
2025-10-07 06:23:26 +03:00
Egor 40a34d1e55 Allow configuring Tribute webhook host 2025-10-07 06:22:41 +03:00
Egor 4204b459e2 Merge pull request #847 from Fr1ngg/avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Add informational pages endpoints to admin web API
2025-10-07 06:21:03 +03:00
Egor cbe80e9447 Add pages management endpoints to web API 2025-10-07 06:20:44 +03:00
Egor 0cfd8d3c7b Merge pull request #846 from Fr1ngg/dev2
FAQ, Политика Конф, Оферта
2025-10-07 06:08:48 +03:00
Egor ddfa61c56f Merge pull request #845 from Fr1ngg/revert-842-revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Revert "Add FAQ management and user menu support""
2025-10-07 06:03:01 +03:00
Egor 7e340bc13a Revert "Revert "Add FAQ management and user menu support"" 2025-10-07 06:02:50 +03:00
Egor f7dd3be27c Merge pull request #844 from Fr1ngg/rcd0rc-bedolaga/add-offer-button-to-bot-info-section
Fix admin offer preview HTML truncation
2025-10-07 06:02:38 +03:00
Egor 3ff06ed9d1 Use paginator for admin offer preview 2025-10-07 06:02:19 +03:00
Egor 08d6a26b23 Merge pull request #843 from Fr1ngg/revert-841-ptoawe-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 06:01:38 +03:00
Egor 37e472d78a Revert "Add configurable public offer info section" 2025-10-07 06:01:25 +03:00
Egor 8cc04e01e9 Merge pull request #842 from Fr1ngg/revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Add FAQ management and user menu support"
2025-10-07 06:01:18 +03:00
Egor c199d04b91 Revert "Add FAQ management and user menu support" 2025-10-07 06:01:09 +03:00
Egor 102d5d0a9c Merge pull request #839 from Fr1ngg/g9mnci-bedolaga/add-faq-button-to-bot-info-section
Add FAQ management and user menu support
2025-10-07 05:52:53 +03:00
Egor 8e7b44811e Merge pull request #841 from Fr1ngg/ptoawe-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:52:37 +03:00
Egor ab1be9943b Fix HTML paginator preserving entities 2025-10-07 05:52:19 +03:00
Egor 61b702272b Merge pull request #840 from Fr1ngg/revert-838-5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:51:59 +03:00
Egor 84bce0043d Revert "Add configurable public offer info section" 2025-10-07 05:51:51 +03:00
Egor f54528f2d5 Add FAQ management and user menu support 2025-10-07 05:47:41 +03:00
Egor b66911f4f7 Merge pull request #838 from Fr1ngg/5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:45:04 +03:00
Egor 352c14331d Handle HTML tags when paginating public offer text 2025-10-07 05:44:16 +03:00
Egor 7ea91f344c Merge pull request #837 from Fr1ngg/revert-836-t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:40:05 +03:00
Egor d2d5b6c823 Revert "Add configurable public offer info section" 2025-10-07 05:39:54 +03:00
Egor 4405c2287a Merge pull request #836 from Fr1ngg/t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:27:38 +03:00
Egor 288a72f739 Fix per-language public offer disabling 2025-10-07 05:27:21 +03:00
Egor 60391a8b89 Merge pull request #835 from Fr1ngg/revert-834-plexvn-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:27:06 +03:00
Egor f2d4da9514 Revert "Add configurable public offer info section" 2025-10-07 05:26:57 +03:00
Egor 648ad24e6d Merge pull request #834 from Fr1ngg/plexvn-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:17:12 +03:00
Egor 5d28cd9930 Add configurable public offer info section 2025-10-07 05:16:53 +03:00
Egor 43974d1685 Merge pull request #833 from Fr1ngg/kq9kol-bedolaga/add-config-policy-button-to-bot-info
Add privacy policy info menu and admin controls
2025-10-07 04:57:06 +03:00
Egor 39ccc7fb4a Add privacy policy section and admin management 2025-10-07 04:56:45 +03:00
Egor d391a46b0e Merge pull request #831 from Fr1ngg/dev2
Расширения функционала отправки промо предложений / отправка конкретному юзеру
2025-10-07 02:39:35 +03:00
Egor 59dd638dd7 Merge pull request #830 from Fr1ngg/bedolaga-5bnc62
Fix promo offer durations and active discount display
2025-10-07 02:35:47 +03:00
Egor 3062061270 Fix promo offer durations and active discount display 2025-10-07 02:34:47 +03:00
Egor 5b0a64964e Merge pull request #829 from Fr1ngg/revert-828-bedolaga-48se3m
Revert "Add detailed promo offer status for user view"
2025-10-07 02:33:52 +03:00
Egor 70e3517d3a Revert "Add detailed promo offer status for user view" 2025-10-07 02:33:44 +03:00
Egor 08b64abb03 Merge pull request #828 from Fr1ngg/bedolaga-48se3m
Add detailed promo offer status for user view
2025-10-07 02:21:14 +03:00
Egor 84a3179974 Add detailed promo offer status for user view 2025-10-07 02:21:02 +03:00
Egor fa56ad8791 Merge pull request #827 from Fr1ngg/revert-826-bedolaga-ztqa24
Revert "Improve promo offer user view with status details"
2025-10-07 02:20:45 +03:00
Egor f584cb78f1 Revert "Improve promo offer user view with status details" 2025-10-07 02:20:36 +03:00
Egor ff3ac2b9f3 Merge pull request #826 from Fr1ngg/bedolaga-ztqa24
Improve promo offer user view with status details
2025-10-07 02:19:06 +03:00
Egor deabcfba28 Enhance promo offer user detail stats 2025-10-07 02:18:45 +03:00
Egor 85775604a8 Merge pull request #825 from Fr1ngg/revert-824-bedolaga-pzccm9
Revert "Fix promo offer localization placeholder mismatch"
2025-10-07 02:16:00 +03:00
Egor bf9b99818d Revert "Fix promo offer localization placeholder mismatch" 2025-10-07 02:15:52 +03:00
Egor 93a8c182bc Merge pull request #824 from Fr1ngg/bedolaga-pzccm9
Fix promo offer localization placeholder mismatch
2025-10-07 02:04:54 +03:00
Egor 7febaf9db2 Fix promo offer profile summary placeholders 2025-10-07 02:04:38 +03:00
Egor 21583279c6 Merge pull request #823 from Fr1ngg/revert-822-bedolaga-b9dizq
Revert "Cleanup promo offer user search prompt and add localization"
2025-10-07 02:04:11 +03:00
Egor df11e09d3a Revert "Cleanup promo offer user search prompt and add localization" 2025-10-07 02:04:02 +03:00
Egor 0b2ebbcdd8 Merge pull request #822 from Fr1ngg/bedolaga-b9dizq
Cleanup promo offer user search prompt and add localization
2025-10-07 01:57:12 +03:00
Egor 28be0954f9 Fix promo offer user search UX and translations 2025-10-07 01:56:59 +03:00
Egor 44164b2948 Merge pull request #821 from Fr1ngg/revert-820-bedolaga-72teet
Revert "Add user-targeted promo offer sending workflow"
2025-10-07 01:56:25 +03:00
Egor d9d4f171f6 Revert "Add user-targeted promo offer sending workflow" 2025-10-07 01:56:17 +03:00
Egor 4c73d1d0b6 Merge pull request #820 from Fr1ngg/bedolaga-72teet
Add user-targeted promo offer sending workflow
2025-10-07 01:41:34 +03:00
Egor 0bf71971f9 feat: add user-targeted promo offer sending 2025-10-07 01:41:15 +03:00
Egor ff19fb8950 Merge pull request #819 from Fr1ngg/revert-818-bedolaga-pm5a67
Revert "Add promo template editing workflow and restrictions"
2025-10-07 01:21:32 +03:00
Egor 68a37fcf72 Revert "Add promo template editing workflow and restrictions" 2025-10-07 01:21:20 +03:00
Egor 2caf16ccc6 Merge pull request #818 from Fr1ngg/bedolaga-pm5a67
Add promo template editing workflow and restrictions
2025-10-07 01:19:23 +03:00
Egor b38ee36698 Add promo template editing workflow and restrictions 2025-10-07 01:19:05 +03:00
Egor 3ee9c3de7b Merge pull request #817 from Fr1ngg/revert-816-bedolaga-m8ijxh
Revert "Improve admin promo template restrictions and editing"
2025-10-07 01:18:50 +03:00
Egor 0fce289926 Revert "Improve admin promo template restrictions and editing" 2025-10-07 01:18:42 +03:00
Egor b1e7f83d11 Merge pull request #816 from Fr1ngg/bedolaga-m8ijxh
Improve admin promo template restrictions and editing
2025-10-07 01:17:09 +03:00
Egor ff28f82240 Refine admin promo template workflow 2025-10-07 01:16:44 +03:00
Egor d5082be405 Merge pull request #815 from Fr1ngg/revert-814-bedolaga
Revert "Add per-user promo template sending in admin panel"
2025-10-07 01:16:13 +03:00
Egor 3e830a32ea Revert "Add per-user promo template sending in admin panel" 2025-10-07 01:16:05 +03:00
Egor c8a99ceed7 Merge pull request #814 from Fr1ngg/bedolaga
Add per-user promo template sending in admin panel
2025-10-07 00:19:18 +03:00
Egor 602ddd4013 feat(admin): manage user promo offers 2025-10-07 00:18:59 +03:00
Egor 33443955c7 Merge pull request #811 from Fr1ngg/dev2
Dev2
2025-10-06 04:48:28 +03:00
Egor 8f8b6ac1fe Merge pull request #810 from Fr1ngg/bedolaga/add-info-section-to-main-page
Add info section to main menu
2025-10-06 04:46:16 +03:00
Egor 8bb0795b1e Add info section to main menu 2025-10-06 04:45:54 +03:00
Egor 0055560d3e Merge pull request #809 from Fr1ngg/dev2
api promo offers
2025-10-06 04:37:05 +03:00
Egor dcc209deea Merge pull request #808 from Fr1ngg/bedolaga/add-api-endpoints-for-promo-offers
Add promo offer management endpoints
2025-10-06 04:27:40 +03:00
Egor a2cb7527f8 feat(api): add promo offers endpoints and docs 2025-10-06 04:27:21 +03:00
Egor 523c6ab69c Merge pull request #807 from Fr1ngg/dev2
Фикс промо предложений
2025-10-06 02:58:38 +03:00
Egor 91220b0b5f Merge pull request #806 from Fr1ngg/main
w
2025-10-06 02:57:53 +03:00
Egor f87dac8efa Merge pull request #805 from Fr1ngg/bedolaga/fix-discount-claim-error
Handle custom placeholders in discount claim message
2025-10-06 02:53:03 +03:00
Egor 44b75be97a Handle missing placeholders in discount claim message 2025-10-06 02:52:44 +03:00
Egor 500ed4cdf9 Update Dockerfile 2025-10-06 01:59:41 +03:00
Egor 498bd77076 Update docker-registry.yml 2025-10-06 01:59:29 +03:00
Egor 29afb6e67f Update docker-hub.yml 2025-10-06 01:59:16 +03:00
Egor 01d10b58dd Merge pull request #804 from Fr1ngg/dev2
Рассылка промопредложений + выдача сквадов(серверов) на время
2025-10-06 01:50:20 +03:00
Egor cf7aed9abf Merge pull request #803 from Fr1ngg/main
w
2025-10-06 01:46:02 +03:00
Egor 1b57531b28 Merge pull request #802 from Fr1ngg/bedolaga/fix-telegram-button-privacy-restriction-error-ptdrym
Handle Telegram privacy restriction fallback
2025-10-06 01:37:31 +03:00
Egor 375c096e82 Handle Telegram privacy restriction fallback 2025-10-06 01:37:12 +03:00
Egor 89b0daae1d Merge pull request #800 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-lplld3
Escape braces in promo hint server names
2025-10-06 01:25:44 +03:00
Egor 754c21a32f Escape braces in promo hint server names 2025-10-06 01:25:26 +03:00
Egor 4928cdde43 Merge pull request #799 from Fr1ngg/revert-798-bedolaga/fix-bot-main-message-for-test-server-36fdi2
Revert "Escape test server names in promo hint"
2025-10-06 01:25:14 +03:00
Egor 057e5f6ab0 Revert "Escape test server names in promo hint" 2025-10-06 01:25:03 +03:00
Egor 6d66d6f627 Merge pull request #798 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-36fdi2
Escape test server names in promo hint
2025-10-06 01:15:58 +03:00
Egor be28e9bec4 Escape server names in promo hint 2025-10-06 01:15:42 +03:00
Egor b923c4bcaa Merge pull request #797 from Fr1ngg/revert-796-bedolaga/fix-bot-main-message-for-test-server
Revert "Display test server names in promo hint"
2025-10-06 01:15:26 +03:00
Egor 3a749c1864 Revert "Display test server names in promo hint" 2025-10-06 01:14:58 +03:00
Egor d16d5b7918 Merge pull request #796 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server
Display test server names in promo hint
2025-10-06 01:09:23 +03:00
Egor 8484515722 Show test server names in promo hint 2025-10-06 01:08:53 +03:00
Egor b9efff6e97 Update ru.json 2025-10-06 01:01:41 +03:00
Egor 061557c438 Update ru.json 2025-10-06 00:58:53 +03:00
Egor df09ccfbd3 Merge pull request #795 from Fr1ngg/bedolaga-4ou2e3
Display promo offer details on main menu
2025-10-06 00:49:26 +03:00
Egor 16c7e140d0 Guard promo offer extra_data lookups 2025-10-06 00:49:09 +03:00
Egor 967924a1ff Merge pull request #794 from Fr1ngg/revert-793-revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Revert "Display promo offer details on main menu"""
2025-10-06 00:48:55 +03:00
Egor e028a25df2 Revert "Revert "Revert "Display promo offer details on main menu""" 2025-10-06 00:48:46 +03:00
Egor d3fcd11201 Merge pull request #793 from Fr1ngg/revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Display promo offer details on main menu""
2025-10-06 00:42:56 +03:00
Egor 97b9056b0b Revert "Revert "Display promo offer details on main menu"" 2025-10-06 00:42:46 +03:00
Egor 00ba52672b Merge pull request #792 from Fr1ngg/revert-791-bedolaga-g6lr1v
Revert "Add active promo info to main menu"
2025-10-06 00:42:01 +03:00
Egor efe16b4269 Revert "Add active promo info to main menu" 2025-10-06 00:41:42 +03:00
Egor 5e94ad2189 Merge pull request #791 from Fr1ngg/bedolaga-g6lr1v
Add active promo info to main menu
2025-10-06 00:37:37 +03:00
Egor 53bb582c81 Add active promo info to main menu 2025-10-06 00:37:19 +03:00
Egor 4c20ec19ab Merge pull request #790 from Fr1ngg/revert-789-bedolaga-ly8q4p
Revert "Display promo offer details on main menu"
2025-10-06 00:36:36 +03:00
Egor a7042ea012 Revert "Display promo offer details on main menu" 2025-10-06 00:36:26 +03:00
Egor f700285415 Merge pull request #789 from Fr1ngg/bedolaga-ly8q4p
Display promo offer details on main menu
2025-10-06 00:32:44 +03:00
Egor ed1fc22f6b Show promo offer details on main menu 2025-10-06 00:32:27 +03:00
Egor c3c0c764fd Merge pull request #788 from Fr1ngg/bedolaga/update-promo-offer-message-and-add-status-bar
Add promo offer timer to subscription flows
2025-10-06 00:10:22 +03:00
Egor 746e810c58 Add promo offer timer and updated discount messaging 2025-10-06 00:10:06 +03:00
Egor 2df5d21a9e Merge pull request #787 from Fr1ngg/bedolaga/fix-subscription-button-text-for-trial-users
Fix promo purchase discount CTA for trials
2025-10-05 23:35:36 +03:00
Egor d664b99e1c Adjust promo discount CTA for purchase offers 2025-10-05 23:35:09 +03:00
Egor 74fe3b09a2 Merge pull request #786 from Fr1ngg/revert-785-bedolaga-8svrvz
Revert "Adjust discount claim CTA for purchase offers"
2025-10-05 17:28:03 +03:00
Egor efea0e8414 Revert "Adjust discount claim CTA for purchase offers" 2025-10-05 17:27:54 +03:00
Egor 1dc4d0e04d Merge pull request #785 from Fr1ngg/bedolaga-8svrvz
Adjust discount claim CTA for purchase offers
2025-10-05 17:24:41 +03:00
Egor 84f06a96b3 Adjust discount claim CTA for purchase offers 2025-10-05 17:24:25 +03:00
Egor 4a7cc3306a Merge pull request #783 from Fr1ngg/bedolaga/add-duration-for-subscription-promo-discounts
Add expiration control for promo offer discounts
2025-10-05 17:01:38 +03:00
Egor 5d576bc727 Add expiration control for promo offer discounts 2025-10-05 17:01:11 +03:00
Egor 19a7a8e6b1 Merge pull request #782 from Fr1ngg/bedolaga/update-promo-message-buttons
Adjust promo offer keyboards to add close button
2025-10-05 06:11:11 +03:00
Egor 064f64afd0 Restrict promo offer keyboards to activation and close 2025-10-05 06:10:49 +03:00
Egor bfa5e5aab9 Merge pull request #781 from Fr1ngg/bedolaga-zcm3jb
Fix discount activation CTA to extend subscription
2025-10-05 05:47:29 +03:00
Egor f59bda57cf Adjust discount activation CTA 2025-10-05 05:46:55 +03:00
Egor 6887c265ad Merge pull request #780 from Fr1ngg/bedolaga/fix-chat-message-retention-during-promo-editing
Удаление сообщений администратора при редактировании промо-предложений
2025-10-05 05:31:06 +03:00
Egor 7db31ff5b5 Удаление пользовательских сообщений при редактировании промо 2025-10-05 05:30:45 +03:00
Egor 6bee1bb85d Merge pull request #779 from Fr1ngg/bedolaga/add-active-trials-category-to-promotions
Add active trial segment to purchase discount offer
2025-10-05 05:11:01 +03:00
Egor c604c5f6ff Add active trial segment to purchase promo 2025-10-05 05:10:45 +03:00
Egor ed5e6aa550 Merge pull request #777 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-qglaer
fix: rollback session after promo log failures
2025-10-05 04:06:21 +03:00
Egor 6b78d0f63e Merge pull request #778 from Fr1ngg/revert-776-bedolaga/add-paginated-log-for-promo-offers-nmhsmh
Revert "fix: restore promo test access claim flow"
2025-10-05 04:05:56 +03:00
Egor fea1819ef8 Revert "fix: restore promo test access claim flow" 2025-10-05 04:05:47 +03:00
Egor 45586277ab fix: rollback session after promo log failures 2025-10-05 04:05:43 +03:00
Egor 8ff3383246 Merge pull request #776 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-nmhsmh
fix: restore promo test access claim flow
2025-10-05 03:54:57 +03:00
Egor 7879dc5e43 fix: define newly added test access squads 2025-10-05 03:54:39 +03:00
Egor 392084e436 Merge pull request #775 from Fr1ngg/revert-774-revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "Revert "feat: add promo offer operation logs"""
2025-10-05 03:53:28 +03:00
Egor 256e1cb778 Revert "Revert "Revert "feat: add promo offer operation logs""" 2025-10-05 03:53:16 +03:00
Egor e131f77de3 Merge pull request #774 from Fr1ngg/revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "feat: add promo offer operation logs""
2025-10-05 03:46:36 +03:00
Egor a60082e5e5 Revert "Revert "feat: add promo offer operation logs"" 2025-10-05 03:46:23 +03:00
Egor 81bb8f8f4a Merge pull request #773 from Fr1ngg/revert-772-bedolaga/add-paginated-log-for-promo-offers-3spy1o
Revert "Add promo offer activity log in admin panel"
2025-10-05 03:46:05 +03:00
Egor a9af75d9df Revert "Add promo offer activity log in admin panel" 2025-10-05 03:45:55 +03:00
Egor 30758848fa Merge pull request #772 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-3spy1o
Add promo offer activity log in admin panel
2025-10-05 03:44:46 +03:00
Egor 00c024bd2c Add promo offer activity log in admin panel 2025-10-05 03:44:28 +03:00
Egor a69d28fb13 Merge pull request #771 from Fr1ngg/revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "feat: add promo offer operation logs"
2025-10-05 03:44:03 +03:00
Egor ac818ccf34 Revert "feat: add promo offer operation logs" 2025-10-05 03:43:53 +03:00
Egor 533335bad9 Merge pull request #770 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers
feat: add promo offer operation logs
2025-10-05 03:32:40 +03:00
Egor 37c99ae1fe feat: add promo offer operation logs 2025-10-05 03:32:24 +03:00
Egor d9b232b87b Merge pull request #769 from Fr1ngg/bedolaga/add-buttons-for-subscription-promotions
Enhance promo offer messaging and safeguards
2025-10-04 17:06:41 +03:00
Egor b8df611d0d Enhance promo offer flows 2025-10-04 17:06:24 +03:00
Egor e0b5beed02 Merge pull request #768 from Fr1ngg/bedolaga/add-purchase-button-to-discount-message
Fix promo discount message button and checkout crash
2025-10-04 16:43:04 +03:00
Egor f92cf97f63 Fix promo discount claim actions 2025-10-04 16:42:44 +03:00
Egor 9ae11bc525 Merge pull request #767 from Fr1ngg/bedolaga/update-promo-offer-functionality-in-admin-panel
Adjust promo test offers with server targeting
2025-10-04 16:29:38 +03:00
Egor ef1426a938 Adjust promo test offers with server targeting 2025-10-04 16:29:23 +03:00
Egor 456b02e2d9 Merge pull request #766 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-onp0w2
Ensure subscription service reloads RemnaWave configuration
2025-10-04 16:12:25 +03:00
Egor abb156a7b1 Fix RemnaWave config refresh in subscription service 2025-10-04 16:12:03 +03:00
Egor ca8917a43c Merge pull request #765 from Fr1ngg/revert-764-bedolaga/enhance-promo-offer-functionality-mxpx1o
Revert "Fix test access sync and add menu navigation"
2025-10-04 16:08:54 +03:00
Egor 8ce75007ff Revert "Fix test access sync and add menu navigation" 2025-10-04 16:08:45 +03:00
Egor 76ced9aeeb Merge pull request #764 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-mxpx1o
Fix test access sync and add menu navigation
2025-10-04 13:10:40 +03:00
Egor 42eeb67039 Fix test access sync and add back to menu button 2025-10-04 13:10:23 +03:00
Egor c11be659b3 Merge pull request #763 from Fr1ngg/revert-762-bedolaga/enhance-promo-offer-functionality-wktrm4
Revert "Improve test access promo syncing and messaging"
2025-10-04 13:09:58 +03:00
Egor 2a44d98015 Revert "Improve test access promo syncing and messaging" 2025-10-04 13:09:48 +03:00
Egor 885fa2fb1a Merge pull request #762 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-wktrm4
Improve test access promo syncing and messaging
2025-10-04 13:07:00 +03:00
Egor 8bcc2135ca Handle Remnawave sync failures for test access offers 2025-10-04 13:06:43 +03:00
Egor a26d2fb50b Merge pull request #761 from Fr1ngg/revert-760-bedolaga/enhance-promo-offer-functionality
Revert "Add admin squad picker for test access promo offers"
2025-10-04 13:06:18 +03:00
Egor ad3a8b3af3 Revert "Add admin squad picker for test access promo offers" 2025-10-04 13:06:09 +03:00
Egor 918a450e63 Merge pull request #760 from Fr1ngg/bedolaga/enhance-promo-offer-functionality
Add admin squad picker for test access promo offers
2025-10-04 12:52:52 +03:00
Egor 596053c720 feat: add squad picker for test promo offers 2025-10-04 12:52:27 +03:00
Egor abb83b43bc Merge pull request #759 from Fr1ngg/bedolaga/fix-promo-discount-handling-and-messages
Fix promo discount consumption race and refresh promo offer texts
2025-10-04 12:25:22 +03:00
Egor 6f67a36a58 Fix promo discount consumption and admin copy 2025-10-04 12:25:00 +03:00
Egor 9cbc9bb3d5 Merge pull request #758 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-5mdcu6
Honor promo offer discounts in autopay renewals
2025-10-04 12:10:34 +03:00
Egor 1cbf3c5643 Recalculate promo offer discount during subscription confirmation 2025-10-04 12:10:04 +03:00
Egor 8f35d56e6a Merge pull request #757 from Fr1ngg/revert-756-bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Revert "Honor promo offer discounts in autopay renewals"
2025-10-04 12:09:13 +03:00
Egor b5773ba668 Revert "Honor promo offer discounts in autopay renewals" 2025-10-04 12:09:04 +03:00
Egor fa5847ee64 Merge pull request #756 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Honor promo offer discounts in autopay renewals
2025-10-04 11:53:09 +03:00
Egor b561fbc9e5 Apply promo offer discounts to autopay renewals 2025-10-04 11:52:38 +03:00
Egor 305a0b6b95 Merge pull request #755 from Fr1ngg/revert-754-bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Revert "Fix promo offer validation to use pre-discount totals"
2025-10-04 11:52:02 +03:00
Egor 12a16097cf Revert "Fix promo offer validation to use pre-discount totals" 2025-10-04 11:51:52 +03:00
Egor c8986bea40 Merge pull request #754 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Fix promo offer validation to use pre-discount totals
2025-10-04 11:43:00 +03:00
Egor bcf01ecf6a Fix promo offer validation to use pre-discount totals 2025-10-04 11:42:43 +03:00
Egor a334fbffb7 Merge pull request #753 from Fr1ngg/revert-752-bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Revert "Refactor promo offers to apply stackable discounts"
2025-10-04 11:42:16 +03:00
Egor fc8f55195f Revert "Refactor promo offers to apply stackable discounts" 2025-10-04 11:42:06 +03:00
Egor dbd79cbc65 Merge pull request #752 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Refactor promo offers to apply stackable discounts
2025-10-04 11:35:06 +03:00
Egor 436686dd1b Ensure promo offers use stackable discounts 2025-10-04 11:34:46 +03:00
Egor 227e754f4a Merge pull request #751 from Fr1ngg/revert-750-bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Revert "Implement percent-based promo discounts"
2025-10-04 11:34:31 +03:00
Egor 3d43358b86 Revert "Implement percent-based promo discounts" 2025-10-04 11:34:22 +03:00
Egor f05f32b92e Merge pull request #750 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Implement percent-based promo discounts
2025-10-04 11:32:43 +03:00
Egor 7628b9aaa7 Implement percent-based promo discounts 2025-10-04 11:31:55 +03:00
Egor 9ab8f1dee9 Merge pull request #748 from Fr1ngg/revert-747-bedolaga/update-promo-offers-to-apply-percentage-discount
Revert "Implement stackable percent-based promo offers"
2025-10-04 11:31:05 +03:00
Egor 092ea034ec Revert "Implement stackable percent-based promo offers" 2025-10-04 11:30:57 +03:00
Egor c4e5261b31 Merge pull request #747 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount
Implement stackable percent-based promo offers
2025-10-04 11:29:04 +03:00
Egor 2baf2b9abb Adjust promo offer templates for percent discounts 2025-10-04 11:28:47 +03:00
Egor 87a6b05e54 Merge pull request #746 from Fr1ngg/revert-745-bedolaga/update-promo-offers-for-discounts-rwpidj
Revert "Implement percent-based promo offers"
2025-10-04 11:05:56 +03:00
Egor 0925dfdf80 Revert "Implement percent-based promo offers" 2025-10-04 11:05:47 +03:00
Egor cf294fae0a Merge pull request #745 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-rwpidj
Implement percent-based promo offers
2025-10-04 10:58:35 +03:00
Egor 12e77cd6bf Implement percent-based promo offers 2025-10-04 10:57:00 +03:00
Egor 867ae1b807 Merge pull request #744 from Fr1ngg/revert-743-bedolaga/update-promo-offers-for-discounts-9jz4ar
Revert "Clear expired promo discounts when preparing checkout summaries"
2025-10-04 10:55:45 +03:00
Egor db9f91b336 Revert "Clear expired promo discounts when preparing checkout summaries" 2025-10-04 10:55:32 +03:00
Egor df0792d4dd Merge pull request #743 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-9jz4ar
Clear expired promo discounts when preparing checkout summaries
2025-10-04 10:54:35 +03:00
Egor 0e0569fe23 Clear expired promo discounts before checkout 2025-10-04 10:53:09 +03:00
Egor daa37370e3 Merge pull request #741 from Fr1ngg/bedolaga/fix-invalid-values-error-for-active-subscriptions
Fix parsing of promo offer send callbacks
2025-10-04 10:19:36 +03:00
Egor b35c731ba5 Fix promo offer segment parsing 2025-10-04 10:19:05 +03:00
Egor 06879089a1 Merge pull request #740 from Fr1ngg/bedolaga/fix-missing-join-in-promo-segment-query
Fix promo segment join for no subscription users
2025-10-04 10:14:01 +03:00
Egor b96d530808 Fix promo segment no subscription join 2025-10-04 10:13:45 +03:00
Egor 8f3c8f37c1 Merge pull request #739 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot-mgwac3
Add admin promo offer management and targeted promotions
2025-10-04 10:05:54 +03:00
Egor e4c7a9cf15 Add admin promo offers and targeted discount workflows 2025-10-04 10:05:31 +03:00
Egor 935e2356db Merge pull request #738 from Fr1ngg/revert-737-bedolaga/add-promotional-offers-feature-to-bot
Revert "Implement targeted promotional offer campaigns"
2025-10-04 10:04:59 +03:00
Egor 3b392a671d Revert "Implement targeted promotional offer campaigns" 2025-10-04 10:04:49 +03:00
Egor 4ceb09d609 Merge pull request #737 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot
Implement targeted promotional offer campaigns
2025-10-04 09:59:41 +03:00
Egor 3f8ea7f8fd Add promotional offer broadcasting support 2025-10-04 09:59:17 +03:00
Egor 95525f2132 Update README.md 2025-10-04 08:03:44 +03:00
Egor d15b46dede Merge pull request #736 from Legacyyy777/DEV
Мулен 3
2025-10-04 07:44:54 +03:00
Egor f2fc7581a3 Update Dockerfile 2025-10-04 07:37:47 +03:00
Egor e96010d24d Update docker-registry.yml 2025-10-04 07:37:32 +03:00
Egor 35fc39b9f2 Update docker-hub.yml 2025-10-04 07:37:13 +03:00
Legacyyy777 7129625ab5 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 09:23:31 +05:00
Legacyyy777 e480356447 Оптимизирована обработка подключения к БД в WebhookServer. Удален цикл async for, добавлена обработка исключений при получении соединения с БД. 2025-10-04 09:21:49 +05:00
Egor 46ea2de300 Merge pull request #735 from Fr1ngg/dev2
Перенос настроек цен и управления серверов + рефактор запуска бота
2025-10-04 06:58:37 +03:00
Egor 2152ded3c4 Merge pull request #734 from Fr1ngg/bedolaga/improve-bot-startup-log-format-ih803q
feat: add structured startup timeline logging
2025-10-04 06:51:36 +03:00
Egor da0e8a2e3e feat: add structured startup timeline logging 2025-10-04 06:51:19 +03:00
Egor da540e8680 Merge pull request #733 from Fr1ngg/revert-732-bedolaga/improve-bot-startup-log-format
Revert "Enhance startup log presentation"
2025-10-04 06:51:07 +03:00
Egor a12ad52c7e Revert "Enhance startup log presentation" 2025-10-04 06:50:59 +03:00
Egor 38b5fa297b Merge pull request #732 from Fr1ngg/bedolaga/improve-bot-startup-log-format
Enhance startup log presentation
2025-10-04 06:49:05 +03:00
Egor d50475974c Improve startup logging aesthetics 2025-10-04 06:48:49 +03:00
Egor ada891f2d9 Merge pull request #731 from Fr1ngg/bedolaga/resolve-missing-localization-keys
Add missing admin pricing locale strings
2025-10-04 06:40:13 +03:00
Egor e7467e5434 Add missing admin pricing translations 2025-10-04 06:38:45 +03:00
Egor fa4a692574 Merge pull request #730 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-y4nuoo
Mutate period price cache in place
2025-10-04 06:31:18 +03:00
Egor 9c60ef2c33 Mutate period price cache in place 2025-10-04 06:30:50 +03:00
Egor 634633d12e Merge pull request #729 from Fr1ngg/revert-728-bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Revert "Fix subscription period price cache update"
2025-10-04 06:30:25 +03:00
Egor b3fd0c50ca Revert "Fix subscription period price cache update" 2025-10-04 06:30:17 +03:00
Egor acb267668e Merge pull request #728 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Fix subscription period price cache update
2025-10-04 06:24:55 +03:00
Egor 344cffef60 Fix period price cache refresh 2025-10-04 06:24:38 +03:00
Egor ef652d4518 Merge pull request #727 from Fr1ngg/revert-726-bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Revert "Fix subscription period price updates in admin panel"
2025-10-04 06:23:25 +03:00
Egor a66f9bbf28 Revert "Fix subscription period price updates in admin panel" 2025-10-04 06:23:17 +03:00
Egor 3e5fbcf2bd Merge pull request #726 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Fix subscription period price updates in admin panel
2025-10-04 06:20:12 +03:00
Egor ba2de9e150 Fix period price updates in admin 2025-10-04 06:19:52 +03:00
Egor b638e04629 Merge pull request #725 from Fr1ngg/revert-724-bedolaga/fix-subscription-price-update-in-admin-panel
Revert "Fix subscription price refresh in admin panel"
2025-10-04 06:19:32 +03:00
Egor 45a5f012c2 Revert "Fix subscription price refresh in admin panel" 2025-10-04 06:19:24 +03:00
Egor 1a66094800 Merge pull request #724 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel
Fix subscription price refresh in admin panel
2025-10-04 06:17:21 +03:00
Egor f7afe481bb Fix subscription price refresh in admin panel 2025-10-04 06:17:04 +03:00
Egor f04bf43962 Merge pull request #723 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-emq0t0
Refine admin pricing menu UX
2025-10-04 06:07:50 +03:00
Egor 3964ed1f3c Improve pricing admin panel UX 2025-10-04 06:07:35 +03:00
Egor 98fa8eef5d Merge pull request #722 from Fr1ngg/revert-721-bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX"
2025-10-04 06:07:18 +03:00
Egor c7b47eef4d Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX" 2025-10-04 06:07:10 +03:00
Egor 9270bb3bf5 Merge pull request #721 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Add trial and tariff configuration controls to admin pricing panel and refine UX
2025-10-04 06:04:55 +03:00
Egor ad2f6f6f22 Improve pricing admin UX 2025-10-04 06:04:33 +03:00
Egor 62749f9ffe Merge pull request #720 from Fr1ngg/revert-719-bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Revert "Add trial and tariff settings to admin pricing"
2025-10-04 05:58:04 +03:00
Egor e623f9df3a Revert "Add trial and tariff settings to admin pricing" 2025-10-04 05:57:49 +03:00
Egor 2b79de7d82 Merge pull request #719 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Add trial and tariff settings to admin pricing
2025-10-04 05:56:26 +03:00
Egor cdcc3b4c4a Add trial and tariff settings to admin pricing 2025-10-04 05:56:07 +03:00
Egor e757569628 Merge pull request #718 from Fr1ngg/revert-717-bedolaga/add-trial-period-management-in-admin-panel
Revert "Add trial and tariff configuration controls to admin pricing panel"
2025-10-04 05:55:34 +03:00
Egor 4f88cdf7db Revert "Add trial and tariff configuration controls to admin pricing panel" 2025-10-04 05:55:26 +03:00
Egor 406c13aea2 Merge pull request #717 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel
Add trial and tariff configuration controls to admin pricing panel
2025-10-04 05:51:31 +03:00
Egor c95b1e4fad feat(admin): extend pricing panel settings 2025-10-04 05:51:12 +03:00
Egor 43c480fa33 Merge pull request #716 from Fr1ngg/revert-713-revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Revert "Refactor admin menu for servers and pricing management""
2025-10-04 05:37:11 +03:00
Egor cb25bbb2ed Revert "Revert "Refactor admin menu for servers and pricing management"" 2025-10-04 05:36:59 +03:00
Egor 2602a05fb2 Merge pull request #715 from Fr1ngg/revert-714-bedolaga/reorganize-admin-panel-menu-t36ykc
Revert "Reorganize admin menu and add pricing management"
2025-10-04 05:35:24 +03:00
Egor c31da6c2ae Revert "Reorganize admin menu and add pricing management" 2025-10-04 05:35:06 +03:00
Egor 17d51ab286 Merge pull request #714 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-t36ykc
Reorganize admin menu and add pricing management
2025-10-04 05:33:40 +03:00
Egor e576cb97ec feat: reorganize admin menu with pricing section 2025-10-04 05:33:25 +03:00
Egor 217fed9c9c Merge pull request #713 from Fr1ngg/revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Refactor admin menu for servers and pricing management"
2025-10-04 05:33:14 +03:00
Egor 030f4efbe1 Revert "Refactor admin menu for servers and pricing management" 2025-10-04 05:33:06 +03:00
Egor 33b3828abc Merge pull request #712 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-mzg1j9
Refactor admin menu for servers and pricing management
2025-10-04 05:30:27 +03:00
Egor f2876fd02c Refactor admin menu for servers and pricing management 2025-10-04 05:30:08 +03:00
Egor ee9ba71f3c Merge pull request #711 from Fr1ngg/revert-710-bedolaga/reorganize-admin-panel-menu
Revert "Reorganize admin panel pricing and server controls"
2025-10-04 05:29:48 +03:00
Egor 6a5d5d960f Revert "Reorganize admin panel pricing and server controls" 2025-10-04 05:29:41 +03:00
Egor 910409cdfb Merge pull request #710 from Fr1ngg/bedolaga/reorganize-admin-panel-menu
Reorganize admin panel pricing and server controls
2025-10-04 05:27:32 +03:00
Egor d78b36b60e Add dedicated pricing management section to admin panel 2025-10-04 05:27:10 +03:00
Egor ca2f83db88 Обновление меню конфигурации бота
Dev2
2025-10-04 04:58:05 +03:00
Egor bf591f55ea Merge pull request #708 from Fr1ngg/bedolaga-hw3yna
feat: enhance admin settings control panel
2025-10-04 04:49:29 +03:00
Egor 99aa09349e feat: enhance admin settings control panel 2025-10-04 04:49:15 +03:00
Egor a391b635ed Merge pull request #707 from Fr1ngg/revert-704-bedolaga-8nay3u
Revert "Complete bot configuration dashboard actions"
2025-10-04 04:48:54 +03:00
Egor 4f1ff34dce Revert "Complete bot configuration dashboard actions" 2025-10-04 04:47:38 +03:00
Egor 9a23a305b5 Merge pull request #706 from Fr1ngg/revert-705-bedolaga/add-alembic-migration-for-system_settings_history-table
Revert "Add migration for system settings history audit table"
2025-10-04 04:47:20 +03:00
Egor 539e7e9066 Revert "Add migration for system settings history audit table" 2025-10-04 04:47:12 +03:00
Egor 6b1173a1b5 Merge pull request #705 from Fr1ngg/bedolaga/add-alembic-migration-for-system_settings_history-table
Add migration for system settings history audit table
2025-10-04 04:39:02 +03:00
Egor 23f8e158ad Add migration for system settings history audit table 2025-10-04 04:38:44 +03:00
Egor 578061be8c Merge pull request #704 from Fr1ngg/bedolaga-8nay3u
Complete bot configuration dashboard actions
2025-10-04 04:24:00 +03:00
Egor 84816b84e7 Complete bot configuration dashboard actions 2025-10-04 04:23:41 +03:00
Egor 0ad2f5bb8a Merge pull request #703 from Fr1ngg/revert-702-bedolaga-1gwn05
Revert "Implement admin bot configuration experience"
2025-10-04 04:23:22 +03:00
Egor e415b4b2ee Revert "Implement admin bot configuration experience" 2025-10-04 04:23:14 +03:00
Egor 7d6ecaaa1b Merge pull request #702 from Fr1ngg/bedolaga-1gwn05
Implement admin bot configuration experience
2025-10-04 04:21:20 +03:00
Egor 588461c53a Implement admin bot configuration experience 2025-10-04 04:21:01 +03:00
Egor 25c23bd653 Merge pull request #701 from Fr1ngg/revert-700-bedolaga-i1q6pf
Revert "feat: enrich bot configuration admin panel"
2025-10-04 04:20:45 +03:00
Egor 1a033de259 Revert "feat: enrich bot configuration admin panel" 2025-10-04 04:20:36 +03:00
Egor 850f8d2b2e Merge pull request #700 from Fr1ngg/bedolaga-i1q6pf
feat: enrich bot configuration admin panel
2025-10-04 04:15:48 +03:00
Egor 39374095eb feat: enrich bot configuration admin panel 2025-10-04 04:15:31 +03:00
Egor f4fe8913fa Merge pull request #699 from Fr1ngg/revert-698-bedolaga/add-subscription-plan-menu-in-admin-panel
Revert "Add admin tariffs menu scaffolding"
2025-10-04 03:48:07 +03:00
Egor 9a5203770b Revert "Add admin tariffs menu scaffolding" 2025-10-04 03:47:57 +03:00
Egor 64b0f5161c Merge pull request #698 from Fr1ngg/bedolaga/add-subscription-plan-menu-in-admin-panel
Add admin tariffs menu scaffolding
2025-10-04 02:40:28 +03:00
Egor 8e12609746 Add admin tariffs menu scaffolding 2025-10-04 02:39:16 +03:00
Egor 703d09c556 Merge pull request #697 from Fr1ngg/dev2
Dev2
2025-10-04 00:41:22 +03:00
Egor 7b238a32b4 Merge pull request #696 from Fr1ngg/main
w
2025-10-04 00:40:34 +03:00
Egor c72fa990fa Merge pull request #694 from Legacyyy777/DEV
Мулен правки 2
2025-10-04 00:39:28 +03:00
Egor f8fc929cbf Merge pull request #695 from Fr1ngg/bedolaga/update-install_bot.sh-for-docker-compose
Add Docker Compose pull update option and display container status
2025-10-04 00:38:58 +03:00
Egor 744aaeb5d6 feat: add docker compose pull update 2025-10-04 00:38:34 +03:00
Legacyyy777 515088ff45 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 02:33:07 +05:00
Legacyyy777 96b64df989 Добавлен return после цикла async for db in get_db().
Добавлена обработка случая, когда не удается получить соединение с БД.
2025-10-04 02:32:04 +05:00
Egor f51c22c67e Merge pull request #693 from Fr1ngg/dev2
Dev2
2025-10-04 00:19:03 +03:00
Egor 010c412268 Merge pull request #682 from Fr1ngg/main
w
2025-10-04 00:14:53 +03:00
Egor 7d204684e1 Update install_bot.sh 2025-10-04 00:11:19 +03:00
Egor 08f47694ee Merge pull request #692 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup-mc8p92
Fix PostgreSQL sequence sync query
2025-10-03 23:55:59 +03:00
Egor e61977a368 Fix PostgreSQL sequence sync query 2025-10-03 23:55:43 +03:00
Egor 72ae83e6ef Merge pull request #691 from Fr1ngg/revert-690-bedolaga/fix-automigration-error-at-bot-startup
Revert "Fix PostgreSQL sequence sync for newer versions"
2025-10-03 23:55:19 +03:00
Egor 80aa0c2443 Revert "Fix PostgreSQL sequence sync for newer versions" 2025-10-03 23:55:09 +03:00
Egor 9e0eb3fd4a Merge pull request #690 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup
Fix PostgreSQL sequence sync for newer versions
2025-10-03 23:54:29 +03:00
Egor 6fe4bad240 Fix PostgreSQL sequence sync for newer versions 2025-10-03 23:54:11 +03:00
Egor 859089e91d Merge pull request #689 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration-hr68zi
Handle users sequence desync after backup restore
2025-10-03 23:36:04 +03:00
Egor c4fa25321e Handle users sequence desync after backup restore 2025-10-03 23:35:47 +03:00
Egor 96a6f26b1e Merge pull request #688 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url-w44me1
Handle missing RemnaWave configuration in subscription service
2025-10-03 22:12:59 +03:00
Egor 3f0d125207 Handle missing RemnaWave configuration in subscription service 2025-10-03 22:12:43 +03:00
Egor cb5821fffc Merge pull request #687 from Fr1ngg/revert-686-bedolaga/fix-attributeerror-for-base_url
Revert "Handle missing RemnaWave API configuration gracefully"
2025-10-03 22:12:13 +03:00
Egor f79420a9b0 Revert "Handle missing RemnaWave API configuration gracefully" 2025-10-03 22:11:51 +03:00
Egor a7b4dc985f Merge pull request #686 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url
Handle missing RemnaWave API configuration gracefully
2025-10-03 22:10:50 +03:00
Egor 38562744ab Handle missing RemnaWave API configuration gracefully 2025-10-03 22:09:29 +03:00
Egor 3d413ced59 Merge pull request #685 from Fr1ngg/revert-684-bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Revert "Fix admin top-up notifications failing after webhook"
2025-10-03 22:01:44 +03:00
Egor 0002ce0a9a Revert "Fix admin top-up notifications failing after webhook" 2025-10-03 22:01:35 +03:00
Egor 140d3580e9 Merge pull request #684 from Fr1ngg/bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Fix admin top-up notifications failing after webhook
2025-10-03 21:57:26 +03:00
Egor cf5cb406d0 Fix admin top-up notifications failing after webhook 2025-10-03 21:57:07 +03:00
Egor 8c3f3d56c8 Merge pull request #681 from Legacyyy777/DEV
Правим мулен
2025-10-03 21:43:47 +03:00
Egor d33831dfe4 Delete .github/workflows/deploy.yml 2025-10-03 21:43:30 +03:00
Legacyyy777 b68f1256ca Добавлено логирование заголовков вебхука Mulen Pay для отладки и временно отключена проверка подписи. Обновлены сообщения логирования для улучшения диагностики. 2025-10-03 10:28:02 +05:00
Legacyyy777 3d3b019e60 Merge branch 'Fr1ngg:main' into DEV 2025-10-03 10:12:31 +05:00
Egor f1a801d693 Merge pull request #680 from Fr1ngg/dev2
Dev2
2025-10-03 00:35:27 +03:00
Egor de33e15ae0 Merge pull request #679 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration
Sync PostgreSQL sequences after restore
2025-10-03 00:29:39 +03:00
Egor abd87c2a48 Sync PostgreSQL sequences after restore 2025-10-03 00:29:11 +03:00
Egor f691e91560 Merge pull request #678 from Fr1ngg/main
w
2025-10-03 00:23:44 +03:00
Egor 7dafb0c29f Update README.md 2025-10-02 23:56:23 +03:00
Egor 5037d92e36 Update index.html 2025-10-02 23:15:53 +03:00
Egor d765e08017 Merge pull request #676 from Fr1ngg/bedolaga/document-manual-reverse-proxy-setup-for-caddy-and-nginx
docs: mount miniapp assets in caddy
2025-10-02 19:57:17 +03:00
Egor 4958613ad9 Update README.md 2025-10-02 19:56:59 +03:00
Egor 489667c847 Update README.md 2025-10-02 19:52:55 +03:00
Egor ccedfad51f docs: mount miniapp assets in caddy 2025-10-02 19:50:46 +03:00
Egor 841ac5cdb3 Update README.md 2025-10-02 19:13:40 +03:00
Egor a8b6202fe9 Merge pull request #675 from Fr1ngg/bedolaga/update-readme-for-install_bot.sh
docs: document installer usage
2025-10-02 19:12:02 +03:00
Egor 4708c67dcc docs: document installer usage 2025-10-02 19:11:34 +03:00
Egor f705b508a1 Merge pull request #674 from Fr1ngg/dev2
Dev2
2025-10-02 17:35:18 +03:00
Egor 6030fb08ec Update install_bot.sh 2025-10-02 17:32:17 +03:00
Egor a5b152e73a Merge pull request #673 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy-dzg7sf
Refactor install script for Caddy-only proxy support
2025-10-02 03:49:05 +03:00
Egor 087a57fc11 Refactor installer to focus on Caddy proxy 2025-10-02 03:48:49 +03:00
Egor 95e44f99a2 Merge pull request #672 from Fr1ngg/revert-671-bedolaga/remove-nginx-support-and-connect-to-caddy
Revert "Refine installer Caddy integration"
2025-10-02 03:48:12 +03:00
Egor b8bb2acd64 Revert "Refine installer Caddy integration" 2025-10-02 03:48:03 +03:00
Egor eb3e806e0f Merge pull request #671 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy
Refine installer Caddy integration
2025-10-02 03:44:40 +03:00
Egor 33911aca93 Update installer to rely solely on Caddy 2025-10-02 03:44:22 +03:00
Egor 63c6e64cbe Merge pull request #655 from Fr1ngg/bedolaga/add-subscription-link-redirect-for-desktop-289875
Add desktop redirect fallback for Happ subscription links
2025-10-02 03:40:49 +03:00
Egor 358c8aed3b Merge branch 'main' into bedolaga/add-subscription-link-redirect-for-desktop-289875 2025-10-02 03:40:18 +03:00
Egor 4ac994454b Merge pull request #670 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-bnh3fm
Allow optional RemnaWave settings for initial startup
2025-10-02 03:14:14 +03:00
Egor 5d6f080663 Allow missing RemnaWave settings on first start 2025-10-02 03:13:31 +03:00
Egor 33f0e3b9ba Merge pull request #669 from Fr1ngg/revert-668-bedolaga/fix-bot-startup-validation-errors
Revert "Allow optional settings to initialize without env overrides"
2025-10-02 03:13:01 +03:00
Egor ef809de9a2 Revert "Allow optional settings to initialize without env overrides" 2025-10-02 03:12:50 +03:00
Egor 628c53513c Merge pull request #668 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Allow optional settings to initialize without env overrides
2025-10-02 02:10:50 +03:00
Egor 876362cd80 Make optional env settings optional 2025-10-02 02:10:22 +03:00
Egor 1a9efd65a1 Update install_bot.sh 2025-10-02 02:02:21 +03:00
Egor 121918c33a Merge pull request #667 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh-xyoa0f
Improve install bot state persistence and path resolution
2025-10-02 00:45:11 +03:00
Egor b46c3bd142 Improve install state handling in install_bot 2025-10-02 00:44:53 +03:00
Egor 566b563626 Merge pull request #666 from Fr1ngg/revert-665-bedolaga/remove-installation-check-in-install_bot.sh
Revert "Handle missing installation state in install_bot.sh"
2025-10-02 00:44:16 +03:00
Egor 397af1cc86 Revert "Handle missing installation state in install_bot.sh" 2025-10-02 00:44:07 +03:00
Egor bdfa2a3039 Merge pull request #665 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh
Handle missing installation state in install_bot.sh
2025-10-02 00:32:31 +03:00
Egor 38001410b8 Handle missing bot install state 2025-10-02 00:31:54 +03:00
Egor c2693fc46a Merge pull request #663 from Fr1ngg/bedolaga/fix-install_bot.sh-to-append-domains
Preserve existing Caddyfile entries when updating webhooks
2025-10-01 23:31:08 +03:00
Egor 9db84af7a9 Preserve existing Caddyfile entries when updating webhooks 2025-10-01 23:25:16 +03:00
Egor 0172138084 Merge pull request #662 from Fr1ngg/bedolaga/fix-script-not-removing-created-network
Add docker network cleanup option to proxy menu
2025-10-01 23:20:05 +03:00
Egor 0e913ec78b Add docker network cleanup option to proxy menu 2025-10-01 23:11:06 +03:00
Egor 6a5d0181b0 Update install_bot.sh 2025-10-01 23:07:05 +03:00
Egor 7684ed382e Update install_bot.sh 2025-10-01 21:55:46 +03:00
Egor 0d47acf044 Merge pull request #661 from Fr1ngg/bedolaga/create-auto-installer-for-bot
Add interactive installation script for the bot
2025-10-01 21:47:15 +03:00
Egor 2d09b10f36 Add interactive installation and monitoring script 2025-10-01 21:46:58 +03:00
Egor 77c99ad33e Merge pull request #660 from Fr1ngg/revert-659-bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Revert "Add automated installer and monitoring script"
2025-10-01 21:33:19 +03:00
Egor a41531944c Revert "Add automated installer and monitoring script" 2025-10-01 21:33:11 +03:00
Egor 9b2d9406d3 Merge pull request #659 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Add automated installer and monitoring script
2025-10-01 21:25:42 +03:00
Egor 1776a5b424 Add automated installer and monitoring script 2025-10-01 21:24:00 +03:00
Egor 419b921487 Merge pull request #658 from Fr1ngg/revert-657-bedolaga/create-auto-installer-for-bot-monitoring
Revert "Add automated installation and monitoring script"
2025-10-01 21:23:20 +03:00
Egor c1f7404912 Revert "Add automated installation and monitoring script" 2025-10-01 21:23:11 +03:00
Egor 1c07f40d43 Merge pull request #657 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring
Add automated installation and monitoring script
2025-10-01 21:16:43 +03:00
Egor d2e6a42eb8 Add automated installer and monitoring script 2025-10-01 21:16:26 +03:00
Egor 5a7960bb14 Merge pull request #656 from Fr1ngg/main
w
2025-10-01 21:03:53 +03:00
Egor 27d48f68b4 Update index.html 2025-10-01 20:27:54 +03:00
Egor 9465c6cba3 Create index.html 2025-10-01 20:25:50 +03:00
Egor 3088be1dde Add desktop redirect fallback for Happ subscription links 2025-10-01 19:46:15 +03:00
Egor 845a8175fa Merge pull request #652 from Fr1ngg/revert-651-bedolaga/d
Revert "Fix desktop handling of happ subscription links"
2025-10-01 19:34:46 +03:00
Egor af27fb570c Revert "Fix desktop handling of happ subscription links" 2025-10-01 19:34:38 +03:00
Egor 181bb137fb Merge pull request #651 from Fr1ngg/bedolaga/d
Fix desktop handling of happ subscription links
2025-10-01 19:29:54 +03:00
Egor 220646c7ac Fix desktop handling of happ subscription links 2025-10-01 19:29:30 +03:00
Egor 4513deb2a1 Add files via upload 2025-10-01 07:11:06 +03:00
Egor e1001156b4 Update Dockerfile 2025-10-01 07:05:17 +03:00
Egor ba2c534622 Update Dockerfile 2025-10-01 07:05:02 +03:00
Egor 72f06a784e Update docker-registry.yml 2025-10-01 07:04:44 +03:00
Egor 04e862b6bd Update docker-hub.yml 2025-10-01 07:04:31 +03:00
Egor 7f4af89d60 Update miniapp-setup.md 2025-10-01 06:43:37 +03:00
Egor f68bb699eb Update index.html 2025-10-01 06:32:56 +03:00
Egor c1d19e2ade Merge pull request #648 from Fr1ngg/dev2
Миниапка с подпиской
2025-10-01 06:14:01 +03:00
Egor e4c7e8b27f Merge pull request #647 from Fr1ngg/bedolaga/localize-subscription-button-to-english-w0zeno
Adjust purchase button localization and navigation
2025-10-01 06:10:51 +03:00
Egor c63c483b9b Adjust purchase button localization and navigation 2025-10-01 06:10:37 +03:00
Egor 4b04696feb Merge pull request #646 from Fr1ngg/revert-645-bedolaga/localize-subscription-button-to-english
Revert "Adjust miniapp purchase button behavior"
2025-10-01 06:10:09 +03:00
Egor 9ffbba7259 Revert "Adjust miniapp purchase button behavior" 2025-10-01 06:10:02 +03:00
Egor cc0b927388 Merge pull request #645 from Fr1ngg/bedolaga/localize-subscription-button-to-english
Adjust miniapp purchase button behavior
2025-10-01 06:08:34 +03:00
Egor cbcf2d55da Adjust miniapp purchase button behavior 2025-10-01 06:08:18 +03:00
Egor f7c70f60e1 Merge pull request #644 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp-izs6w9
Add configurable purchase link button to mini app error state
2025-10-01 06:01:30 +03:00
Egor 7c96b0f71c Add purchase link support to mini app error state 2025-10-01 06:01:12 +03:00
Egor 2002032be3 Merge pull request #643 from Fr1ngg/revert-642-bedolaga/add-subscription-purchase-button-in-miniapp
Revert "Add configurable purchase link to the mini app"
2025-10-01 06:00:40 +03:00
Egor ed6ff293ea Revert "Add configurable purchase link to the mini app" 2025-10-01 06:00:29 +03:00
Egor 65e66353b2 Merge pull request #642 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp
Add configurable purchase link to the mini app
2025-10-01 05:57:08 +03:00
Egor 4fa87804d9 Add miniapp purchase link support 2025-10-01 05:56:51 +03:00
Egor 17587a9b30 Update miniapp-setup.md 2025-10-01 05:53:53 +03:00
Egor 37c552c705 Merge pull request #641 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding-etzom5
Open Happ CryptoLink subscriptions externally
2025-10-01 05:42:46 +03:00
Egor 95c6e72f1b fix: open happ cryptolink externally 2025-10-01 05:42:32 +03:00
Egor 4fa49e6f2e Merge pull request #640 from Fr1ngg/revert-639-bedolaga/remove-happ_cryptolink_redirect_template-binding
Revert "Open HAPP cryptolink from miniapp externally"
2025-10-01 05:41:48 +03:00
Egor 3aaa3d0671 Revert "Open HAPP cryptolink from miniapp externally" 2025-10-01 05:41:40 +03:00
Egor 9c013f19c4 Merge pull request #639 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding
Open HAPP cryptolink from miniapp externally
2025-10-01 05:39:33 +03:00
Egor c7b6929dec Open HAPP cryptolink in external browser 2025-10-01 05:39:17 +03:00
Egor 4dca932ec2 Merge pull request #638 from Fr1ngg/revert-637-bedolaga/fix-hap_cryptolink-redirect-link
Revert "Fix miniapp connect button to open Happ CryptoLink directly"
2025-10-01 05:36:38 +03:00
Egor 997fcf8ec7 Revert "Fix miniapp connect button to open Happ CryptoLink directly" 2025-10-01 05:35:43 +03:00
Egor eb8c8ec4ce Merge pull request #637 from Fr1ngg/bedolaga/fix-hap_cryptolink-redirect-link
Fix miniapp connect button to open Happ CryptoLink directly
2025-10-01 05:34:04 +03:00
Egor f4ed61c4ca Adjust miniapp connect link handling 2025-10-01 05:33:30 +03:00
Egor 70d3434952 Merge pull request #636 from Fr1ngg/revert-635-revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"""
2025-10-01 05:27:04 +03:00
Egor 450d75227b Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""" 2025-10-01 05:26:48 +03:00
Egor 6e61980fa9 Merge pull request #635 from Fr1ngg/revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""
2025-10-01 05:25:02 +03:00
Egor 777e243bcd Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"" 2025-10-01 05:24:52 +03:00
Egor 46d9c25968 Merge pull request #634 from Fr1ngg/revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Add configurable purchase link and direct Happ flow in miniapp"
2025-10-01 05:24:39 +03:00
Egor e26223a7d8 Revert "Add configurable purchase link and direct Happ flow in miniapp" 2025-10-01 05:24:28 +03:00
Egor 2098165229 Delete miniapp/index2.html 2025-10-01 05:19:00 +03:00
Egor c7e9d84585 Merge pull request #633 from Fr1ngg/bedolaga/remove-redirect-to-happ_cryptolink
Add configurable purchase link and direct Happ flow in miniapp
2025-10-01 05:17:29 +03:00
Egor 8a92d5e5a9 Add purchase link support and adjust miniapp connect flow 2025-10-01 05:17:12 +03:00
Egor 8b8f187cb3 Update index.html 2025-10-01 04:42:40 +03:00
Egor 02e3faf194 Create index2.html 2025-10-01 04:25:14 +03:00
Egor 7e8e05a0fc Merge pull request #631 from Fr1ngg/bedolaga/add-service-name-configuration-option-d3ekaj
Add configurable miniapp branding
2025-10-01 04:03:32 +03:00
Egor c3ca12a8a5 Update config.py 2025-10-01 04:03:14 +03:00
Egor d82b24927b Update README.md 2025-10-01 04:02:53 +03:00
Egor 7d9d05b1e8 Update .env.example 2025-10-01 04:02:33 +03:00
Egor cc96919672 Add configurable miniapp branding 2025-10-01 04:01:47 +03:00
Egor 1f0f5a4317 Merge pull request #630 from Fr1ngg/revert-629-bedolaga/add-service-name-configuration-option
Revert "feat: allow configuring miniapp branding"
2025-10-01 04:01:31 +03:00
Egor cd9218ec09 Revert "feat: allow configuring miniapp branding" 2025-10-01 04:01:22 +03:00
Egor 7256a0d1ab Merge pull request #629 from Fr1ngg/bedolaga/add-service-name-configuration-option
feat: allow configuring miniapp branding
2025-10-01 04:01:08 +03:00
Egor e2f5191844 Update config.py 2025-10-01 04:00:48 +03:00
Egor d964a2d53c Update README.md 2025-10-01 04:00:18 +03:00
Egor 7ace06834b Update .env.example 2025-10-01 03:59:49 +03:00
Egor 0a789d3bd1 feat: allow configuring miniapp branding 2025-10-01 03:58:34 +03:00
Egor 9c44c3ebd0 Merge pull request #628 from Fr1ngg/bedolaga/update-miniapp-index.html-template-mdwbtr
Enhance miniapp subscription details
2025-10-01 03:46:09 +03:00
Egor 67650666fc Enhance miniapp subscription details 2025-10-01 03:45:51 +03:00
Egor b5e343cfb0 Merge pull request #627 from Fr1ngg/revert-626-bedolaga/update-miniapp-index.html-template
Revert "Improve miniapp subscription metadata display"
2025-10-01 03:45:36 +03:00
Egor 68f72a88a4 Revert "Improve miniapp subscription metadata display" 2025-10-01 03:45:28 +03:00
Egor a225540c48 Merge pull request #626 from Fr1ngg/bedolaga/update-miniapp-index.html-template
Improve miniapp subscription metadata display
2025-10-01 03:44:30 +03:00
Egor 3fdc1fdeda Enhance miniapp subscription details 2025-10-01 03:44:05 +03:00
Egor 0748e9eed3 Merge pull request #625 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-waqu1h
Add localization and account data to miniapp
2025-10-01 03:17:27 +03:00
Egor f420e07a24 Add localization and account data to miniapp 2025-10-01 03:17:09 +03:00
Egor 885a56ecf4 Merge pull request #624 from Fr1ngg/revert-623-bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Revert "Enhance miniapp page with localization and account data"
2025-10-01 03:16:47 +03:00
Egor 4e4e80f3c4 Revert "Enhance miniapp page with localization and account data" 2025-10-01 03:16:38 +03:00
Egor 4942ac4c5e Merge pull request #623 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Enhance miniapp page with localization and account data
2025-10-01 03:14:58 +03:00
Egor 20f83ac751 feat: enhance miniapp UI with localization and account data 2025-10-01 03:14:40 +03:00
Egor 6b2b20cca2 Merge pull request #622 from Fr1ngg/revert-621-bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Revert "Add localization, balance, and server info to miniapp"
2025-10-01 03:13:44 +03:00
Egor 9db392b410 Revert "Add localization, balance, and server info to miniapp" 2025-10-01 03:13:34 +03:00
Egor 444e87d70e Merge pull request #621 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Add localization, balance, and server info to miniapp
2025-10-01 03:11:27 +03:00
Egor d8a6b6b08c Enhance miniapp with localization and account data 2025-10-01 03:11:09 +03:00
Egor 2658c298f0 Merge pull request #620 from Fr1ngg/revert-619-bedolaga/add-language-switch-and-subscription-redirect
Revert "Add localization and account data to mini app"
2025-10-01 03:10:44 +03:00
Egor 0ca4f9630a Revert "Add localization and account data to mini app" 2025-10-01 03:10:35 +03:00
Egor 718c9aba92 Merge pull request #619 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect
Add localization and account data to mini app
2025-10-01 03:05:52 +03:00
Egor 078f1f8878 Add localization and account info to mini app 2025-10-01 03:05:34 +03:00
Egor c76eeea020 Merge pull request #617 from Fr1ngg/bedolaga/integrate-miniapp-page-with-bot-api
Document miniapp deployment and reverse proxy setup
2025-10-01 02:32:57 +03:00
Egor f919368d0b Document miniapp deployment and reverse proxy setup 2025-10-01 02:32:36 +03:00
Egor ccb442fe44 Create index.html 2025-10-01 02:10:06 +03:00
Egor d8c0dd862b Merge pull request #616 from Fr1ngg/dev2
W
2025-10-01 02:08:39 +03:00
Egor 1ed418b176 Delete miniapp/index.html 2025-10-01 02:08:17 +03:00
Egor 59efd837cd Merge pull request #615 from Fr1ngg/revert-614-bedolaga/add-third-purchase-mode-for-subscriptions
Revert "Add tariff-based subscription mode"
2025-10-01 02:06:32 +03:00
Egor 60d23bedde Revert "Add tariff-based subscription mode" 2025-10-01 02:06:18 +03:00
Egor d08f74179f Merge pull request #614 from Fr1ngg/bedolaga/add-third-purchase-mode-for-subscriptions
Add tariff-based subscription mode
2025-10-01 02:05:00 +03:00
Egor 70ad084bcd Add tariff-aware subscription API handling 2025-10-01 02:04:20 +03:00
Egor 818ef1ccb0 Create index.html 2025-10-01 02:03:30 +03:00
Egor f6369f7b44 Merge pull request #613 from Fr1ngg/bedolaga/add-english-locale-support-14ehhu
Add English localization for subscription management flows
2025-10-01 01:23:19 +03:00
Egor 5f8295a588 Add English localization for subscription settings flows 2025-10-01 01:22:49 +03:00
Egor 5c51788dc3 Merge pull request #612 from Fr1ngg/revert-611-bedolaga/add-english-locale-support
Revert "Add English localization for subscription management interfaces"
2025-10-01 01:22:36 +03:00
Egor 24e6d57273 Revert "Add English localization for subscription management interfaces" 2025-10-01 01:22:28 +03:00
Egor be95d34122 Merge pull request #611 from Fr1ngg/bedolaga/add-english-locale-support
Add English localization for subscription management interfaces
2025-10-01 01:18:14 +03:00
Egor 359cdd106d Add English localization for subscription management menus 2025-10-01 01:17:43 +03:00
Egor 0bc5b10a78 Merge pull request #610 from Fr1ngg/bedolaga/add-english-localization-for-user-management
Add English localization for admin user management view
2025-10-01 01:04:24 +03:00
Egor efd4eda62a Add English localization for admin user view 2025-10-01 01:04:05 +03:00
Egor d4c169e6bb Merge pull request #609 from Fr1ngg/bedolaga/add-localization-to-admin-panel-submenu
Add English localization for admin support menus
2025-10-01 00:08:24 +03:00
Egor c12f3d443c Add English localization for admin support menus 2025-10-01 00:08:03 +03:00
Egor db7ecea006 Merge pull request #608 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue-xgkdfd
Handle additional MulenPay webhook signature formats
2025-09-30 23:57:35 +03:00
Egor 2b4136faa7 Handle additional MulenPay webhook signature formats 2025-09-30 23:57:18 +03:00
Egor ca09ce8cf4 Merge pull request #607 from Fr1ngg/revert-606-bedolaga/fix-payment-callback-signature-issue
Revert "Improve MulenPay webhook signature handling"
2025-09-30 23:57:02 +03:00
Egor 533087b333 Revert "Improve MulenPay webhook signature handling" 2025-09-30 23:56:54 +03:00
Egor 80afcbb737 Merge pull request #606 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue
Improve MulenPay webhook signature handling
2025-09-30 23:51:44 +03:00
Egor 845faf037f Improve MulenPay webhook signature handling 2025-09-30 23:51:29 +03:00
Egor 1f160ced02 Merge pull request #605 from Fr1ngg/bedolaga/add-english-translations-for-admin-panel
Add missing admin panel locale translations
2025-09-30 23:47:25 +03:00
Egor 07e4c07d6e Add admin panel translations for ru/en locales 2025-09-30 23:47:06 +03:00
Egor 78ab84b021 Merge pull request #604 from Fr1ngg/bedolaga/add-card-payment-option-to-paypalych
Support PayPalych card payments with configurable button labels
2025-09-30 23:42:36 +03:00
Egor db5551df23 Add customizable PayPalych payment buttons 2025-09-30 23:42:19 +03:00
Egor ee75395c31 Merge pull request #603 from Fr1ngg/bedolaga/add-missing-translations-for-admin-panel
Add missing admin translations for Russian and English locales
2025-09-30 22:16:22 +03:00
Egor 453bb75b7f Add missing admin translations for Russian and English 2025-09-30 22:15:57 +03:00
Egor 88d2c8325d Merge pull request #602 from Fr1ngg/bedolaga/add-notifications-for-unsubscribed-users
Add monitoring notification for trial users leaving the channel
2025-09-30 21:45:13 +03:00
Egor 8fe9a3dbe2 feat: notify trial users when leaving channel 2025-09-30 21:44:49 +03:00
Egor cf6c282b25 Merge pull request #600 from PEDZEO/main
fix /start moderator
2025-09-30 21:12:03 +03:00
Egor 773e525a6a Merge pull request #599 from SantaSpeen/main
[+] Adding support stock app-config.json
2025-09-30 21:08:44 +03:00
Egor a7d35d51b8 Merge pull request #601 from Fr1ngg/revert-596-bedolaga/fix-all-merge-conflicts
Revert "Resolve duplicate handlers and cleanup imports"
2025-09-30 21:07:46 +03:00
Egor 46eb93dabe Revert "Resolve duplicate handlers and cleanup imports" 2025-09-30 21:07:37 +03:00
Egor 43e63f864e Merge pull request #594 from Gy9vin/main
Расширенный фильтр пользователей
2025-09-30 20:56:45 +03:00
PEDZEO 8774b53570 fix /start moderator 2025-09-30 18:45:44 +03:00
SantaSpeen 2aaec2ca58 Merge pull request #1 from SantaSpeen/slave
Merge into main
2025-09-30 17:20:56 +03:00
SantaSpeen 5efe36878d [~] Update upstream 2025-09-30 17:20:08 +03:00
SantaSpeen b9861249b7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/handlers/subscription.py
2025-09-30 17:11:32 +03:00
Egor 2c9b585f4b Merge pull request #597 from Fr1ngg/dev2
fix
2025-09-30 16:51:54 +03:00
Egor dd5b36ff4a Merge pull request #596 from Fr1ngg/bedolaga/fix-all-merge-conflicts
Resolve duplicate handlers and cleanup imports
2025-09-30 16:41:09 +03:00
Egor 144a942a97 Resolve duplicate handlers and cleanup imports 2025-09-30 16:40:08 +03:00
SantaSpeen ff661d84ee [+] Update app-config.json: new get_apps_for_device 2025-09-30 14:46:55 +03:00
SantaSpeen ab1b8619f8 [+] Update get_apps_for_device: Allow app-config.json just from panel 2025-09-30 14:26:22 +03:00
gy9vin 0f621bfe5a Revert "Оживление виджета "Детальная статистика пользователей""
This reverts commit 400cc5a32e.
2025-09-30 12:42:17 +03:00
gy9vin c344f418c5 Расширение фильтров 2025-09-30 12:39:15 +03:00
gy9vin 400cc5a32e Оживление виджета "Детальная статистика пользователей" 2025-09-30 11:55:04 +03:00
Egor b6a383b5d8 Merge pull request #593 from Fr1ngg/dev2
Логи в боте
2025-09-30 04:37:25 +03:00
Egor a8215ddec5 Update system_logs.py 2025-09-30 04:34:09 +03:00
Egor 954c96ed16 Merge pull request #592 from Fr1ngg/bedolaga/add-log-section-to-admin-panel
Add system logs view to admin panel
2025-09-30 04:30:47 +03:00
Egor ed9a3613f5 Add system logs view to admin panel 2025-09-30 04:30:30 +03:00
Egor 29743351de Delete app/webapi/vpn-admin-dashboard.html 2025-09-30 03:52:57 +03:00
Egor fd897464f5 Update Dockerfile 2025-09-30 03:52:36 +03:00
Egor d9b0420202 Update docker-registry.yml 2025-09-30 03:52:24 +03:00
Egor 527d907035 Update docker-hub.yml 2025-09-30 03:52:11 +03:00
Egor 6c3270e6d7 Merge pull request #590 from Fr1ngg/dev2
Dev to main
2025-09-30 03:36:11 +03:00
Egor b7382eb7c3 Merge pull request #589 from Fr1ngg/bedolaga/update-bot-configuration-menu
Update admin bot configuration menu grouping
2025-09-30 03:32:04 +03:00
Egor 0cd7d45338 Refine bot configuration menu structure 2025-09-30 03:31:37 +03:00
Egor 23dcb9699c Merge pull request #588 from Fr1ngg/bedolaga/fix-duplicate-cryptolink-display
Remove duplicate Happ CryptoLink link display
2025-09-30 03:17:49 +03:00
Egor 9603952b2a Adjust Happ CryptoLink message formatting 2025-09-30 03:17:17 +03:00
Egor 299eeee9d3 Merge pull request #587 from Fr1ngg/bedolaga-xjizv4
Convert Happ cryptolink message to happ scheme
2025-09-30 03:11:48 +03:00
Egor 63c7e9c08b Adjust Happ cryptolink message formatting 2025-09-30 03:11:28 +03:00
Egor 42131457bf Merge pull request #586 from Fr1ngg/revert-585-bedolaga-uswi1l
Revert "feat: update Happ cryptolink subscription link handling"
2025-09-30 03:11:17 +03:00
Egor a7370fbe52 Revert "feat: update Happ cryptolink subscription link handling" 2025-09-30 03:11:08 +03:00
Egor 41f0a15cea Merge pull request #585 from Fr1ngg/bedolaga-uswi1l
feat: update Happ cryptolink subscription link handling
2025-09-30 03:08:59 +03:00
Egor dd92f37fc1 feat: adjust Happ cryptolink presentation 2025-09-30 03:08:42 +03:00
Egor 46d4d699dd Merge pull request #584 from Fr1ngg/revert-583-bedolaga-bsbzht
Revert "Update Happ CryptoLink message formatting"
2025-09-30 03:07:37 +03:00
Egor 76b594363b Revert "Update Happ CryptoLink message formatting" 2025-09-30 03:07:28 +03:00
Egor 107b18d642 Merge pull request #583 from Fr1ngg/bedolaga-bsbzht
Update Happ CryptoLink message formatting
2025-09-30 03:05:01 +03:00
Egor 9973491503 Update Happ CryptoLink message formatting 2025-09-30 03:04:26 +03:00
Egor 418081048e Merge pull request #582 from Fr1ngg/bedolaga/add-language-selection-toggle-in-bot-config
feat: allow disabling language selection
2025-09-30 02:51:53 +03:00
Egor f22838b99f feat: make language selection optional 2025-09-30 02:51:31 +03:00
Egor 9bdacea568 Update .env.example 2025-09-30 02:50:26 +03:00
Egor 6bec0ed0c8 Merge pull request #581 from Fr1ngg/bedolaga/add-language-selection-at-start
Add language selection workflow and menu option
2025-09-30 02:38:29 +03:00
Egor 4363955443 Add language selection workflow and menu option 2025-09-30 02:37:57 +03:00
Egor 47a272c7e8 Merge pull request #580 from Fr1ngg/bedolaga/fix-subscription-renewal-error
Prevent async session errors when extending subscriptions
2025-09-30 02:17:24 +03:00
Egor 07ce852917 Fix subscription extension notifications and bot init 2025-09-30 02:17:06 +03:00
Egor a6ada5c9e3 Merge pull request #579 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups-zboyk5
feat: expand admin promo notifications
2025-09-30 02:06:47 +03:00
Egor 90c5b1d003 feat: expand admin notifications for promo events 2025-09-30 02:06:31 +03:00
Egor 326aca31d7 Merge pull request #578 from Fr1ngg/revert-577-bedolaga/add-admin-notifications-for-promo-groups
Revert "Enhance admin notifications with promo group context"
2025-09-30 02:06:20 +03:00
Egor 4fd5ba863b Revert "Enhance admin notifications with promo group context" 2025-09-30 02:06:11 +03:00
Egor 4bd0b221e7 Merge pull request #577 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups
Enhance admin notifications with promo group context
2025-09-30 01:57:14 +03:00
Egor f47511f26a Enhance admin notifications with promo group context 2025-09-30 01:56:52 +03:00
Egor 2d409eaa1f Merge pull request #576 from Fr1ngg/bedolaga/update-subscription-status-to-icons
Move subscription status icons into server user buttons
2025-09-30 01:41:16 +03:00
Egor 9d42c4fb7d Adjust server user list status icons 2025-09-30 01:41:03 +03:00
Egor d3ced8f7e2 Merge pull request #575 from Fr1ngg/bedolaga/add-pagination-to-users-list-page
Add pagination to admin server users list
2025-09-30 01:34:24 +03:00
Egor a5715b2ca8 Add pagination to server users admin view 2025-09-30 01:34:07 +03:00
Egor c18dd604d8 Merge pull request #574 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-g9vyec
Fix server user list to account for JSON-based connections
2025-09-30 01:28:59 +03:00
Egor c2656f4ee6 Fix server users list by including JSON connections 2025-09-30 01:28:43 +03:00
Egor 18641d46d3 Merge pull request #573 from Fr1ngg/revert-572-bedolaga/fix-user-count-display-on-server-list-e72yq9
Revert "Fix server user list counts"
2025-09-30 01:28:28 +03:00
Egor 7ef3a08215 Revert "Fix server user list counts" 2025-09-30 01:28:20 +03:00
Egor 55f1a3b220 Merge pull request #572 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-e72yq9
Fix server user list counts
2025-09-30 01:26:51 +03:00
Egor e2f313e6ea Fix server user list counts 2025-09-30 01:26:37 +03:00
Egor 5f99478433 Merge pull request #571 from Fr1ngg/revert-570-bedolaga/fix-user-count-display-on-server-list-gt974l
Revert "Fix server user list to include JSON connections"
2025-09-30 01:26:12 +03:00
Egor 89f0a3663d Revert "Fix server user list to include JSON connections" 2025-09-30 01:26:03 +03:00
Egor e53dab87e3 Merge pull request #570 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-gt974l
Fix server user list to include JSON connections
2025-09-30 01:24:22 +03:00
Egor 6d85c179bb Fix server user list to include JSON connections 2025-09-30 01:24:05 +03:00
Egor 30dafcef49 Merge pull request #569 from Fr1ngg/revert-568-bedolaga/fix-user-count-display-on-server-list
Revert "Fix admin server user list query"
2025-09-30 01:23:47 +03:00
Egor 32d7a7394e Revert "Fix admin server user list query" 2025-09-30 01:23:36 +03:00
Egor 3cb4f27433 Merge pull request #568 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list
Fix admin server user list query
2025-09-30 01:22:06 +03:00
Egor 50692566d5 Fix server user list to use connected squads 2025-09-30 01:21:49 +03:00
Egor fbb2afd9e1 Merge pull request #567 from Fr1ngg/bedolaga/update-backup-and-restore-mechanism
Expand backup coverage to include configuration snapshots
2025-09-30 01:03:15 +03:00
Egor 3de951b153 Enhance backup coverage and configuration snapshots 2025-09-30 01:02:57 +03:00
Egor 1b86d467b5 Merge pull request #566 from Fr1ngg/bedolaga/investigate-subscription-cancellation-bug
Convert extended trials to paid subscriptions
2025-09-30 00:51:15 +03:00
Egor e4743ced9b Convert extended trials to paid subscriptions 2025-09-30 00:50:57 +03:00
Egor 2351056d56 Merge pull request #565 from Fr1ngg/bedolaga/fix-subscription-promotion-allocation
Fix total spent calculation for promo groups
2025-09-30 00:43:06 +03:00
Egor 748f9a2393 Fix total spent calculation for promo groups 2025-09-30 00:42:46 +03:00
Egor ada15065ea Merge pull request #564 from Fr1ngg/bedolaga/fix-user-statistics-in-admin-panel
Fix admin user subscription stats
2025-09-29 17:37:52 +03:00
Egor 53f1831f59 Fix admin user subscription stats 2025-09-29 17:37:20 +03:00
Egor 860ebf18b6 Merge pull request #563 from Fr1ngg/bedolaga/fix-promotion-group-mechanics-and-discounts
Fix base period discount rounding and summary formatting
2025-09-29 16:00:27 +03:00
Egor ae05ca08ea Round discounted subscription prices up and clean summary 2025-09-29 15:43:50 +03:00
Egor 88e8a56526 Merge pull request #562 from Fr1ngg/bedolaga/fix-transaction-history-logging-for-admin-actions
Log admin-triggered balance deductions in transaction history
2025-09-29 15:11:05 +03:00
Egor fb4d714441 Log admin balance deductions as transactions 2025-09-29 15:10:47 +03:00
Egor 2690f63a82 Merge pull request #561 from Fr1ngg/bedolaga/update-subscription-pricing-calculation
Fix admin subscription purchase cost calculation
2025-09-29 14:59:02 +03:00
Egor ec9855a0a9 Fix admin subscription price calculation 2025-09-29 14:57:47 +03:00
Egor 984ad8e448 Merge pull request #560 from Fr1ngg/bedolaga/add-traffic-reset-on-subscription-payment
Add configurable traffic reset on subscription payments
2025-09-29 14:41:06 +03:00
Egor ca81b32502 Add configurable traffic reset on payments 2025-09-29 14:40:36 +03:00
Egor 70f190501b Merge pull request #559 from Fr1ngg/bedolaga/fix-automation-migration-errors
Fix promo group migration defaults for addon discounts
2025-09-29 14:21:46 +03:00
Egor 432efdbb7c Fix promo group seeding for new databases 2025-09-29 14:21:23 +03:00
Egor 83a821a591 Merge pull request #558 from Fr1ngg/bedolaga/add-trial-and-subscription-days-summation
Add setting to roll remaining trial time into paid subscriptions
2025-09-29 14:02:33 +03:00
Egor 14bdfd4a1e Add configurable trial rollover for paid subscriptions 2025-09-29 14:02:14 +03:00
Egor 66ff2a38d3 Merge pull request #557 from Fr1ngg/bedolaga/update-subscription-link-handling
Respect hidden subscription link setting
2025-09-29 13:40:46 +03:00
Egor 9f7c9cad38 feat: respect hidden subscription link setting 2025-09-29 13:40:27 +03:00
Egor 2132226b15 Merge pull request #556 from Fr1ngg/revert-555-bedolaga/hide-subscription-link-in-notifications
Revert "Hide subscription link when configured"
2025-09-29 13:16:49 +03:00
Egor 849465b2e1 Revert "Hide subscription link when configured" 2025-09-29 13:16:38 +03:00
Egor 402b31625b Merge pull request #555 from Fr1ngg/bedolaga/hide-subscription-link-in-notifications
Hide subscription link when configured
2025-09-29 13:12:42 +03:00
Egor 5fc24dbfae Hide subscription link in success messages when configured 2025-09-29 13:12:24 +03:00
Egor 8ba65cc2fe Merge pull request #554 from Fr1ngg/main
w
2025-09-29 13:02:20 +03:00
Egor ee2bffe08e Merge pull request #550 from PEDZEO/feature/ticket-user-info
Исправил поведение кнопок
2025-09-29 12:58:48 +03:00
PEDZEO c4b5db9002 fix(validation): sanitize имён + HTML-escape только в /start 2025-09-29 02:17:19 +03:00
PEDZEO 1a78bdc194 Исправил поведение кнопок 2025-09-29 01:31:49 +03:00
Egor a3bb749a6a Merge pull request #549 from Fr1ngg/main
w
2025-09-28 22:39:25 +03:00
Egor 626f78b230 Merge pull request #548 from PEDZEO/feature/ticket-user-info
Feature/ticket user info
2025-09-28 22:21:47 +03:00
PEDZEO 5d44285c0a feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:47 +03:00
PEDZEO 2d8091ddcb feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:29 +03:00
Egor fa6a4698ab Merge pull request #546 from FireWookie/dev2
Update API
2025-09-28 16:41:40 +03:00
firewookie f40454313f add ads compaigns remote route 2025-09-28 14:21:53 +05:00
firewookie 8a256b1bc2 add broadcast service 2025-09-28 14:05:35 +05:00
firewookie 71ac7ea464 backup logic and add documentation to stats route 2025-09-28 14:04:17 +05:00
firewookie bbc9f93429 update api 2025-09-28 13:36:53 +05:00
firewookie 3ba9598b5e update api 2025-09-28 13:35:13 +05:00
firewookie ffa51986a8 env to json 2025-09-28 13:34:43 +05:00
Egor 7e20140a00 Merge pull request #544 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue-j3pfif
Add fallback for bootstrap API token authentication
2025-09-28 06:28:06 +03:00
Egor 50d080f54e Improve web API token authentication fallback 2025-09-28 06:27:38 +03:00
Egor 29829b1c8c Merge pull request #543 from Fr1ngg/revert-542-bedolaga/fix-bot-token-authorization-issue
Revert "Ensure bootstrap web API token works without migration"
2025-09-28 06:26:47 +03:00
Egor e84e707d2f Revert "Ensure bootstrap web API token works without migration" 2025-09-28 06:26:37 +03:00
Egor c12566ae53 Merge pull request #542 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue
Ensure bootstrap web API token works without migration
2025-09-28 06:24:31 +03:00
Egor e6976b5262 Handle bootstrap API token fallback 2025-09-28 06:24:13 +03:00
Egor 81cfdcf7ac Merge pull request #541 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-jxb0f9
Synchronize bootstrap web API token with config overrides
2025-09-28 06:16:38 +03:00
Egor 9c1ca5b748 Ensure default web API token stays in sync with settings 2025-09-28 06:16:13 +03:00
Egor a0d14f97f4 Merge pull request #540 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-mvwipi
Handle missing RemnaWave dependencies in web API
2025-09-28 06:03:25 +03:00
Egor 8fdc70fdc3 Handle optional RemnaWave dependencies in web API 2025-09-28 06:03:11 +03:00
Egor d93139a458 Merge pull request #539 from Fr1ngg/revert-538-bedolaga/add-admin-panel-functionality-to-bot-api
Revert "Handle optional RemnaWave dependencies in web API"
2025-09-28 06:02:51 +03:00
Egor b78dc12db9 Revert "Handle optional RemnaWave dependencies in web API" 2025-09-28 06:02:42 +03:00
Egor 2a02f589bf Merge pull request #538 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api
Handle optional RemnaWave dependencies in web API
2025-09-28 06:00:37 +03:00
Egor bcfa4f8400 Handle optional RemnaWave dependencies in web API 2025-09-28 06:00:18 +03:00
Egor 042dc900ef Merge pull request #537 from Fr1ngg/bedolaga/add-endpoint-information-to-redoc
Fix OpenAPI export for RemnaWave endpoints
2025-09-28 05:45:49 +03:00
Egor 6004607bf6 Update __init__.py 2025-09-28 05:45:41 +03:00
Egor 40e4b1ce7f Ensure RemnaWave routes are imported for OpenAPI 2025-09-28 05:45:09 +03:00
Egor a4c5a2295f Merge pull request #536 from Fr1ngg/bedolaga/fix-missing-remnawave-endpoints-in-docs
Fix RemnaWave API docs registration
2025-09-28 05:29:01 +03:00
Egor 3d9a75ff51 Fix RemnaWave API docs registration 2025-09-28 05:28:41 +03:00
Egor 1216a4dd9d Merge pull request #535 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints-6ae7or
Add RemnaWave API documentation to Swagger
2025-09-28 05:09:45 +03:00
Egor 4bc1c402e9 feat: document remnawave endpoints in swagger 2025-09-28 05:09:24 +03:00
Egor 42a758bd68 Merge pull request #534 from Fr1ngg/revert-533-bedolaga/fix-documentation-for-remnawave-endpoints
Revert "Fix RemnaWave routes visibility in docs"
2025-09-28 05:08:18 +03:00
Egor 07a0d0ca43 Revert "Fix RemnaWave routes visibility in docs" 2025-09-28 05:08:08 +03:00
Egor f53b04444b Merge pull request #533 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints
Fix RemnaWave routes visibility in docs
2025-09-28 05:06:32 +03:00
Egor 9877c00c92 Fix RemnaWave routes visibility in docs 2025-09-28 05:06:16 +03:00
Egor 14aa175193 Merge pull request #532 from Fr1ngg/revert-529-revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Revert "Add RemnaWave management endpoints to web API""
2025-09-28 04:57:19 +03:00
Egor 562766a637 Revert "Revert "Add RemnaWave management endpoints to web API"" 2025-09-28 04:57:10 +03:00
Egor 4616911189 Merge pull request #531 from Fr1ngg/revert-530-bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Revert "Add Remnawave component management API"
2025-09-28 04:56:43 +03:00
Egor 1e14a0919f Revert "Add Remnawave component management API" 2025-09-28 04:56:33 +03:00
Egor e1f0bd152c Merge pull request #530 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Add Remnawave component management API
2025-09-28 04:53:36 +03:00
Egor c10e34ad43 Add Remnawave component management API 2025-09-28 04:53:01 +03:00
Egor 714f742560 Merge pull request #529 from Fr1ngg/revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Add RemnaWave management endpoints to web API"
2025-09-28 04:51:51 +03:00
Egor dc5e440fe2 Revert "Add RemnaWave management endpoints to web API" 2025-09-28 04:51:42 +03:00
Egor d53873dc7f Merge pull request #527 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components
Add RemnaWave management endpoints to web API
2025-09-28 04:46:12 +03:00
Egor 10ab557d8d feat(webapi): add remnawave integration endpoints 2025-09-28 04:45:57 +03:00
Egor 81147d42a9 Merge pull request #526 from Fr1ngg/revert-525-bedolaga/-scalar-redoc-api-osbdld
Revert "Replace ReDoc docs with Scalar UI"
2025-09-28 04:30:48 +03:00
Egor 8d5e2037d3 Revert "Replace ReDoc docs with Scalar UI" 2025-09-28 04:30:38 +03:00
Egor 7f03e61b5f Merge pull request #525 from Fr1ngg/bedolaga/-scalar-redoc-api-osbdld
Replace ReDoc docs with Scalar UI
2025-09-28 04:28:00 +03:00
Egor 870ee2fb66 Replace ReDoc docs with Scalar UI 2025-09-28 04:27:43 +03:00
Egor a525079282 Merge pull request #524 from Fr1ngg/revert-523-bedolaga/-scalar-redoc-api
Revert "Replace ReDoc with Scalar API reference"
2025-09-28 04:27:06 +03:00
Egor 39aff2bc1b Revert "Replace ReDoc with Scalar API reference" 2025-09-28 04:26:57 +03:00
Egor b4dc9f1970 Merge pull request #523 from Fr1ngg/bedolaga/-scalar-redoc-api
Replace ReDoc with Scalar API reference
2025-09-28 04:24:32 +03:00
Egor 37ac3e7a81 Replace ReDoc with Scalar API reference 2025-09-28 04:24:13 +03:00
Egor e3e6428d2a Merge pull request #522 from Fr1ngg/bedolaga/enable-swagger-for-api-in-bot
docs: explain enabling swagger ui
2025-09-28 04:20:43 +03:00
Egor 3e3385d1eb docs: explain enabling swagger ui 2025-09-28 04:20:28 +03:00
Egor 0375e74227 Merge pull request #521 from Fr1ngg/revert-505-revert-504-bedolaga/-docs
Revert "Revert "Enable API key auth in web API docs""
2025-09-28 04:02:41 +03:00
Egor b6bdeeefdd Revert "Revert "Enable API key auth in web API docs"" 2025-09-28 04:02:28 +03:00
Egor 24b3660f7b Merge pull request #520 from Fr1ngg/bedolaga/fix-bot-startup-issue
Fix database URL initialization
2025-09-28 03:56:01 +03:00
Egor da2c30cd8d Fix database URL initialization 2025-09-28 03:55:43 +03:00
Egor 1fad95c0bb Merge pull request #519 from Fr1ngg/revert-518-bedolaga/fix-bot-startup-validation-errors-f8krj6
Revert "Allow bot to boot without RemnaWave API settings"
2025-09-28 03:50:14 +03:00
Egor 6a1b1a9a91 Revert "Allow bot to boot without RemnaWave API settings" 2025-09-28 03:50:06 +03:00
Egor 9abf0b6ebe Merge pull request #518 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-f8krj6
Allow bot to boot without RemnaWave API settings
2025-09-28 03:46:33 +03:00
Egor 34e90b335b Handle missing RemnaWave configuration 2025-09-28 03:46:12 +03:00
Egor e84c82f29d Merge pull request #517 from Fr1ngg/revert-506-bedolaga/fix-bot-startup-error-due-to-missing-database_url
Revert "Make DATABASE_URL optional for default setup"
2025-09-28 03:38:31 +03:00
Egor 1c929a1a70 Revert "Make DATABASE_URL optional for default setup" 2025-09-28 03:38:23 +03:00
Egor 1c8878e651 Merge pull request #516 from Fr1ngg/revert-507-bedolaga/fix-missing-database_url-in-settings
Revert "Fix default handling for DATABASE_URL setting"
2025-09-28 03:37:53 +03:00
Egor 84645d84f7 Revert "Fix default handling for DATABASE_URL setting" 2025-09-28 03:37:44 +03:00
Egor 0cc59501bc Merge pull request #515 from Fr1ngg/revert-510-bedolaga/fix-missing-database_url-in-settings-wqxv3w
Revert "Allow optional Remnawave integration settings"
2025-09-28 03:36:54 +03:00
Egor ef6aaa58ea Revert "Allow optional Remnawave integration settings" 2025-09-28 03:36:42 +03:00
Egor 942ce48241 Merge pull request #514 from Fr1ngg/revert-513-bedolaga/fix-bot-startup-validation-errors-2waops
Revert "Handle missing optional RemnaWave settings"
2025-09-28 03:36:19 +03:00
Egor 625ec43c34 Revert "Handle missing optional RemnaWave settings" 2025-09-28 03:36:10 +03:00
Egor f184303415 Merge pull request #513 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-2waops
Handle missing optional RemnaWave settings
2025-09-28 03:35:02 +03:00
Egor 84a17864cd Handle missing optional RemnaWave settings 2025-09-28 03:34:44 +03:00
Egor 1476247519 Merge pull request #512 from Fr1ngg/revert-511-bedolaga/fix-bot-startup-validation-errors
Revert "Fix missing defaults for optional Remnawave settings"
2025-09-28 03:34:05 +03:00
Egor 0ea95005ee Revert "Fix missing defaults for optional Remnawave settings" 2025-09-28 03:33:56 +03:00
Egor ba808f66da Merge pull request #511 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Fix missing defaults for optional Remnawave settings
2025-09-28 03:32:42 +03:00
Egor e8472a502f Make optional Remnawave settings nullable by default 2025-09-28 03:32:23 +03:00
Egor 7d552ab715 Merge pull request #510 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-wqxv3w
Allow optional Remnawave integration settings
2025-09-28 03:27:55 +03:00
Egor 00de3a41df Allow optional Remnawave integration settings 2025-09-28 03:27:25 +03:00
Egor 36f38187d9 Merge pull request #509 from Fr1ngg/revert-508-bedolaga/fix-missing-database_url-in-settings-gvqm03
Revert "Make Remnawave settings optional by default"
2025-09-28 03:27:12 +03:00
Egor dd68b2b788 Revert "Make Remnawave settings optional by default" 2025-09-28 03:27:02 +03:00
Egor 7a9d37c884 Merge pull request #508 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-gvqm03
Make Remnawave settings optional by default
2025-09-28 03:25:20 +03:00
Egor 6a904d4be2 Make Remnawave settings optional by default 2025-09-28 03:25:04 +03:00
Egor b5b8408055 Merge pull request #507 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings
Fix default handling for DATABASE_URL setting
2025-09-28 03:20:07 +03:00
Egor 86a3228579 Fix default handling for DATABASE_URL setting 2025-09-28 03:19:52 +03:00
Egor 560993c0f3 Merge pull request #506 from Fr1ngg/bedolaga/fix-bot-startup-error-due-to-missing-database_url
Make DATABASE_URL optional for default setup
2025-09-28 03:11:15 +03:00
Egor a3fe935149 Make DATABASE_URL optional in settings 2025-09-28 03:10:57 +03:00
Egor a75549aebf Merge pull request #505 from Fr1ngg/revert-504-bedolaga/-docs
Revert "Enable API key auth in web API docs"
2025-09-28 03:02:31 +03:00
Egor be3fc7aa09 Revert "Enable API key auth in web API docs" 2025-09-28 03:02:22 +03:00
Egor 6cbfc6d74b Merge pull request #504 from Fr1ngg/bedolaga/-docs
Enable API key auth in web API docs
2025-09-28 02:59:25 +03:00
Egor 7ff7a051d9 Enable API key auth in web API docs 2025-09-28 02:59:07 +03:00
Egor 9a2f156158 Merge pull request #503 from Fr1ngg/bedolaga/fix-err_empty_response-on-api-endpoint
Expose admin API port for docker deployments
2025-09-28 02:48:58 +03:00
Egor 38b058636c Expose admin API port in docker compose 2025-09-28 02:48:42 +03:00
Egor 32523f5dea Merge pull request #502 from Fr1ngg/bedolaga/fix-web-api-startup-error
Handle uvicorn startup flag regression for admin web API
2025-09-28 02:32:09 +03:00
Egor ff70e38dbc Fix web API startup wait for uvicorn 0.32 2025-09-28 02:31:56 +03:00
Egor 8110f52bc3 Merge pull request #501 from Fr1ngg/bedolaga/fix-bot-api-launch-issue-3qh95y
Fix FastAPI 204 delete endpoints to return empty responses
2025-09-28 02:25:43 +03:00
Egor a5a0176433 Fix 204 responses to avoid response bodies 2025-09-28 02:25:26 +03:00
Egor 2d960e3e30 Merge pull request #500 from Fr1ngg/revert-499-bedolaga/fix-bot-api-launch-issue
Revert "Fix 204 responses for web API deletes"
2025-09-28 02:25:10 +03:00
Egor b5fa978023 Revert "Fix 204 responses for web API deletes" 2025-09-28 02:25:01 +03:00
Egor 9cc49e9f66 Merge pull request #499 from Fr1ngg/bedolaga/fix-bot-api-launch-issue
Fix 204 responses for web API deletes
2025-09-28 02:24:17 +03:00
Egor dbf63e518f Fix 204 responses for web API deletes 2025-09-28 02:23:58 +03:00
Egor 3e218b6e6d Merge pull request #498 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-vi0z1h
Handle RemnaWave server removal and add connected users view
2025-09-28 02:08:25 +03:00
Egor 2f52f6b8de Remove missing RemnaWave servers and list connected users 2025-09-28 02:08:05 +03:00
Egor 5d377cf885 Merge pull request #497 from Fr1ngg/revert-496-bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Revert "Handle removed RemnaWave squads and expose server users"
2025-09-28 02:07:50 +03:00
Egor 62ecacc3cc Revert "Handle removed RemnaWave squads and expose server users" 2025-09-28 02:07:43 +03:00
Egor 4ab6d45e50 Merge pull request #496 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Handle removed RemnaWave squads and expose server users
2025-09-28 02:02:55 +03:00
Egor d1bed5e41a Handle removed RemnaWave squads and expose server users 2025-09-28 02:02:37 +03:00
Egor b907f884ba Merge pull request #495 from Fr1ngg/revert-494-bedolaga/fix-server-deletion-issue-in-subscriptions
Revert "Handle RemnaWave server removal and expose server user list"
2025-09-28 02:02:08 +03:00
Egor 83a76c928d Revert "Handle RemnaWave server removal and expose server user list" 2025-09-28 02:01:57 +03:00
Egor 90d3f3aef2 Merge pull request #494 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions
Handle RemnaWave server removal and expose server user list
2025-09-28 01:56:59 +03:00
Egor 263df606d2 Remove missing RemnaWave servers during sync and add user list view 2025-09-28 01:56:40 +03:00
Egor 2a38969e3d Merge pull request #493 from Fr1ngg/main
w
2025-09-28 01:50:53 +03:00
Egor 300feec18e Merge pull request #492 from Fr1ngg/dev3
Dev3
2025-09-28 01:38:14 +03:00
Egor 7c56452eb4 Merge pull request #491 from Fr1ngg/bedolaga/fix-payment-button-and-status-issues
Fix PayPalych SBP payments
2025-09-28 01:34:02 +03:00
Egor 548ec540f2 Fix PayPalych SBP payments 2025-09-28 01:33:38 +03:00
Egor bceba34cc3 Merge pull request #490 from Fr1ngg/main
w
2025-09-28 01:14:29 +03:00
Egor b07ce2be5c Merge pull request #487 from yazhog/main
Пропуск выбора стран при оформлении подписки если доступен только 1 сквад, фикс уведомлений при пополнении баланса
2025-09-28 01:13:50 +03:00
Egor 9472cf5e8c Merge pull request #484 from PEDZEO/feature/ticket-report
Добавлен отчет по тикетам
2025-09-28 01:12:51 +03:00
yazhog 82f34e8c6e Merge pull request #44 from yazhog/codex/update-payment-options-layout
Reorder subscription return button in top-up selection
2025-09-27 16:48:18 +03:00
yazhog 02b718dac5 Adjust top-up keyboard button order 2025-09-27 16:44:52 +03:00
yazhog a65a23acb2 Merge pull request #43 from yazhog/codex/refactor-admin-notification-subscription-handling
Avoid lazy subscription load in admin notifications
2025-09-27 15:58:11 +03:00
yazhog 12523e7515 Avoid lazy subscription load in admin notifications 2025-09-27 15:54:38 +03:00
yazhog d3497f924a Merge pull request #42 from yazhog/codex/skip-country-selection-if-one-available
Skip country selection step when only one option is available
2025-09-27 13:29:20 +03:00
yazhog 75faa54b85 Skip country selection when only one option 2025-09-27 13:10:03 +03:00
Legacyyy777 02a9ac9be5 Merge branch 'Fr1ngg:main' into DEV 2025-09-26 20:39:23 +05:00
Pedzeo d22f9aaf62 Добавлен отчет по тикетам 2025-09-26 17:14:00 +03:00
Egor 68f5e2ab80 Add files via upload 2025-09-26 15:17:29 +03:00
Egor 4e2b71cb85 Update Dockerfile 2025-09-26 05:42:14 +03:00
Egor e229a7ee65 Merge pull request #480 from Fr1ngg/bedolaga/update-api-bot-for-web-admin-integration
Document and type admin web API integration
2025-09-26 05:37:28 +03:00
Egor abf1f53218 Add web admin API schemas and docs 2025-09-26 05:37:08 +03:00
Egor 2859219f80 Merge pull request #479 from Fr1ngg/revert-478-revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert 478 revert 477 bedolaga/add api for bot integration vlrm04
2025-09-26 05:18:55 +03:00
Egor e71b7f4533 Revert "Revert "Add administrative web API and database support"" 2025-09-26 05:18:41 +03:00
Egor 5ba652fab6 Merge pull request #478 from Fr1ngg/revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert "Add administrative web API and database support"
2025-09-26 05:18:32 +03:00
Egor 0a3913a2aa Revert "Add administrative web API and database support" 2025-09-26 05:18:21 +03:00
Egor 118ef8a29f Merge pull request #477 from Fr1ngg/bedolaga/add-api-for-bot-integration-vlrm04
Add administrative web API and database support
2025-09-26 05:17:33 +03:00
Egor b5f7e06605 Add administrative web API and database support 2025-09-26 05:16:58 +03:00
Egor d258d27406 Merge pull request #476 from Fr1ngg/revert-475-revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert "Revert 474 revert 473 bedolaga/add api for bot integration"
2025-09-26 05:16:43 +03:00
Egor 554fb28cdb Revert "Revert 474 revert 473 bedolaga/add api for bot integration" 2025-09-26 05:16:36 +03:00
Egor 808901c38b Merge pull request #475 from Fr1ngg/revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert 474 revert 473 bedolaga/add api for bot integration
2025-09-26 05:11:38 +03:00
Egor 68508d8a86 Revert "Revert "Add web admin API for bot management"" 2025-09-26 05:11:13 +03:00
Egor 0daf8d7691 Merge pull request #474 from Fr1ngg/revert-473-bedolaga/add-api-for-bot-integration
Revert "Add web admin API for bot management"
2025-09-26 05:10:12 +03:00
Egor 5aeb2038c5 Revert "Add web admin API for bot management" 2025-09-26 05:09:59 +03:00
Egor d3a1060117 Merge pull request #473 from Fr1ngg/bedolaga/add-api-for-bot-integration
Add web admin API for bot management
2025-09-26 05:09:54 +03:00
Egor 4d22e99076 feat: add web admin api server 2025-09-26 05:09:26 +03:00
Legacyyy777 23f53dc5df Merge branch 'Fr1ngg:main' into DEV 2025-09-25 19:15:06 +05:00
Legacyyy777 b4d857eb88 Merge branch 'Fr1ngg:main' into DEV 2025-09-23 21:40:33 +05:00
Legacyyy777 51ba5b980c Merge branch 'Fr1ngg:main' into DEV 2025-09-21 10:57:47 +05:00
Legacyyy777 9fb6d7ecf6 Merge branch 'Fr1ngg:main' into DEV 2025-09-20 18:13:25 +05:00
Legacyyy777 d297e9b6d1 Add deploy workflow 2025-09-19 22:08:52 +05:00
330 changed files with 122183 additions and 15477 deletions
+123 -9
View File
@@ -67,6 +67,14 @@ REMNAWAVE_SECRET_KEY=
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Шаблон имени пользователя в панели Remnawave
# Доступные плейсхолдеры аналогичны описанию выше
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
@@ -77,7 +85,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
@@ -89,8 +98,13 @@ MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
# Плюсовать дни триала при покупке платной подписке
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -108,12 +122,24 @@ FIXED_TRAFFIC_LIMIT_GB=100
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
# Лимит трафика в ГБ (0 — безлимит)
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
# ===== ЦЕНЫ (в копейках) =====
BASE_SUBSCRIPTION_PRICE=0
# Цены за периоды
PRICE_14_DAYS=7000
PRICE_30_DAYS=9900
PRICE_30_DAYS=1000
PRICE_60_DAYS=25900
PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
@@ -125,12 +151,17 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=5000
PRICE_PER_DEVICE=10000
# Включить выбор количества устройств при покупке и продлении
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
@@ -142,20 +173,24 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
TELEGRAM_STARS_ENABLED=false
TELEGRAM_STARS_RATE_RUB=1.79
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -206,8 +241,8 @@ YOOKASSA_PAYMENT_SUBJECT=service
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -216,8 +251,18 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
@@ -232,11 +277,29 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Эти порты используются только при ручном запуске старого aiohttp сервера.
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# HELEKET
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_BASE_URL=https://api.heleket.com/v1
HELEKET_DEFAULT_CURRENCY=USDT
HELEKET_DEFAULT_NETWORK=
HELEKET_INVOICE_LIFETIME=3600
HELEKET_MARKUP_PERCENT=0
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_HOST=0.0.0.0
HELEKET_WEBHOOK_PORT=8086
HELEKET_CALLBACK_URL=
HELEKET_RETURN_URL=
HELEKET_SUCCESS_URL=
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
@@ -265,6 +328,26 @@ PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# Отображать кнопку СБП в PayPalych (true - отображать, false - скрывать)
PAL24_SBP_BUTTON_VISIBLE=true
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
PAL24_CARD_BUTTON_VISIBLE=true
# PLATEGA
PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== ИНТЕРФЕЙС И UX =====
@@ -272,6 +355,9 @@ PAL24_REQUEST_TIMEOUT=30
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
MAIN_MENU_MODE=default
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
@@ -285,6 +371,11 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
@@ -332,12 +423,18 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MONITORING_ENABLED=true
MAINTENANCE_RETRY_ATTEMPTS=1
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
@@ -375,3 +472,20 @@ LOG_FILE=logs/bot.log
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
MINIAPP_STATIC_PATH=miniapp
+42
View File
@@ -0,0 +1,42 @@
---
name: Bug Bedolage
about: Создать баг-репорт «Бедолаге»
title: "[BUG] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем текущую проблему.
Указываем настройки из `.env` конфига (если таковые имеются):
```yml
```
## Шаги для воспроизведения
1.
2.
3.
### Фактический результат
*
*
*
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Ожидаемое поведение
Опишите, как должно работать.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+32
View File
@@ -0,0 +1,32 @@
---
name: Feat Bedolage
about: Создать запрос на фичу «Бедолаге»
title: "[FEAT] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем что хотим.
Указываем настройки из `.env` конфига (если таковые к месту):
```yml
```
## Текущее поведение
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Желаемое поведение
Опишите, как хотелось бы, чтобы было.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+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.4.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.4.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.4.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.4.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.4.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.4.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
+8 -2
View File
@@ -1,19 +1,25 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!install_bot.sh
!Dockerfile
!app-config.json
!main.py
!requirements.txt
!docs/
!docs/**
!migrations/
!migrations/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!locales/
!locales/**
!tests/
!tests/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
+3 -3
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.4.0"
ARG VERSION="v2.8.0"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8081 8082 8083
EXPOSE 8080
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["python", "main.py"]
+39
View File
@@ -0,0 +1,39 @@
.PHONY: up
up: ## Поднять контейнеры (detached)
@echo "🚀 Поднимаем контейнеры (detached)..."
docker compose up -d --build
.PHONY: up-follow
up-follow: ## Поднять контейнеры с логами
@echo "📡 Поднимаем контейнеры (в консоли)..."
docker compose up --build
.PHONY: down
down: ## Остановить и удалить контейнеры
@echo "🛑 Останавливаем и удаляем контейнеры..."
docker compose down
.PHONY: reload
reload: ## Перезапустить контейнеры (detached)
@$(MAKE) down
@$(MAKE) up
.PHONY: reload-follow
reload-follow: ## Перезапустить контейнеры с логами
@$(MAKE) down
@$(MAKE) up-follow
.PHONY: test
test: ## Запустить тесты
@echo "🧪 Запускаем тесты..."
pytest -v
.PHONY: help
help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
sed -E 's/:.*?## /| /' | \
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@echo ""
+1412 -1087
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
```
### 📊 Мониторинг безопасности
@@ -239,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
ADMIN_IDS=123456789,987654321
# Webhook секреты - генерируйте случайные значения
YOOKASSA_WEBHOOK_SECRET=random_secret_here
TRIBUTE_WEBHOOK_SECRET=another_random_secret
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
```
@@ -283,7 +281,7 @@ services:
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8081:8081" # Только localhost
- "127.0.0.1:8080:8080" # Только localhost
# Read-only root filesystem
read_only: true
+636 -141
View File
@@ -1,163 +1,658 @@
{
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"ru": "App Store [EU]"
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "在 App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "在 App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "在 Google Play 中打开"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
},
{
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"ru": "App Store [RU]"
}
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"ru": "Скачать APK"
}
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Откройте приложение и подключитесь к серверу"
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
}
],
"pc": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
"buttonText": {
"en": "Mac Os",
"ru": "Mac Os"
],
"windows": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Windows",
"ru": "Windows"
}
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
}
],
"tv": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
],
"linux": [],
"androidTV": [
{
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
]
],
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
+49 -9
View File
@@ -10,14 +10,25 @@ from app.middlewares.auth import AuthMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
start,
menu,
subscription,
balance,
promocode,
referral,
support,
server_status,
common,
tickets,
)
from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
users as admin_users,
@@ -29,17 +40,26 @@ from app.handlers.admin import (
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
polls as admin_polls,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
backup as admin_backup,
system_logs as admin_system_logs,
welcome_text as admin_welcome_text,
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
payments as admin_payments,
trials as admin_trials,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -96,6 +116,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
display_name_middleware = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_middleware)
dp.callback_query.middleware(display_name_middleware)
dp.pre_checkout_query.middleware(display_name_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
@@ -116,7 +140,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
balance.register_handlers(dp)
balance.register_balance_handlers(dp)
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
@@ -133,25 +157,41 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
admin_backup.register_handlers(dp)
admin_system_logs.register_handlers(dp)
admin_welcome_text.register_welcome_text_handlers(dp)
admin_tickets.register_handlers(dp)
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
admin_pricing.register_handlers(dp)
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
if settings.is_maintenance_monitoring_enabled():
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
else:
logger.info("Мониторинг техработ отключен настройками")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
+740 -44
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
"""Database package exports."""
from .database import (
DatabaseManager,
batch_ops,
close_db,
db_manager,
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
)
__all__ = [
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
]
+6 -2
View File
@@ -32,6 +32,7 @@ async def create_campaign(
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
is_active: bool = True,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
@@ -43,7 +44,7 @@ async def create_campaign(
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
created_by=created_by,
is_active=True,
is_active=is_active,
)
db.add(campaign)
@@ -135,7 +136,10 @@ async def update_campaign(
"is_active",
}
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
update_data = {}
for key, value in kwargs.items():
if key in allowed_fields and value is not None:
update_data[key] = value
if not update_data:
return campaign
+189 -4
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Optional
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
async def upsert_discount_offer(
db: AsyncSession,
@@ -16,6 +23,8 @@ async def upsert_discount_offer(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
@@ -37,6 +46,8 @@ async def upsert_discount_offer(
offer.bonus_amount_kopeks = bonus_amount_kopeks
offer.expires_at = expires_at
offer.subscription_id = subscription_id
offer.effect_type = effect_type
offer.extra_data = extra_data
else:
offer = DiscountOffer(
user_id=user_id,
@@ -46,6 +57,8 @@ async def upsert_discount_offer(
bonus_amount_kopeks=bonus_amount_kopeks,
expires_at=expires_at,
is_active=True,
effect_type=effect_type,
extra_data=extra_data,
)
db.add(offer)
@@ -56,16 +69,128 @@ async def upsert_discount_offer(
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
result = await db.execute(
select(DiscountOffer).where(DiscountOffer.id == offer_id)
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(DiscountOffer.id == offer_id)
)
return result.scalar_one_or_none()
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
async def list_discount_offers(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 50,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> List[DiscountOffer]:
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.order_by(DiscountOffer.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def list_active_discount_offers_for_user(
db: AsyncSession,
user_id: int,
) -> List[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.utcnow()
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.expires_at > now,
)
.order_by(DiscountOffer.expires_at.asc())
)
result = await db.execute(stmt)
return result.scalars().all()
async def count_discount_offers(
db: AsyncSession,
*,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> int:
stmt = select(func.count(DiscountOffer.id))
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return int(result.scalar() or 0)
async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: Optional[dict] = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
await db.commit()
await db.refresh(offer)
try:
await log_promo_offer_action(
db,
user_id=offer.user_id,
offer_id=offer.id,
action="claimed",
source=offer.notification_type,
percent=offer.discount_percent,
effect_type=offer.effect_type,
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer claim log for offer %s: %s",
offer.id,
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer claim log failure: %s",
rollback_error,
)
return offer
@@ -82,9 +207,69 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
return 0
count = 0
log_payloads = []
for offer in offers:
offer.is_active = False
count += 1
log_payloads.append(
{
"user_id": offer.user_id,
"offer_id": offer.id,
"source": offer.notification_type,
"percent": offer.discount_percent,
"effect_type": offer.effect_type,
}
)
await db.commit()
for payload in log_payloads:
if not payload.get("user_id"):
continue
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload["offer_id"],
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer disable log for offer %s: %s",
payload.get("offer_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer disable log failure: %s",
rollback_error,
)
return count
async def get_latest_claimed_offer_for_user(
db: AsyncSession,
user_id: int,
source: Optional[str] = None,
) -> Optional[DiscountOffer]:
stmt = (
select(DiscountOffer)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.claimed_at.isnot(None),
)
.order_by(DiscountOffer.claimed_at.desc())
)
if source:
stmt = stmt.where(DiscountOffer.notification_type == source)
result = await db.execute(stmt)
return result.scalars().first()
+150
View File
@@ -0,0 +1,150 @@
import logging
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
result = await db.execute(
select(FaqSetting).where(FaqSetting.language == language)
)
return result.scalar_one_or_none()
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
setting = await get_faq_setting(db, language)
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.utcnow()
else:
setting = FaqSetting(
language=language,
is_enabled=bool(enabled),
)
db.add(setting)
await db.commit()
await db.refresh(setting)
logger.info(
"✅ Статус FAQ для языка %s обновлен: %s",
language,
"enabled" if setting.is_enabled else "disabled",
)
return setting
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
return await set_faq_enabled(db, language, enabled)
async def get_faq_pages(
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
) -> list[FaqPage]:
query = select(FaqPage).where(FaqPage.language == language)
if not include_inactive:
query = query.where(FaqPage.is_active.is_(True))
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
result = await db.execute(query)
pages = list(result.scalars().all())
return pages
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
return result.scalar_one_or_none()
async def create_faq_page(
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: bool = True,
) -> FaqPage:
if display_order is None:
result = await db.execute(
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
)
max_order = result.scalar() or 0
display_order = max_order + 1
page = FaqPage(
language=language,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
db.add(page)
await db.commit()
await db.refresh(page)
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
return page
async def update_faq_page(
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
if title is not None:
page.title = title
if content is not None:
page.content = content
if display_order is not None:
page.display_order = display_order
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(page)
logger.info("✅ Страница FAQ %s обновлена", page.id)
return page
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info("🗑️ Страница FAQ %s удалена", page_id)
async def bulk_update_order(
db: AsyncSession,
pages: Iterable[tuple[int, int]],
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage)
.where(FaqPage.id == page_id)
.values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
+176
View File
@@ -0,0 +1,176 @@
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
async def create_heleket_payment(
db: AsyncSession,
*,
user_id: int,
uuid: str,
order_id: str,
amount: str,
currency: str,
status: str,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> HeleketPayment:
payment = HeleketPayment(
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate,
discount_percent=discount_percent,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=metadata or {},
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
uuid,
order_id,
amount,
currency,
user_id,
)
return payment
async def get_heleket_payment_by_uuid(
db: AsyncSession,
uuid: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_heleket_payment(
db: AsyncSession,
uuid: str,
*,
status: Optional[str] = None,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
paid_at: Optional[datetime] = None,
payment_url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Heleket платеж с uuid=%s не найден", uuid)
return None
if status is not None:
payment.status = status
if payer_amount is not None:
payment.payer_amount = payer_amount
if payer_currency is not None:
payment.payer_currency = payer_currency
if exchange_rate is not None:
payment.exchange_rate = exchange_rate
if discount_percent is not None:
payment.discount_percent = discount_percent
if payment_url is not None:
payment.payment_url = payment_url
if metadata is not None:
existing = dict(payment.metadata_json or {})
existing.update(metadata)
payment.metadata_json = existing
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
)
return payment
async def link_heleket_payment_to_transaction(
db: AsyncSession,
uuid: str,
transaction_id: int,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Heleket платеж %s связан с транзакцией %s",
uuid,
transaction_id,
)
return payment
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
MainMenuButton,
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
async def count_main_menu_buttons(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(MainMenuButton))
return int(result.scalar() or 0)
async def get_main_menu_buttons(
db: AsyncSession,
*,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[MainMenuButton]:
stmt = select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
MainMenuButton.id.asc(),
)
if offset is not None:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_main_menu_button_by_id(
db: AsyncSession, button_id: int
) -> MainMenuButton | None:
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id == button_id)
)
return result.scalar_one_or_none()
async def get_next_display_order(db: AsyncSession) -> int:
result = await db.execute(select(func.max(MainMenuButton.display_order)))
current_max = result.scalar()
return (int(current_max) if current_max is not None else 0) + 1
def _enum_value(value, enum_cls):
if value is None:
return None
if isinstance(value, enum_cls):
return value.value
return str(value)
async def create_main_menu_button(
db: AsyncSession,
*,
text: str,
action_type: MainMenuButtonActionType | str,
action_value: str,
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
is_active: bool = True,
display_order: Optional[int] = None,
) -> MainMenuButton:
if display_order is None:
display_order = await get_next_display_order(db)
button = MainMenuButton(
text=text,
action_type=_enum_value(action_type, MainMenuButtonActionType),
action_value=action_value,
visibility=_enum_value(visibility, MainMenuButtonVisibility)
or MainMenuButtonVisibility.ALL.value,
is_active=bool(is_active),
display_order=int(display_order),
)
db.add(button)
await db.commit()
await db.refresh(button)
return button
async def update_main_menu_button(
db: AsyncSession,
button: MainMenuButton,
*,
text: Optional[str] = None,
action_type: MainMenuButtonActionType | str | None = None,
action_value: Optional[str] = None,
visibility: MainMenuButtonVisibility | str | None = None,
is_active: Optional[bool] = None,
display_order: Optional[int] = None,
) -> MainMenuButton:
if text is not None:
button.text = text
if action_type is not None:
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
if action_value is not None:
button.action_value = action_value
if visibility is not None:
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
if is_active is not None:
button.is_active = bool(is_active)
if display_order is not None:
button.display_order = int(display_order)
await db.commit()
await db.refresh(button)
return button
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
await db.delete(button)
await db.commit()
async def reorder_main_menu_buttons(
db: AsyncSession,
ordered_ids: Sequence[int],
) -> None:
if not ordered_ids:
return
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
)
buttons = result.scalars().all()
for button in buttons:
desired_order = order_map.get(button.id)
if desired_order is not None:
button.display_order = desired_order
await db.commit()
+20 -1
View File
@@ -3,8 +3,10 @@ from datetime import datetime
from typing import Optional
from sqlalchemy import select
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
@@ -40,7 +42,8 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
amount_kopeks,
@@ -88,6 +91,7 @@ async def update_mulenpay_payment_status(
paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None,
metadata: Optional[dict] = None,
) -> MulenPayPayment:
payment.status = status
if is_paid is not None:
@@ -98,6 +102,8 @@ async def update_mulenpay_payment_status(
payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
@@ -105,6 +111,19 @@ async def update_mulenpay_payment_status(
return payment
async def update_mulenpay_payment_metadata(
db: AsyncSession,
*,
payment: MulenPayPayment,
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_mulenpay_payment_to_transaction(
db: AsyncSession,
*,
+14
View File
@@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
)
)
await db.commit()
async def clear_notification_by_type(
db: AsyncSession,
subscription_id: int,
notification_type: str,
) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
)
)
await db.commit()
+7
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
@@ -87,6 +88,7 @@ async def update_pal24_payment_status(
status: str,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
@@ -94,6 +96,7 @@ async def update_pal24_payment_status(
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
update_values: Dict[str, Any] = {
"status": status,
@@ -103,6 +106,8 @@ async def update_pal24_payment_status(
update_values["is_active"] = is_active
if is_paid is not None:
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
if payment_id is not None:
update_values["payment_id"] = payment_id
if payment_status is not None:
@@ -117,6 +122,8 @@ async def update_pal24_payment_status(
update_values["payer_account"] = payer_account
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
if metadata is not None:
update_values["metadata_json"] = metadata
update_values["last_status"] = status
+156
View File
@@ -0,0 +1,156 @@
"""CRUD-операции для платежей Platega."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
async def create_platega_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
payment_method_code: int,
correlation_id: str,
platega_transaction_id: Optional[str],
redirect_url: Optional[str],
return_url: Optional[str],
failed_url: Optional[str],
payload: Optional[str],
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
payment = PlategaPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=platega_transaction_id,
redirect_url=redirect_url,
return_url=return_url,
failed_url=failed_url,
payload=payload,
metadata_json=metadata or {},
expires_at=expires_at,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
)
return payment
async def get_platega_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(PlategaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_id_for_update(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_platega_payment_by_transaction_id(
db: AsyncSession, transaction_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.platega_transaction_id == transaction_id
)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_correlation_id(
db: AsyncSession, correlation_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.correlation_id == correlation_id
)
)
return result.scalar_one_or_none()
async def update_platega_payment(
db: AsyncSession,
*,
payment: PlategaPayment,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
platega_transaction_id: Optional[str] = None,
redirect_url: Optional[str] = None,
callback_payload: Optional[dict[str, Any]] = None,
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
if status is not None:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at is not None:
payment.paid_at = paid_at
if platega_transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = platega_transaction_id
if redirect_url is not None:
payment.redirect_url = redirect_url
if callback_payload is not None:
payment.callback_payload = callback_payload
if metadata is not None:
payment.metadata_json = metadata
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_platega_payment_to_transaction(
db: AsyncSession,
*,
payment: PlategaPayment,
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+297
View File
@@ -0,0 +1,297 @@
import logging
from typing import Iterable, Sequence
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Poll,
PollAnswer,
PollOption,
PollQuestion,
PollResponse,
)
logger = logging.getLogger(__name__)
async def create_poll(
db: AsyncSession,
*,
title: str,
description: str | None,
reward_enabled: bool,
reward_amount_kopeks: int,
created_by: int | None,
questions: Sequence[dict[str, Iterable[str]]],
) -> Poll:
poll = Poll(
title=title,
description=description,
reward_enabled=reward_enabled,
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
created_by=created_by,
)
db.add(poll)
await db.flush()
for order, question_data in enumerate(questions, start=1):
question_text = question_data.get("text", "").strip()
if not question_text:
continue
question = PollQuestion(
poll_id=poll.id,
text=question_text,
order=order,
)
db.add(question)
await db.flush()
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
option_text = option_text.strip()
if not option_text:
continue
option = PollOption(
question_id=question.id,
text=option_text,
order=option_order,
)
db.add(option)
await db.commit()
await db.refresh(
poll,
attribute_names=["questions"],
)
return poll
async def list_polls(db: AsyncSession) -> list[Poll]:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
)
.order_by(Poll.created_at.desc())
)
return result.scalars().all()
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
selectinload(Poll.responses),
)
.where(Poll.id == poll_id)
)
return result.scalar_one_or_none()
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
poll = await db.get(Poll, poll_id)
if not poll:
return False
await db.delete(poll)
await db.commit()
logger.info("🗑️ Удалён опрос %s", poll_id)
return True
async def create_poll_response(
db: AsyncSession,
poll_id: int,
user_id: int,
) -> PollResponse:
result = await db.execute(
select(PollResponse)
.where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id == user_id,
)
)
)
response = result.scalar_one_or_none()
if response:
return response
response = PollResponse(
poll_id=poll_id,
user_id=user_id,
)
db.add(response)
await db.commit()
await db.refresh(response)
return response
async def get_poll_response_by_id(
db: AsyncSession,
response_id: int,
) -> PollResponse | None:
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.poll)
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
selectinload(PollResponse.answers),
selectinload(PollResponse.user),
)
.where(PollResponse.id == response_id)
)
return result.scalar_one_or_none()
async def record_poll_answer(
db: AsyncSession,
*,
response_id: int,
question_id: int,
option_id: int,
) -> PollAnswer:
result = await db.execute(
select(PollAnswer)
.where(
and_(
PollAnswer.response_id == response_id,
PollAnswer.question_id == question_id,
)
)
)
answer = result.scalar_one_or_none()
if answer:
answer.option_id = option_id
await db.commit()
await db.refresh(answer)
return answer
answer = PollAnswer(
response_id=response_id,
question_id=question_id,
option_id=option_id,
)
db.add(answer)
await db.commit()
await db.refresh(answer)
return answer
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
await db.execute(
delete(PollAnswer).where(PollAnswer.response_id == response_id)
)
await db.commit()
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
totals_result = await db.execute(
select(
func.count(PollResponse.id),
func.count(PollResponse.completed_at),
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
).where(PollResponse.poll_id == poll_id)
)
total_responses, completed_responses, reward_sum = totals_result.one()
option_counts_result = await db.execute(
select(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
func.count(PollAnswer.id),
)
.join(PollOption, PollOption.question_id == PollQuestion.id)
.outerjoin(
PollAnswer,
and_(
PollAnswer.question_id == PollQuestion.id,
PollAnswer.option_id == PollOption.id,
),
)
.where(PollQuestion.poll_id == poll_id)
.group_by(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
)
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
)
questions_map: dict[int, dict] = {}
for (
question_id,
question_text,
question_order,
option_id,
option_text,
option_order,
answer_count,
) in option_counts_result:
question_entry = questions_map.setdefault(
question_id,
{
"id": question_id,
"text": question_text,
"order": question_order,
"options": [],
},
)
question_entry["options"].append(
{
"id": option_id,
"text": option_text,
"count": answer_count,
}
)
questions = sorted(questions_map.values(), key=lambda item: item["order"])
return {
"total_responses": total_responses,
"completed_responses": completed_responses,
"reward_sum_kopeks": reward_sum,
"questions": questions,
}
async def get_poll_responses_with_answers(
db: AsyncSession,
poll_id: int,
*,
limit: int,
offset: int,
) -> tuple[list[PollResponse], int]:
total_result = await db.execute(
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
)
total = int(total_result.scalar_one() or 0)
if total == 0:
return [], 0
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.user),
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
)
.where(PollResponse.poll_id == poll_id)
.order_by(PollResponse.sent_at.asc())
.offset(offset)
.limit(limit)
)
responses = result.scalars().unique().all()
return responses, total
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
result = await db.execute(
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
)
return result.scalar_one_or_none()
async def upsert_privacy_policy(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.content = content or ""
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
language,
policy.id,
)
return policy
async def set_privacy_policy_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.is_enabled = bool(enabled)
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
language,
"enabled" if policy.is_enabled else "disabled",
)
return policy
+122 -6
View File
@@ -1,11 +1,11 @@
import logging
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Set, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
from app.database.models import PromoGroup, User, UserPromoGroup
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
@@ -30,20 +30,54 @@ logger = logging.getLogger(__name__)
async def get_promo_groups_with_counts(
db: AsyncSession,
*,
offset: int = 0,
limit: Optional[int] = None,
) -> List[Tuple[PromoGroup, int]]:
result = await db.execute(
query = (
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
)
if offset:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
result = await db.execute(query)
return result.all()
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
result = await db.execute(
select(PromoGroup)
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
.order_by(PromoGroup.auto_assign_total_spent_kopeks, PromoGroup.id)
)
return result.scalars().all()
async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
result = await db.execute(
select(func.count(PromoGroup.id))
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
)
return bool(result.scalar_one())
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
return await db.get(PromoGroup, group_id)
async def count_promo_groups(db: AsyncSession) -> int:
result = await db.execute(select(func.count(PromoGroup.id)))
return int(result.scalar_one())
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
result = await db.execute(
select(PromoGroup).where(PromoGroup.is_default.is_(True))
@@ -55,12 +89,14 @@ async def create_promo_group(
db: AsyncSession,
name: str,
*,
priority: int = 0,
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: bool = True,
is_default: bool = False,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
@@ -70,24 +106,38 @@ async def create_promo_group(
else None
)
existing_default = await get_default_promo_group(db)
should_be_default = existing_default is None or is_default
promo_group = PromoGroup(
name=name.strip(),
priority=max(0, priority),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
period_discounts=normalized_period_discounts or None,
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
apply_discounts_to_addons=bool(apply_discounts_to_addons),
is_default=False,
is_default=should_be_default,
)
db.add(promo_group)
await db.flush()
if should_be_default and existing_default and existing_default.id != promo_group.id:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != promo_group.id)
.values(is_default=False)
)
await db.commit()
await db.refresh(promo_group)
logger.info(
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
promo_group.name,
promo_group.is_default,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
@@ -104,15 +154,19 @@ async def update_promo_group(
group: PromoGroup,
*,
name: Optional[str] = None,
priority: Optional[int] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: Optional[bool] = None,
is_default: Optional[bool] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
if priority is not None:
group.priority = max(0, priority)
if server_discount_percent is not None:
group.server_discount_percent = max(0, min(100, server_discount_percent))
if traffic_discount_percent is not None:
@@ -127,6 +181,36 @@ async def update_promo_group(
if apply_discounts_to_addons is not None:
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
if is_default is not None:
if is_default:
group.is_default = True
await db.flush()
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != group.id)
.values(is_default=False)
)
else:
if group.is_default:
group.is_default = False
await db.flush()
replacement = await db.execute(
select(PromoGroup)
.where(PromoGroup.id != group.id)
.order_by(PromoGroup.id)
.limit(1)
)
new_default = replacement.scalars().first()
if new_default:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id == new_default.id)
.values(is_default=True)
)
else:
# Не допускаем состояния без базовой промогруппы
group.is_default = True
await db.commit()
await db.refresh(group)
@@ -148,11 +232,43 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
logger.error("Не найдена базовая промогруппа для reassignment")
return False
# Получаем список пользователей, связанных с удаляемой промогруппой
affected_user_ids: Set[int] = set()
user_ids_result = await db.execute(
select(User.id).where(User.promo_group_id == group.id)
)
affected_user_ids.update(user_ids_result.scalars().all())
promo_group_links_result = await db.execute(
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
)
affected_user_ids.update(promo_group_links_result.scalars().all())
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
if affected_user_ids:
existing_defaults_result = await db.execute(
select(UserPromoGroup.user_id)
.where(UserPromoGroup.promo_group_id == default_group.id)
.where(UserPromoGroup.user_id.in_(affected_user_ids))
)
existing_default_user_ids = set(existing_defaults_result.scalars().all())
for user_id in affected_user_ids - existing_default_user_ids:
db.add(
UserPromoGroup(
user_id=user_id,
promo_group_id=default_group.id,
assigned_by="system",
)
)
await db.delete(group)
await db.commit()
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
async def log_promo_offer_action(
db: AsyncSession,
*,
user_id: Optional[int],
offer_id: Optional[int],
action: str,
source: Optional[str] = None,
percent: Optional[int] = None,
effect_type: Optional[str] = None,
details: Optional[Dict[str, object]] = None,
commit: bool = True,
) -> PromoOfferLog:
"""Persist a promo offer log entry."""
entry = PromoOfferLog(
user_id=user_id,
offer_id=offer_id,
action=action,
source=source,
percent=percent,
effect_type=effect_type,
details=(details or {}).copy(),
)
db.add(entry)
if commit:
try:
await db.commit()
await db.refresh(entry)
except Exception:
logger.exception("Failed to commit promo offer log entry")
raise
return entry
async def list_promo_offer_logs(
db: AsyncSession,
offset: int = 0,
limit: int = 20,
*,
user_id: Optional[int] = None,
offer_id: Optional[int] = None,
action: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[PromoOfferLog], int]:
stmt = (
select(PromoOfferLog)
.options(
selectinload(PromoOfferLog.user),
selectinload(PromoOfferLog.offer),
)
.order_by(PromoOfferLog.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
stmt = stmt.where(PromoOfferLog.action == action)
if source:
stmt = stmt.where(PromoOfferLog.source == source)
result = await db.execute(stmt)
logs = result.scalars().all()
count_stmt = select(func.count(PromoOfferLog.id))
if user_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
count_stmt = count_stmt.where(PromoOfferLog.action == action)
if source:
count_stmt = count_stmt.where(PromoOfferLog.source == source)
total = (await db.execute(count_stmt)).scalar() or 0
return logs, total
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PromoOfferTemplate
UPDATED_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 Экономия {discount_percent}% при продлении\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
LEGACY_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
DEFAULT_TEMPLATES: tuple[dict, ...] = (
{
"offer_type": "test_access",
"name": "Тестовые сервера",
"message_text": (
"🔥 <b>Испытайте новые сервера</b>\n\n"
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
"Предложение действительно {valid_hours} ч."
),
"button_text": "🚀 Попробовать серверы",
"valid_hours": 24,
"discount_percent": 0,
"bonus_amount_kopeks": 0,
"active_discount_hours": None,
"test_duration_hours": 24,
"test_squad_uuids": [],
},
{
"offer_type": "extend_discount",
"name": "Скидка на продление",
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
"button_text": "🎁 Получить скидку",
"valid_hours": 24,
"discount_percent": 20,
"bonus_amount_kopeks": 0,
"active_discount_hours": 24,
"test_duration_hours": None,
"test_squad_uuids": [],
},
{
"offer_type": "purchase_discount",
"name": "Скидка на покупку",
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
"button_text": "🎁 Забрать скидку",
"valid_hours": 48,
"discount_percent": 25,
"bonus_amount_kopeks": 0,
"active_discount_hours": 48,
"test_duration_hours": None,
"test_squad_uuids": [],
},
)
def _format_template_fields(payload: dict) -> dict:
data = dict(payload)
data.setdefault("valid_hours", 24)
data.setdefault("discount_percent", 0)
data.setdefault("bonus_amount_kopeks", 0)
data.setdefault("active_discount_hours", None)
data.setdefault("test_duration_hours", None)
data.setdefault("test_squad_uuids", [])
return data
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
templates: List[PromoOfferTemplate] = []
for template_data in DEFAULT_TEMPLATES:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
)
existing = result.scalars().first()
if existing:
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
should_update = False
if new_message and legacy_message and existing.message_text == legacy_message:
should_update = True
elif new_message and (
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
):
should_update = True
if should_update and new_message:
existing.message_text = new_message
existing.updated_at = datetime.utcnow()
await db.flush()
target_active_hours = template_data.get("active_discount_hours")
if (
target_active_hours is not None
and target_active_hours > 0
and not existing.active_discount_hours
):
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.utcnow()
await db.flush()
templates.append(existing)
continue
payload = _format_template_fields(template_data)
template = PromoOfferTemplate(
name=payload["name"],
offer_type=payload["offer_type"],
message_text=payload["message_text"],
button_text=payload["button_text"],
valid_hours=payload["valid_hours"],
discount_percent=payload["discount_percent"],
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
active_discount_hours=payload["active_discount_hours"],
test_duration_hours=payload["test_duration_hours"],
test_squad_uuids=payload["test_squad_uuids"],
is_active=True,
created_by=created_by,
)
db.add(template)
await db.flush()
templates.append(template)
await db.commit()
return templates
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
)
return result.scalars().all()
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
)
return result.scalar_one_or_none()
async def update_promo_offer_template(
db: AsyncSession,
template: PromoOfferTemplate,
*,
name: Optional[str] = None,
message_text: Optional[str] = None,
button_text: Optional[str] = None,
valid_hours: Optional[int] = None,
discount_percent: Optional[int] = None,
bonus_amount_kopeks: Optional[int] = None,
active_discount_hours: Optional[int] = None,
test_duration_hours: Optional[int] = None,
test_squad_uuids: Optional[Iterable[str]] = None,
is_active: Optional[bool] = None,
) -> PromoOfferTemplate:
if name is not None:
template.name = name
if message_text is not None:
template.message_text = message_text
if button_text is not None:
template.button_text = button_text
if valid_hours is not None:
template.valid_hours = valid_hours
if discount_percent is not None:
template.discount_percent = discount_percent
if bonus_amount_kopeks is not None:
template.bonus_amount_kopeks = bonus_amount_kopeks
if active_discount_hours is not None:
template.active_discount_hours = active_discount_hours
if test_duration_hours is not None:
template.test_duration_hours = test_duration_hours
if test_squad_uuids is not None:
template.test_squad_uuids = list(test_squad_uuids)
if is_active is not None:
template.is_active = is_active
template.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(template)
return template
+58 -12
View File
@@ -13,12 +13,50 @@ logger = logging.getLogger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.uses))
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.code == code.upper())
)
return result.scalar_one_or_none()
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
"""
Получает промокод по ID с eager loading всех связанных данных.
Используется для избежания lazy loading в async контексте.
"""
result = await db.execute(
select(PromoCode)
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.id == promo_id)
)
return result.scalar_one_or_none()
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
"""
Проверяет существование и валидность промокода без активации.
Возвращает словарь с информацией о промокоде.
"""
promocode = await get_promocode_by_code(db, code)
if not promocode:
return {"valid": False, "error": "not_found", "promocode": None}
if not promocode.is_valid:
if promocode.current_uses >= promocode.max_uses:
return {"valid": False, "error": "used", "promocode": None}
else:
return {"valid": False, "error": "expired", "promocode": None}
return {"valid": True, "error": None, "promocode": promocode}
async def create_promocode(
db: AsyncSession,
code: str,
@@ -27,7 +65,8 @@ async def create_promocode(
subscription_days: int = 0,
max_uses: int = 1,
valid_until: Optional[datetime] = None,
created_by: Optional[int] = None
created_by: Optional[int] = None,
promo_group_id: Optional[int] = None
) -> PromoCode:
promocode = PromoCode(
@@ -37,14 +76,18 @@ async def create_promocode(
subscription_days=subscription_days,
max_uses=max_uses,
valid_until=valid_until,
created_by=created_by
created_by=created_by,
promo_group_id=promo_group_id
)
db.add(promocode)
await db.commit()
await db.refresh(promocode)
logger.info(f"✅ Создан промокод: {code}")
if promo_group_id:
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
else:
logger.info(f"✅ Создан промокод: {code}")
return promocode
@@ -53,9 +96,9 @@ async def use_promocode(
promocode_id: int,
user_id: int
) -> bool:
try:
promocode = await db.get(PromoCode, promocode_id)
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
return False
@@ -143,14 +186,17 @@ async def get_promocodes_list(
limit: int = 50,
is_active: Optional[bool] = None
) -> List[PromoCode]:
query = select(PromoCode).options(selectinload(PromoCode.uses))
query = select(PromoCode).options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
result = await db.execute(
select(PublicOffer).where(PublicOffer.language == language)
)
return result.scalar_one_or_none()
async def upsert_public_offer(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.content = content or ""
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
language,
offer.id,
)
return offer
async def set_public_offer_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.is_enabled = bool(enabled)
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Статус публичной оферты для языка %s обновлен: %s",
language,
"enabled" if offer.is_enabled else "disabled",
)
return offer
+285 -17
View File
@@ -1,11 +1,30 @@
import logging
import random
from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
from sqlalchemy import select, and_, func, update, delete, text
from sqlalchemy import (
select,
and_,
func,
update,
delete,
text,
or_,
cast,
String,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription
from app.database.models import (
PromoGroup,
ServerSquad,
SubscriptionServer,
Subscription,
SubscriptionStatus,
User,
)
logger = logging.getLogger(__name__)
@@ -27,6 +46,8 @@ async def create_server_squad(
description: str = None,
max_users: int = None,
is_available: bool = True,
is_trial_eligible: bool = False,
sort_order: int = 0,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
@@ -59,6 +80,8 @@ async def create_server_squad(
description=description,
max_users=max_users,
is_available=is_available,
is_trial_eligible=is_trial_eligible,
sort_order=sort_order,
allowed_promo_groups=promo_groups,
)
@@ -146,6 +169,58 @@ async def get_available_server_squads(
return result.scalars().unique().all()
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
"""Возвращает список активных серверов, доступных для подключения."""
squads = await get_available_server_squads(db)
if not squads:
return []
eligible: List[ServerSquad] = []
for squad in squads:
max_users = squad.max_users
current_users = squad.current_users or 0
if max_users is not None and current_users >= max_users:
continue
eligible.append(squad)
if eligible:
return eligible
return squads
async def choose_random_active_server_squad(
db: AsyncSession,
) -> Optional[ServerSquad]:
"""Возвращает случайный активный сервер."""
squads = await get_active_server_squads(db)
if not squads:
return None
return random.choice(squads)
async def get_random_active_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
"""Возвращает UUID случайного активного сервера или запасной UUID."""
squad = await choose_random_active_server_squad(db)
if squad:
return squad.squad_uuid
return fallback_uuid
async def update_server_squad_promo_groups(
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
) -> Optional[ServerSquad]:
@@ -187,8 +262,15 @@ async def update_server_squad(
) -> Optional[ServerSquad]:
valid_fields = {
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order'
"display_name",
"original_name",
"country_code",
"price_kopeks",
"description",
"max_users",
"is_available",
"sort_order",
"is_trial_eligible",
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
@@ -232,10 +314,10 @@ async def sync_with_remnawave(
db: AsyncSession,
remnawave_squads: List[dict]
) -> Tuple[int, int, int]:
created = 0
updated = 0
disabled = 0
removed = 0
existing_servers = {}
result = await db.execute(select(ServerSquad))
@@ -265,19 +347,186 @@ async def sync_with_remnawave(
)
created += 1
for uuid, server in existing_servers.items():
if uuid not in remnawave_uuids and server.is_available:
server.is_available = False
disabled += 1
removed_servers = [
server for uuid, server in existing_servers.items()
if uuid not in remnawave_uuids
]
if removed_servers:
removed_ids = [server.id for server in removed_servers]
removed_uuids = {server.squad_uuid for server in removed_servers}
subscription_ids_result = await db.execute(
select(SubscriptionServer.subscription_id)
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
for server in removed_servers:
logger.info(
"🗑️ Удаляется сервер %s (UUID: %s)",
server.display_name,
server.squad_uuid,
)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscriptions_to_update: dict[int, Subscription] = {}
if subscription_ids:
subscriptions_result = await db.execute(
select(Subscription).where(Subscription.id.in_(subscription_ids))
)
for subscription in subscriptions_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
for squad_uuid in removed_uuids:
if not squad_uuid:
continue
extra_result = await db.execute(
select(Subscription).where(
text("connected_squads::text LIKE :uuid_pattern")
),
{"uuid_pattern": f'%"{squad_uuid}"%'}
)
for subscription in extra_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
cleaned_subscriptions = 0
for subscription in subscriptions_to_update.values():
current_squads = list(subscription.connected_squads or [])
if not current_squads:
continue
filtered_squads = [
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
]
if len(filtered_squads) != len(current_squads):
subscription.connected_squads = filtered_squads
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
removed = len(removed_servers)
if cleaned_subscriptions:
logger.info(
"🧹 Обновлены подписки после удаления серверов: %s",
cleaned_subscriptions,
)
await db.commit()
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
return created, updated, disabled
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
return created, updated, removed
async def get_server_connected_users(
db: AsyncSession,
server_id: int
) -> List[User]:
server_uuid_result = await db.execute(
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
)
server_uuid = server_uuid_result.scalar_one_or_none()
connection_filters = [SubscriptionServer.id.isnot(None)]
if server_uuid:
connection_filters.append(
cast(Subscription.connected_squads, String).like(
f'%"{server_uuid}"%'
)
)
result = await db.execute(
select(User)
.join(Subscription, Subscription.user_id == User.id)
.outerjoin(
SubscriptionServer,
and_(
SubscriptionServer.subscription_id == Subscription.id,
SubscriptionServer.server_squad_id == server_id,
),
)
.where(or_(*connection_filters))
.options(selectinload(User.subscription))
.order_by(User.id)
)
return result.scalars().unique().all()
async def get_trial_eligible_server_squads(
db: AsyncSession,
include_unavailable: bool = False,
) -> List[ServerSquad]:
query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True))
result = await db.execute(query)
squads = result.scalars().unique().all()
if include_unavailable:
return squads
preferred_squads: List[ServerSquad] = []
fallback_squads: List[ServerSquad] = []
for squad in squads:
current_users = squad.current_users or 0
is_full = squad.max_users is not None and current_users >= squad.max_users
if is_full:
continue
if squad.is_available:
preferred_squads.append(squad)
else:
fallback_squads.append(squad)
if preferred_squads:
return preferred_squads
if fallback_squads:
return fallback_squads
return squads
async def choose_random_trial_server_squad(
db: AsyncSession,
) -> Optional[ServerSquad]:
squads = await get_trial_eligible_server_squads(db)
if not squads:
return None
return random.choice(squads)
async def get_random_trial_squad_uuid(
db: AsyncSession,
) -> Optional[str]:
squad = await choose_random_trial_server_squad(db)
if squad:
return squad.squad_uuid
return None
def _generate_display_name(original_name: str) -> str:
country_names = {
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
@@ -313,10 +562,10 @@ def _extract_country_code(original_name: str) -> Optional[str]:
async def get_server_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
available_result = await db.execute(
select(func.count(ServerSquad.id))
.where(ServerSquad.is_available == True)
@@ -355,6 +604,25 @@ async def get_server_statistics(db: AsyncSession) -> dict:
'total_revenue_rubles': total_revenue_kopeks / 100
}
async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int:
"""Возвращает количество активных подписок, подключенных к указанному скваду."""
result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
]
),
cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'),
)
)
return result.scalar() or 0
async def add_user_to_servers(
db: AsyncSession,
server_squad_ids: List[int]
+623 -36
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlalchemy import select, and_, func
from typing import Iterable, Optional, List, Tuple
from sqlalchemy import select, and_, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -11,10 +11,12 @@ from app.database.models import (
User,
SubscriptionServer,
PromoGroup,
UserPromoGroup,
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -30,6 +32,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
subscription = result.scalar_one_or_none()
if subscription:
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
@@ -40,17 +43,35 @@ async def create_trial_subscription(
user_id: int,
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: int = None,
device_limit: Optional[int] = None,
squad_uuid: str = None
) -> Subscription:
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
if not squad_uuid:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(db)
if squad_uuid:
logger.debug(
"Выбран сквад %s для триальной подписки пользователя %s",
squad_uuid,
user_id,
)
except Exception as error:
logger.error(
"Не удалось получить сквад для триальной подписки пользователя %s: %s",
user_id,
error,
)
end_date = datetime.utcnow() + timedelta(days=duration_days)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -59,14 +80,43 @@ async def create_trial_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else []
connected_squads=[squad_uuid] if squad_uuid else [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
if squad_uuid:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для триального сквада %s",
squad_uuid,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
squad_uuid,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
squad_uuid,
error,
)
return subscription
@@ -74,13 +124,17 @@ async def create_paid_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None
traffic_limit_gb: int = 0,
device_limit: Optional[int] = None,
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -89,14 +143,131 @@ async def create_paid_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or []
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
squad_uuids = list(connected_squads or [])
if update_server_counters and squad_uuids:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, squad_uuids)
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)",
user_id,
squad_uuids,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)",
user_id,
squad_uuids,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s",
user_id,
error,
)
return subscription
async def replace_subscription(
db: AsyncSession,
subscription: Subscription,
*,
duration_days: int,
traffic_limit_gb: int,
device_limit: int,
connected_squads: List[str],
is_trial: bool,
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
update_server_counters: bool = False,
) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя."""
current_time = datetime.utcnow()
old_squads = set(subscription.connected_squads or [])
new_squads = set(connected_squads or [])
new_autopay_enabled = (
subscription.autopay_enabled
if autopay_enabled is None
else autopay_enabled
)
new_autopay_days_before = (
subscription.autopay_days_before
if autopay_days_before is None
else autopay_days_before
)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.is_trial = is_trial
subscription.start_date = current_time
subscription.end_date = current_time + timedelta(days=duration_days)
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.autopay_enabled = new_autopay_enabled
subscription.autopay_days_before = new_autopay_days_before
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
if update_server_counters:
try:
from app.database.crud.server_squad import (
add_user_to_servers,
get_server_ids_by_uuids,
remove_user_from_servers,
)
squads_to_remove = old_squads - new_squads
squads_to_add = new_squads - old_squads
if squads_to_remove:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
if server_ids:
await remove_user_from_servers(db, sorted(server_ids))
if squads_to_add:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
if server_ids:
await add_user_to_servers(db, sorted(server_ids))
logger.info(
"♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s",
subscription.id,
len(squads_to_remove),
len(squads_to_add),
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s",
subscription.id,
error,
)
return subscription
@@ -106,23 +277,72 @@ async def extend_subscription(
days: int
) -> Subscription:
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
if subscription.end_date > current_time:
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
bonus_days = 0
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Вычисляем остаток триала
if subscription.end_date and subscription.end_date > current_time:
remaining = subscription.end_date - current_time
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
bonus_days,
subscription.id,
)
# Применяем продление с учетом бонусных дней
total_days = days + bonus_days
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
logger.info(
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
abs(days),
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=total_days)
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
if subscription.status == SubscriptionStatus.EXPIRED.value:
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
logger.info(
"🔄 Статус подписки %s изменён с %s на ACTIVE",
subscription.id,
previous_status,
)
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
logger.warning(
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
subscription.id,
days
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
@@ -203,6 +423,64 @@ async def remove_subscription_squad(
return subscription
async def decrement_subscription_server_counts(
db: AsyncSession,
subscription: Optional[Subscription],
*,
subscription_servers: Optional[Iterable[SubscriptionServer]] = None,
) -> None:
"""Decrease server counters linked to the provided subscription."""
if not subscription:
return
server_ids: set[int] = set()
if subscription_servers is not None:
for sub_server in subscription_servers:
if sub_server and sub_server.server_squad_id is not None:
server_ids.add(sub_server.server_squad_id)
else:
try:
ids_from_links = await get_subscription_server_ids(db, subscription.id)
server_ids.update(ids_from_links)
except Exception as error:
logger.error(
"⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s",
subscription.id,
error,
)
connected_squads = list(subscription.connected_squads or [])
if connected_squads:
try:
from app.database.crud.server_squad import get_server_ids_by_uuids
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
server_ids.update(squad_server_ids)
except Exception as error:
logger.error(
"⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s",
subscription.id,
error,
)
if not server_ids:
return
try:
from app.database.crud.server_squad import remove_user_from_servers
await remove_user_from_servers(db, sorted(server_ids))
except Exception as error:
logger.error(
"⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s",
list(server_ids),
subscription.id,
error,
)
async def update_subscription_autopay(
db: AsyncSession,
subscription: Subscription,
@@ -398,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
"purchased_today": purchased_today,
"purchased_week": purchased_week,
"purchased_month": purchased_month,
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
}
async def get_trial_statistics(db: AsyncSession) -> dict:
now = datetime.utcnow()
total_trials_result = await db.execute(
select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))
)
total_trials = total_trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.is_trial.is_(True),
Subscription.end_date > now,
Subscription.status.in_(
[SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]
),
)
)
active_trials = active_trials_result.scalar() or 0
resettable_trials_result = await db.execute(
select(func.count(Subscription.id))
.join(User, Subscription.user_id == User.id)
.where(
Subscription.is_trial.is_(True),
Subscription.end_date <= now,
User.has_had_paid_subscription.is_(False),
)
)
resettable_trials = resettable_trials_result.scalar() or 0
return {
"used_trials": total_trials,
"active_trials": active_trials,
"resettable_trials": resettable_trials,
}
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.subscription_servers),
)
.join(User, Subscription.user_id == User.id)
.where(
Subscription.is_trial.is_(True),
Subscription.end_date <= now,
User.has_had_paid_subscription.is_(False),
)
)
subscriptions = result.scalars().unique().all()
if not subscriptions:
return 0
reset_count = len(subscriptions)
for subscription in subscriptions:
try:
await decrement_subscription_server_counts(
db,
subscription,
subscription_servers=subscription.subscription_servers,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Не удалось обновить счётчики серверов при сбросе триала %s: %s",
subscription.id,
error,
)
subscription_ids = [subscription.id for subscription in subscriptions]
if subscription_ids:
try:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id.in_(subscription_ids)
)
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Ошибка удаления серверных связей триалов %s: %s",
subscription_ids,
error,
)
raise
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
try:
await db.commit()
except Exception as error: # pragma: no cover - defensive logging
await db.rollback()
logger.error("Ошибка сохранения сброса триалов: %s", error)
raise
logger.info("♻️ Сброшено триальных подписок: %s", reset_count)
return reset_count
async def update_subscription_usage(
db: AsyncSession,
subscription: Subscription,
@@ -607,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}"
@@ -617,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}"
@@ -626,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}"
@@ -635,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
@@ -724,7 +1105,7 @@ async def get_subscription_renewal_cost(
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
.where(Subscription.id == subscription_id)
)
@@ -941,6 +1322,14 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
logger.info(
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
subscription.id,
subscription.status,
format_local_datetime(subscription.end_date),
format_local_datetime(current_time),
)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
@@ -951,9 +1340,68 @@ async def check_and_update_subscription_status(
await db.refresh(subscription)
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
elif subscription.status == SubscriptionStatus.PENDING.value:
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
return subscription
async def create_subscription_no_commit(
db: AsyncSession,
user_id: int,
status: str = "trial",
is_trial: bool = True,
end_date: datetime = None,
traffic_limit_gb: int = 10,
traffic_used_gb: float = 0.0,
device_limit: int = 1,
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = "",
subscription_crypto_link: str = "",
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
) -> Subscription:
"""
Создает подписку без немедленного коммита для пакетной обработки
"""
if end_date is None:
end_date = datetime.utcnow() + timedelta(days=3)
if connected_squads is None:
connected_squads = []
subscription = Subscription(
user_id=user_id,
status=status,
is_trial=is_trial,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(
settings.is_autopay_enabled_by_default()
if autopay_enabled is None
else autopay_enabled
),
autopay_days_before=(
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
if autopay_days_before is None
else autopay_days_before
),
)
db.add(subscription)
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
return subscription
async def create_subscription(
db: AsyncSession,
user_id: int,
@@ -966,7 +1414,9 @@ async def create_subscription(
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = "",
subscription_crypto_link: str = ""
subscription_crypto_link: str = "",
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
) -> Subscription:
if end_date is None:
@@ -986,7 +1436,17 @@ async def create_subscription(
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(
settings.is_autopay_enabled_by_default()
if autopay_enabled is None
else autopay_enabled
),
autopay_days_before=(
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
if autopay_days_before is None
else autopay_days_before
),
)
db.add(subscription)
@@ -995,3 +1455,130 @@ async def create_subscription(
logger.info(f"✅ Создана подписка для пользователя {user_id}")
return subscription
async def create_pending_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
) -> Subscription:
"""Creates a pending subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = False
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=False,
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
async def activate_pending_subscription(
db: AsyncSession,
user_id: int,
period_days: int = None
) -> Optional[Subscription]:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
from sqlalchemy import and_
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
# Находим pending подписку пользователя
result = await db.execute(
select(Subscription)
.where(
and_(
Subscription.user_id == user_id,
Subscription.status == SubscriptionStatus.PENDING.value
)
)
)
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
return None
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Если указан период, обновляем дату окончания
if period_days is not None:
effective_start = pending_subscription.start_date or current_time
if effective_start < current_time:
effective_start = current_time
pending_subscription.end_date = effective_start + timedelta(days=period_days)
# Обновляем дату начала, если она не установлена или в прошлом
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
pending_subscription.start_date = current_time
await db.commit()
await db.refresh(pending_subscription)
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
return pending_subscription
+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)
+53 -5
View File
@@ -274,6 +274,30 @@ class TicketCRUD:
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
)
@staticmethod
async def close_all_open_tickets(
db: AsyncSession,
) -> List[int]:
"""Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов."""
open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]
result = await db.execute(
select(Ticket.id).where(Ticket.status.in_(open_statuses))
)
ticket_ids = result.scalars().all()
if not ticket_ids:
return []
now = datetime.utcnow()
await db.execute(
update(Ticket)
.where(Ticket.id.in_(ticket_ids))
.values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now)
)
await db.commit()
return ticket_ids
@staticmethod
async def add_support_audit(
db: AsyncSession,
@@ -309,18 +333,42 @@ class TicketCRUD:
*,
limit: int = 50,
offset: int = 0,
action: Optional[str] = None,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query.offset(offset).limit(limit))
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
query = select(func.count()).select_from(SupportAuditLog)
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
from sqlalchemy import select
result = await db.execute(
select(SupportAuditLog.action)
.where(SupportAuditLog.action.isnot(None))
.distinct()
.order_by(SupportAuditLog.action)
)
return [row[0] for row in result.fetchall()]
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:
+1 -6
View File
@@ -118,12 +118,7 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
)
+645 -71
View File
@@ -2,14 +2,28 @@ import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, and_, or_, func
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy.exc import IntegrityError
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
from app.database.models import (
User,
UserStatus,
Subscription,
SubscriptionStatus,
Transaction,
PromoGroup,
UserPromoGroup,
PaymentMethod,
TransactionType,
)
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -25,6 +39,8 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.id == user_id)
@@ -32,6 +48,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
@@ -42,25 +59,64 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
if not username:
return None
normalized = username.lower()
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(func.lower(User.username) == normalized)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.promo_group))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.referral_code == referral_code)
)
return result.scalar_one_or_none()
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def create_unique_referral_code(db: AsyncSession) -> str:
@@ -76,6 +132,88 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
return f"ref{timestamp}"
async def _sync_users_sequence(db: AsyncSession) -> None:
"""Ensure the users.id sequence matches the current max ID."""
await db.execute(
text(
"SELECT setval('users_id_seq', "
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
)
)
await db.commit()
logger.warning(
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
)
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
default_group = await get_default_promo_group(db)
if default_group:
return default_group
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
)
db.add(default_group)
await db.flush()
return default_group
async def create_user_no_commit(
db: AsyncSession,
telegram_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
) -> User:
"""
Создает пользователя без немедленного коммита для пакетной обработки
"""
if not referral_code:
referral_code = await create_unique_referral_code(db)
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
await db.flush()
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
user.promo_group = default_group
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
)
return user
async def create_user(
db: AsyncSession,
telegram_id: int,
@@ -90,44 +228,61 @@ async def create_user(
if not referral_code:
referral_code = await create_unique_referral_code(db)
default_group = await get_default_promo_group(db)
if not default_group:
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
attempts = 3
for attempt in range(1, attempts + 1):
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(default_group)
await db.flush()
promo_group_id = default_group.id
db.add(user)
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
if default_group:
user.promo_group = default_group
try:
await db.commit()
await db.refresh(user)
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
user.promo_group = default_group
logger.info(
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
)
return user
return user
except IntegrityError as exc:
await db.rollback()
if (
isinstance(getattr(exc, "orig", None), Exception)
and "users_pkey" in str(exc.orig)
and attempt < attempts
):
logger.warning(
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
"Выполняем повторную синхронизацию (попытка %s/%s)",
telegram_id,
attempt,
attempts,
)
await _sync_users_sequence(db)
continue
raise
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
async def update_user(
@@ -136,7 +291,10 @@ async def update_user(
**kwargs
) -> User:
from app.utils.validators import sanitize_telegram_name
for field, value in kwargs.items():
if field in ("first_name", "last_name"):
value = sanitize_telegram_name(value)
if hasattr(user, field):
setattr(user, field, value)
@@ -153,7 +311,8 @@ async def add_user_balance(
amount_kopeks: int,
description: str = "Пополнение баланса",
create_transaction: bool = True,
bot = None
transaction_type: TransactionType = TransactionType.DEPOSIT,
bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
@@ -162,12 +321,11 @@ async def add_user_balance(
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
from app.database.models import TransactionType
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description
)
@@ -187,9 +345,10 @@ async def add_user_balance(
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
telegram_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса"
description: str = "Пополнение баланса",
transaction_type: TransactionType = TransactionType.DEPOSIT,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
@@ -197,7 +356,13 @@ async def add_user_balance_by_id(
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(db, user, amount_kopeks, description)
return await add_user_balance(
db,
user,
amount_kopeks,
description,
transaction_type=transaction_type,
)
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
@@ -205,29 +370,115 @@ async def add_user_balance_by_id(
async def subtract_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str,
create_transaction: bool = False,
payment_method: Optional[PaymentMethod] = None,
*,
consume_promo_offer: bool = False,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.info(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None
if consume_promo_offer:
try:
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
current_percent = 0
if current_percent > 0:
source = getattr(user, "promo_offer_discount_source", None)
log_context = {
"offer_id": None,
"percent": current_percent,
"source": source,
"effect_type": None,
"details": {
"reason": "manual_charge",
"description": description,
"amount_kopeks": amount_kopeks,
},
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_context["offer_id"] = offer.id
log_context["effect_type"] = offer.effect_type
if not log_context["percent"] and offer.discount_percent:
log_context["percent"] = offer.discount_percent
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
if create_transaction:
from app.database.crud.transaction import (
create_transaction as create_trans,
)
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
)
if consume_promo_offer and log_context:
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get("offer_id"),
action="consumed",
source=log_context.get("source"),
percent=log_context.get("percent"),
effect_type=log_context.get("effect_type"),
details=log_context.get("details"),
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer consumption log for user %s: %s",
user.id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer consumption log failure: %s",
rollback_error,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
@@ -237,16 +488,115 @@ async def subtract_user_balance(
return False
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(User).where(
User.promo_offer_discount_percent > 0,
User.promo_offer_discount_expires_at.isnot(None),
User.promo_offer_discount_expires_at <= now,
)
)
users = result.scalars().all()
if not users:
return 0
log_payloads: List[Dict[str, object]] = []
for user in users:
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
percent = 0
source = getattr(user, "promo_offer_discount_source", None)
offer_id = None
effect_type = None
if source:
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
user.id,
lookup_error,
)
offer = None
if offer:
offer_id = offer.id
effect_type = offer.effect_type
if not percent and offer.discount_percent:
percent = offer.discount_percent
log_payloads.append(
{
"user_id": user.id,
"offer_id": offer_id,
"source": source,
"percent": percent,
"effect_type": effect_type,
}
)
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = now
await db.commit()
for payload in log_payloads:
user_id = payload.get("user_id")
if not user_id:
continue
try:
await log_promo_offer_action(
db,
user_id=user_id,
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to log promo offer expiration for user %s: %s",
user_id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer expiration log failure: %s",
rollback_error,
)
return len(users)
async def get_users_list(
db: AsyncSession,
offset: int = 0,
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None,
order_by_balance: bool = False
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
order_by_total_spent: bool = False,
order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
query = select(User).options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
if status:
query = query.where(User.status == status.value)
@@ -260,20 +610,96 @@ async def get_users_list(
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
query = query.where(or_(*conditions))
# Сортировка по балансу в порядке убывания, если order_by_balance=True
if order_by_balance:
query = query.order_by(User.balance_kopeks.desc())
sort_flags = [
order_by_balance,
order_by_traffic,
order_by_last_activity,
order_by_total_spent,
order_by_purchase_count,
]
if sum(int(flag) for flag in sort_flags) > 1:
logger.debug(
"Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
)
transactions_stats = None
if order_by_total_spent or order_by_purchase_count:
from app.database.models import Transaction
transactions_stats = (
select(
Transaction.user_id.label("user_id"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(Transaction.is_completed.is_(True))
.group_by(Transaction.user_id)
.subquery()
)
query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
if order_by_traffic:
traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
query = query.outerjoin(Subscription, Subscription.user_id == User.id)
query = query.order_by(traffic_sort.desc(), User.created_at.desc())
elif order_by_total_spent:
order_column = func.coalesce(transactions_stats.c.total_spent, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_purchase_count:
order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_balance:
query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
elif order_by_last_activity:
query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
else:
query = query.order_by(User.created_at.desc())
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_users_count(
@@ -296,7 +722,14 @@ async def get_users_count(
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
query = query.where(or_(*conditions))
@@ -304,17 +737,148 @@ async def get_users_count(
return result.scalar()
async def get_users_spending_stats(
db: AsyncSession,
user_ids: List[int]
) -> Dict[int, Dict[str, int]]:
if not user_ids:
return {}
from app.database.models import Transaction
stats_query = (
select(
Transaction.user_id,
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
)
.group_by(Transaction.user_id)
)
result = await db.execute(stats_query)
rows = result.all()
return {
row.user_id: {
"total_spent": int(row.total_spent or 0),
"purchase_count": int(row.purchase_count or 0),
}
for row in rows
}
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
now = datetime.utcnow()
base_query = (
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.status == UserStatus.ACTIVE.value)
)
if segment == "no_subscription":
query = (
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
.where(Subscription.id.is_(None))
)
else:
query = base_query.join(Subscription)
if segment == "paid_active":
query = query.where(
Subscription.is_trial == False, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "paid_expired":
query = query.where(
Subscription.is_trial == False, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
elif segment == "trial_active":
query = query.where(
Subscription.is_trial == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "trial_expired":
query = query.where(
Subscription.is_trial == True, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
else:
logger.warning("Неизвестный сегмент для промо: %s", segment)
return []
result = await db.execute(query.order_by(User.id))
users = result.scalars().unique().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
@@ -324,6 +888,8 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(
@@ -333,7 +899,15 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
)
)
)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def delete_user(db: AsyncSession, user: User) -> bool:
+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:
+311
View File
@@ -0,0 +1,311 @@
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy import select, and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import UserPromoGroup, PromoGroup, User
logger = logging.getLogger(__name__)
async def _sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
try:
result = await db.execute(
select(UserPromoGroup.promo_group_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.where(UserPromoGroup.user_id == user_id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
first = result.first()
new_primary_id = first[0] if first else None
user = await db.get(User, user_id)
if not user:
return
if user.promo_group_id != new_primary_id:
user.promo_group_id = new_primary_id
user.updated_at = datetime.utcnow()
except Exception as error:
logger.error(
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
user_id,
error,
)
async def sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
await _sync_user_primary_promo_group(db, user_id)
async def add_user_to_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int,
assigned_by: str = "admin"
) -> Optional[UserPromoGroup]:
"""
Добавляет пользователю промогруппу.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
Returns:
UserPromoGroup или None если уже существует
"""
try:
# Проверяем существование связи
existing = await has_user_promo_group(db, user_id, promo_group_id)
if existing:
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
return None
# Создаем новую связь
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by,
)
db.add(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user_promo_group)
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
return user_promo_group
except Exception as error:
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
await db.rollback()
return None
async def remove_user_from_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Удаляет промогруппу у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если удалено, False если связи не было
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
user_promo_group = result.scalar_one_or_none()
if not user_promo_group:
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
return False
await db.delete(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
return True
except Exception as error:
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
await db.rollback()
return False
async def get_user_promo_groups(
db: AsyncSession,
user_id: int
) -> List[UserPromoGroup]:
"""
Получает все промогруппы пользователя, отсортированные по приоритету.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
"""
try:
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
return list(result.scalars().all())
except Exception as error:
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
return []
async def get_primary_user_promo_group(
db: AsyncSession,
user_id: int
) -> Optional[PromoGroup]:
"""
Получает промогруппу пользователя с максимальным приоритетом.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
PromoGroup с максимальным приоритетом или None
"""
try:
user_promo_groups = await get_user_promo_groups(db, user_id)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
except Exception as error:
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
return None
async def has_user_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Проверяет наличие промогруппы у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если пользователь уже имеет эту промогруппу
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
return result.scalar_one_or_none() is not None
except Exception as error:
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
return False
async def count_user_promo_groups(
db: AsyncSession,
user_id: int
) -> int:
"""
Подсчитывает количество промогрупп у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Количество промогрупп
"""
try:
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
return len(list(result.scalars().all()))
except Exception as error:
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
return 0
async def replace_user_promo_groups(
db: AsyncSession,
user_id: int,
promo_group_ids: List[int],
assigned_by: str = "admin"
) -> bool:
"""
Заменяет все промогруппы пользователя на новый список.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_ids: Список ID промогрупп
assigned_by: Кто назначил
Returns:
True если успешно
"""
try:
# Удаляем все текущие промогруппы
await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
for upg in result.scalars().all():
await db.delete(upg)
# Добавляем новые
for promo_group_id in promo_group_ids:
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by
)
db.add(user_promo_group)
await db.commit()
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
return True
except Exception as error:
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
await db.rollback()
return False
+168
View File
@@ -0,0 +1,168 @@
"""CRUD helpers for WATA payment records."""
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WataPayment
logger = logging.getLogger(__name__)
async def create_wata_payment(
db: AsyncSession,
*,
user_id: int,
payment_link_id: str,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
type_: Optional[str],
url: Optional[str],
order_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
terminal_public_id: Optional[str] = None,
success_redirect_url: Optional[str] = None,
fail_redirect_url: Optional[str] = None,
) -> WataPayment:
payment = WataPayment(
user_id=user_id,
payment_link_id=payment_link_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
type=type_,
url=url,
metadata_json=metadata or {},
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_redirect_url,
fail_redirect_url=fail_redirect_url,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
status,
)
return payment
async def get_wata_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_wata_payment_by_link_id(
db: AsyncSession,
payment_link_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.payment_link_id == payment_link_id)
)
return result.scalar_one_or_none()
async def get_wata_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def update_wata_payment_status(
db: AsyncSession,
payment: WataPayment,
*,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
last_status: Optional[str] = None,
url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
callback_payload: Optional[Dict[str, Any]] = None,
terminal_public_id: Optional[str] = None,
) -> WataPayment:
update_values: Dict[str, Any] = {}
if status is not None:
update_values["status"] = status
if is_paid is not None:
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
if last_status is not None:
update_values["last_status"] = last_status
if url is not None:
update_values["url"] = url
if metadata is not None:
update_values["metadata_json"] = metadata
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
if terminal_public_id is not None:
update_values["terminal_public_id"] = terminal_public_id
if not update_values:
return payment
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Wata платеж %s: статус=%s, is_paid=%s",
payment.payment_link_id,
payment.status,
payment.is_paid,
)
return payment
async def link_wata_payment_to_transaction(
db: AsyncSession,
payment: WataPayment,
transaction_id: int,
) -> WataPayment:
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Wata платеж %s привязан к транзакции %s",
payment.payment_link_id,
transaction_id,
)
return payment
+106
View File
@@ -0,0 +1,106 @@
"""CRUD операции для токенов административного веб-API."""
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WebApiToken
async def list_tokens(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> List[WebApiToken]:
query = select(WebApiToken)
if not include_inactive:
query = query.where(WebApiToken.is_active.is_(True))
query = query.order_by(WebApiToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
return await db.get(WebApiToken, token_id)
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
query = select(WebApiToken).where(
WebApiToken.token_hash == token_hash
)
result = await db.execute(query)
return result.scalar_one_or_none()
async def create_token(
db: AsyncSession,
*,
name: str,
token_hash: str,
token_prefix: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
) -> WebApiToken:
token = WebApiToken(
name=name,
token_hash=token_hash,
token_prefix=token_prefix,
description=description,
expires_at=expires_at,
created_by=created_by,
is_active=True,
)
db.add(token)
await db.flush()
await db.refresh(token)
return token
async def update_token(
db: AsyncSession,
token: WebApiToken,
**kwargs,
) -> WebApiToken:
for key, value in kwargs.items():
if hasattr(token, key):
setattr(token, key, value)
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def set_tokens_active_status(
db: AsyncSession,
token_ids: Iterable[int],
*,
is_active: bool,
) -> None:
await db.execute(
update(WebApiToken)
.where(WebApiToken.id.in_(list(token_ids)))
.values(is_active=is_active, updated_at=datetime.utcnow())
)
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
await db.delete(token)
__all__ = [
"list_tokens",
"get_token_by_id",
"get_token_by_hash",
"create_token",
"update_token",
"set_tokens_active_status",
"delete_token",
]
+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 бесплатно! "
+361 -15
View File
@@ -1,31 +1,167 @@
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from typing import AsyncGenerator, Optional
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.engine import Engine
import time
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
# ============================================================================
if settings.get_database_url().startswith("sqlite"):
poolclass = NullPool
pool_kwargs = {}
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_pre_ping": True,
# 🔥 Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
}
# ============================================================================
# ENGINE WITH ADVANCED OPTIMIZATIONS
# ============================================================================
engine = create_async_engine(
settings.DATABASE_URL,
poolclass=NullPool,
settings.get_database_url(),
poolclass=poolclass,
echo=settings.DEBUG,
future=True
future=True,
**pool_kwargs,
connect_args={
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
} if not settings.get_database_url().startswith("sqlite") else {},
execution_options={
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
"compiled_cache_size": 500, # Кеш скомпилированных запросов
}
)
# ============================================================================
# SESSION FACTORY WITH OPTIMIZATIONS
# ============================================================================
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
autoflush=False, # 🔥 Критично для производительности
autocommit=False,
)
# ============================================================================
# QUERY PERFORMANCE MONITORING
# ============================================================================
if settings.DEBUG:
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault("query_start_time", []).append(time.time())
logger.debug(f"🔍 Executing query: {statement[:100]}...")
@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
total = time.time() - conn.info["query_start_time"].pop(-1)
if total > 0.1: # Логируем медленные запросы > 100ms
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
else:
logger.debug(f"⚡ Query executed in {total:.3f}s")
# ============================================================================
# ADVANCED SESSION MANAGER WITH READ REPLICAS
# ============================================================================
class DatabaseManager:
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
def __init__(self):
self.engine = engine
self.read_replica_engine: Optional[AsyncEngine] = None
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
self.read_replica_engine = create_async_engine(
settings.DATABASE_READ_REPLICA_URL,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
echo=False,
)
@asynccontextmanager
async def session(self, read_only: bool = False):
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
async_session = async_sessionmaker(
bind=target_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with async_session() as session:
try:
yield session
if not read_only:
await session.commit()
except Exception:
await session.rollback()
raise
async def health_check(self) -> dict:
pool = self.engine.pool
try:
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except Exception as e:
logger.error(f"❌ Database health check failed: {e}")
status = "unhealthy"
latency = None
return {
"status": status,
"latency_ms": round(latency, 2) if latency else None,
"pool": _collect_health_pool_metrics(pool),
}
db_manager = DatabaseManager()
# ============================================================================
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
# ============================================================================
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Стандартная dependency для FastAPI"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
"""Read-only dependency для тяжелых SELECT запросов"""
async with db_manager.session(read_only=True) as session:
yield session
# ============================================================================
# BATCH OPERATIONS FOR PERFORMANCE
# ============================================================================
class BatchOperations:
"""Утилиты для массовых операций"""
@staticmethod
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовая вставка с чанками"""
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
session.add_all([model(**item) for item in chunk])
await session.flush()
await session.commit()
@staticmethod
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовое обновление с чанками"""
if not data:
return
primary_keys = [column.name for column in model.__table__.primary_key.columns]
if not primary_keys:
raise ValueError("Model must have a primary key for bulk_update")
updatable_columns = [
column.name
for column in model.__table__.columns
if column.name not in primary_keys
]
if not updatable_columns:
raise ValueError("No columns available for update in bulk_update")
stmt = (
model.__table__.update()
.where(
*[
getattr(model.__table__.c, pk) == bindparam(pk)
for pk in primary_keys
]
)
.values(
**{
column: bindparam(column, required=False)
for column in updatable_columns
}
)
)
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
filtered_chunk = []
for item in chunk:
missing_keys = [pk for pk in primary_keys if pk not in item]
if missing_keys:
raise ValueError(
f"Missing primary key values {missing_keys} for bulk_update"
)
filtered_item = {
key: value
for key, value in item.items()
if key in primary_keys or key in updatable_columns
}
filtered_chunk.append(filtered_item)
await session.execute(stmt, filtered_chunk)
await session.commit()
batch_ops = BatchOperations()
# ============================================================================
# INITIALIZATION AND CLEANUP
# ============================================================================
async def init_db():
logger.info("Создание таблиц базы данных...")
"""Инициализация БД с оптимизациями"""
logger.info("🚀 Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if not settings.get_database_url().startswith("sqlite"):
logger.info("📊 Создание индексов для оптимизации...")
async with engine.begin() as conn:
indexes = [
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
"payments",
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
"Пропускаем создание индекса %s: таблица %s отсутствует",
index_sql,
table_name,
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug("Index creation skipped for %s: %s", table_name, e)
logger.info("✅ База данных успешно инициализирована")
health = await db_manager.health_check()
logger.info(f"📊 Database health: {health}")
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info("🔄 Закрытие соединений с БД...")
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
if db_manager.read_replica_engine:
await db_manager.read_replica_engine.dispose()
logger.info("✅ Все подключения к базе данных закрыты")
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
def _pool_counters(pool):
"""Return basic pool counters or ``None`` when unsupported."""
required_methods = ("size", "checkedin", "checkedout", "overflow")
for method_name in required_methods:
method = getattr(pool, method_name, None)
if method is None or not callable(method):
return None
size = pool.size()
checked_in = pool.checkedin()
checked_out = pool.checkedout()
overflow = pool.overflow()
total_connections = size + overflow
return {
"size": size,
"checked_in": checked_in,
"checked_out": checked_out,
"overflow": overflow,
"total_connections": total_connections,
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
}
def _collect_health_pool_metrics(pool) -> dict:
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"size": 0,
"checked_in": 0,
"checked_out": 0,
"overflow": 0,
"total_connections": 0,
"utilization": "0.0%",
}
return {
"metrics_available": True,
"size": counters["size"],
"checked_in": counters["checked_in"],
"checked_out": counters["checked_out"],
"overflow": counters["overflow"],
"total_connections": counters["total_connections"],
"utilization": f"{counters['utilization_percent']:.1f}%",
}
async def get_pool_metrics() -> dict:
"""Детальные метрики пула для Prometheus/Grafana"""
pool = engine.pool
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"pool_size": 0,
"checked_in_connections": 0,
"checked_out_connections": 0,
"overflow_connections": 0,
"total_connections": 0,
"max_possible_connections": 0,
"pool_utilization_percent": 0.0,
}
return {
"metrics_available": True,
"pool_size": counters["size"],
"checked_in_connections": counters["checked_in"],
"checked_out_connections": counters["checked_out"],
"overflow_connections": counters["overflow"],
"total_connections": counters["total_connections"],
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
"pool_utilization_percent": round(counters["utilization_percent"], 2),
}
+560 -28
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from typing import Optional, List, Dict, Any
from enum import Enum
from sqlalchemy import (
@@ -54,20 +54,23 @@ class SubscriptionStatus(Enum):
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
PENDING = "pending"
class TransactionType(Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
POLL_REWARD = "poll_reward"
class PromoCodeType(Enum):
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
PROMO_GROUP = "promo_group"
class PaymentMethod(Enum):
@@ -75,10 +78,24 @@ class PaymentMethod(Enum):
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
HELEKET = "heleket"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
MANUAL = "manual"
class MainMenuButtonActionType(Enum):
URL = "url"
MINI_APP = "mini_app"
class MainMenuButtonVisibility(Enum):
ALL = "all"
ADMINS = "admins"
SUBSCRIBERS = "subscribers"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
@@ -177,6 +194,72 @@ class CryptoBotPayment(Base):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class HeleketPayment(Base):
__tablename__ = "heleket_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
uuid = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(128), unique=True, nullable=False, index=True)
amount = Column(String(50), nullable=False)
currency = Column(String(10), nullable=False)
payer_amount = Column(String(50), nullable=True)
payer_currency = Column(String(10), nullable=True)
exchange_rate = Column(Float, nullable=True)
discount_percent = Column(Integer, nullable=True)
status = Column(String(50), nullable=False)
payment_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="heleket_payments")
transaction = relationship("Transaction", backref="heleket_payment")
@property
def amount_float(self) -> float:
try:
return float(self.amount)
except (TypeError, ValueError):
return 0.0
@property
def amount_kopeks(self) -> int:
return int(round(self.amount_float * 100))
@property
def payer_amount_float(self) -> float:
try:
return float(self.payer_amount) if self.payer_amount is not None else 0.0
except (TypeError, ValueError):
return 0.0
@property
def is_paid(self) -> bool:
return self.status in {"paid", "paid_over"}
def __repr__(self):
return (
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
" {currency}, status={status})>"
).format(
id=self.id,
uuid=self.uuid,
order_id=self.order_id,
amount=self.amount,
currency=self.currency,
status=self.status,
)
class MulenPayPayment(Base):
__tablename__ = "mulenpay_payments"
@@ -282,11 +365,112 @@ class Pal24Payment(Base):
)
class WataPayment(Base):
__tablename__ = "wata_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
order_id = Column(String(255), nullable=True, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
type = Column(String(50), nullable=True)
status = Column(String(50), nullable=False, default="Opened")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
last_status = Column(String(50), nullable=True)
terminal_public_id = Column(String(64), nullable=True)
url = Column(Text, nullable=True)
success_redirect_url = Column(Text, nullable=True)
fail_redirect_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="wata_payments")
transaction = relationship("Transaction", backref="wata_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<WataPayment(id={0}, link_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.payment_link_id,
self.amount_rubles,
self.status,
)
)
class PlategaPayment(Base):
__tablename__ = "platega_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
payment_method_code = Column(Integer, nullable=False)
status = Column(String(50), nullable=False, default="PENDING")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
redirect_url = Column(Text, nullable=True)
return_url = Column(Text, nullable=True)
failed_url = Column(Text, nullable=True)
payload = Column(String(255), nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="platega_payments")
transaction = relationship("Transaction", backref="platega_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
self.id,
self.platega_transaction_id,
self.amount_rubles,
self.status,
self.payment_method_code,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
priority = Column(Integer, nullable=False, default=0, index=True)
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
@@ -298,6 +482,7 @@ class PromoGroup(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
server_squads = relationship(
"ServerSquad",
secondary=server_squad_promo_groups,
@@ -356,11 +541,31 @@ 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))
class UserPromoGroup(Base):
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
__tablename__ = "user_promo_groups"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
assigned_at = Column(DateTime, default=func.now())
assigned_by = Column(String(50), default="system")
user = relationship("User", back_populates="user_promo_groups")
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
def __repr__(self):
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
class User(Base):
__tablename__ = "users"
@@ -386,30 +591,64 @@ class User(Base):
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
discount_offers = relationship("DiscountOffer", back_populates="user")
promo_offer_logs = relationship("PromoOfferLog", back_populates="user")
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)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
return self.balance_kopeks / 100
@property
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return getattr(self, "promo_group", None)
try:
# Сортируем по приоритету группы (убывание), затем по ID группы
# Используем getattr для защиты от ленивой загрузки
sorted_groups = sorted(
self.user_promo_groups,
key=lambda upg: (
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
upg.promo_group_id
),
reverse=True
)
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
except Exception:
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
pass
# Fallback на старую связь если новая пустая или возникла ошибка
return getattr(self, "promo_group", None)
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
if not self.promo_group:
primary_group = self.get_primary_promo_group()
if not primary_group:
return 0
return self.promo_group.get_discount_percent(category, period_days)
return primary_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -453,6 +692,7 @@ class Subscription(Base):
user = relationship("User", back_populates="subscription")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
@property
def is_active(self) -> bool:
@@ -571,8 +811,7 @@ class Subscription(Base):
return 0.0
def extend_subscription(self, days: int):
from datetime import timedelta, datetime
if self.end_date > datetime.utcnow():
self.end_date = self.end_date + timedelta(days=days)
else:
@@ -657,13 +896,15 @@ class PromoCode(Base):
valid_until = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
uses = relationship("PromoCodeUse", back_populates="promocode")
promo_group = relationship("PromoGroup")
@property
def is_valid(self) -> bool:
@@ -740,9 +981,9 @@ class Squad(Base):
class ServiceRule(Base):
__tablename__ = "service_rules"
id = Column(Integer, primary_key=True, index=True)
order = Column(Integer, default=0)
title = Column(String(255), nullable=False)
@@ -756,9 +997,54 @@ class ServiceRule(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PrivacyPolicy(Base):
__tablename__ = "privacy_policies"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PublicOffer(Base):
__tablename__ = "public_offers"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqSetting(Base):
__tablename__ = "faq_settings"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqPage(Base):
__tablename__ = "faq_pages"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
display_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=True)
@@ -797,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__ = (
@@ -812,17 +1122,79 @@ class DiscountOffer(Base):
expires_at = Column(DateTime, nullable=False)
claimed_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
effect_type = Column(String(50), nullable=False, default="percent_discount")
extra_data = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="discount_offers")
subscription = relationship("Subscription", back_populates="discount_offers")
logs = relationship("PromoOfferLog", back_populates="offer")
class PromoOfferTemplate(Base):
__tablename__ = "promo_offer_templates"
__table_args__ = (
Index("ix_promo_offer_templates_type", "offer_type"),
)
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
offer_type = Column(String(50), nullable=False)
message_text = Column(Text, nullable=False)
button_text = Column(String(255), nullable=False)
valid_hours = Column(Integer, nullable=False, default=24)
discount_percent = Column(Integer, nullable=False, default=0)
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
active_discount_hours = Column(Integer, nullable=True)
test_duration_hours = Column(Integer, nullable=True)
test_squad_uuids = Column(JSON, default=list)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User")
class SubscriptionTemporaryAccess(Base):
__tablename__ = "subscription_temporary_access"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False)
squad_uuid = Column(String(255), nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=func.now())
deactivated_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
was_already_connected = Column(Boolean, default=False, nullable=False)
subscription = relationship("Subscription", back_populates="temporary_accesses")
offer = relationship("DiscountOffer")
class PromoOfferLog(Base):
__tablename__ = "promo_offer_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True)
action = Column(String(50), nullable=False)
source = Column(String(100), nullable=True)
percent = Column(Integer, nullable=True)
effect_type = Column(String(50), nullable=True)
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", back_populates="promo_offer_logs")
offer = relationship("DiscountOffer", back_populates="logs")
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True)
@@ -838,6 +1210,106 @@ class BroadcastHistory(Base):
completed_at = Column(DateTime(timezone=True), nullable=True)
admin = relationship("User", back_populates="broadcasts")
class Poll(Base):
__tablename__ = "polls"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
reward_enabled = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
questions = relationship(
"PollQuestion",
back_populates="poll",
cascade="all, delete-orphan",
order_by="PollQuestion.order",
)
responses = relationship(
"PollResponse",
back_populates="poll",
cascade="all, delete-orphan",
)
class PollQuestion(Base):
__tablename__ = "poll_questions"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="questions")
options = relationship(
"PollOption",
back_populates="question",
cascade="all, delete-orphan",
order_by="PollOption.order",
)
answers = relationship("PollAnswer", back_populates="question")
class PollOption(Base):
__tablename__ = "poll_options"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
question = relationship("PollQuestion", back_populates="options")
answers = relationship("PollAnswer", back_populates="option")
class PollResponse(Base):
__tablename__ = "poll_responses"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
sent_at = Column(DateTime, default=func.now(), nullable=False)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
reward_given = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="responses")
user = relationship("User", back_populates="poll_responses")
answers = relationship(
"PollAnswer",
back_populates="response",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
class PollAnswer(Base):
__tablename__ = "poll_answers"
id = Column(Integer, primary_key=True, index=True)
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
response = relationship("PollResponse", back_populates="answers")
question = relationship("PollQuestion", back_populates="answers")
option = relationship("PollOption", back_populates="answers")
__table_args__ = (
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
)
class ServerSquad(Base):
__tablename__ = "server_squads"
@@ -852,6 +1324,7 @@ class ServerSquad(Base):
country_code = Column(String(5), nullable=True)
is_available = Column(Boolean, default=True)
is_trial_eligible = Column(Boolean, default=False, nullable=False)
price_kopeks = Column(Integer, default=0)
@@ -1091,7 +1564,7 @@ class Ticket(Base):
class TicketMessage(Base):
__tablename__ = "ticket_messages"
id = Column(Integer, primary_key=True, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
@@ -1118,6 +1591,65 @@ class TicketMessage(Base):
@property
def is_admin_message(self) -> bool:
return self.is_from_admin
def __repr__(self):
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
class WebApiToken(Base):
__tablename__ = "web_api_tokens"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
token_hash = Column(String(128), nullable=False, unique=True, index=True)
token_prefix = Column(String(32), nullable=False, index=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
expires_at = Column(DateTime, nullable=True)
last_used_at = Column(DateTime, nullable=True)
last_used_ip = Column(String(64), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(String(255), nullable=True)
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
class MainMenuButton(Base):
__tablename__ = "main_menu_buttons"
id = Column(Integer, primary_key=True, index=True)
text = Column(String(64), nullable=False)
action_type = Column(String(20), nullable=False)
action_value = Column(Text, nullable=False)
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
is_active = Column(Boolean, nullable=False, default=True)
display_order = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
__table_args__ = (
Index("ix_main_menu_buttons_order", "display_order", "id"),
)
@property
def action_type_enum(self) -> MainMenuButtonActionType:
try:
return MainMenuButtonActionType(self.action_type)
except ValueError:
return MainMenuButtonActionType.URL
@property
def visibility_enum(self) -> MainMenuButtonVisibility:
try:
return MainMenuButtonVisibility(self.visibility)
except ValueError:
return MainMenuButtonVisibility.ALL
def __repr__(self) -> str:
return (
f"<MainMenuButton id={self.id} text='{self.text}' "
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
)
File diff suppressed because it is too large Load Diff
+34 -14
View File
@@ -19,10 +19,10 @@ class CryptoBotService:
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
) -> Optional[Dict[str, Any]]:
if not self.api_token:
@@ -37,11 +37,18 @@ class CryptoBotService:
try:
async with aiohttp.ClientSession() as session:
request_kwargs: Dict[str, Any] = {"headers": headers}
if method.upper() == "GET":
if data:
request_kwargs["params"] = data
elif data:
request_kwargs["json"] = data
async with session.request(
method,
url,
headers=headers,
json=data if data else None
method,
url,
**request_kwargs,
) as response:
response_data = await response.json()
@@ -95,21 +102,34 @@ class CryptoBotService:
asset: Optional[str] = None,
status: Optional[str] = None,
offset: int = 0,
count: int = 100
count: int = 100,
invoice_ids: Optional[list] = None,
) -> Optional[list]:
data = {
'offset': offset,
'count': count
}
if asset:
data['asset'] = asset
if status:
data['status'] = status
return await self._make_request('GET', 'getInvoices', data)
if invoice_ids:
data['invoice_ids'] = invoice_ids
result = await self._make_request('GET', 'getInvoices', data)
if isinstance(result, dict):
items = result.get('items')
return items if isinstance(items, list) else []
if isinstance(result, list):
return result
return []
async def get_balance(self) -> Optional[list]:
return await self._make_request('GET', 'getBalance')
+174
View File
@@ -0,0 +1,174 @@
"""HTTP client for Heleket payment API."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class HeleketService:
"""Minimal wrapper around Heleket API endpoints."""
def __init__(self) -> None:
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
self.merchant_id = settings.HELEKET_MERCHANT_ID
self.api_key = settings.HELEKET_API_KEY
@property
def is_configured(self) -> bool:
return bool(self.merchant_id and self.api_key)
def _prepare_body(
self,
payload: Dict[str, Any],
*,
ignore_none: bool,
sort_keys: bool,
) -> str:
if ignore_none:
cleaned = {key: value for key, value in payload.items() if value is not None}
else:
cleaned = dict(payload)
serialized = json.dumps(
cleaned,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=sort_keys,
)
if "/" in serialized:
serialized = serialized.replace("/", "\\/")
return serialized
def _generate_signature(self, body: str) -> str:
api_key = self.api_key or ""
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
raw = f"{encoded}{api_key}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
async def _request(
self,
endpoint: str,
payload: Dict[str, Any],
*,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
signature = self._generate_signature(body)
url = f"{self.base_url}/{endpoint.lstrip('/')}"
headers = {
"merchant": self.merchant_id or "",
"sign": signature,
"Content-Type": "application/json",
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
url,
data=body.encode("utf-8"),
headers=headers,
params=params,
) as response:
text = await response.text()
if response.content_type != "application/json":
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.error("Ошибка парсинга Heleket JSON: %s", text)
return None
if response.status >= 400:
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
return None
if isinstance(data, dict) and data.get("state") == 0:
return data
logger.error("Heleket API вернул ошибку: %s", data)
return None
except Exception as error: # pragma: no cover - defensive
logger.error("Ошибка запроса к Heleket API: %s", error)
return None
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self._request("payment", payload)
async def get_payment_info(
self,
*,
uuid: Optional[str] = None,
order_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not uuid and not order_id:
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
payload: Dict[str, Any] = {}
if uuid:
payload["uuid"] = uuid
if order_id:
payload["order_id"] = order_id
return await self._request("payment/info", payload)
async def list_payments(
self,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
cursor: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {}
if date_from:
payload["date_from"] = date_from
if date_to:
payload["date_to"] = date_to
params = {"cursor": cursor} if cursor else None
return await self._request("payment/list", payload, params=params)
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning("Heleket сервис не настроен, подпись пропускается")
return True
if not isinstance(payload, dict):
logger.error("Heleket webhook payload не dict: %s", payload)
return False
signature = payload.get("sign")
if not signature:
logger.error("Heleket webhook без подписи")
return False
data = dict(payload)
data.pop("sign", None)
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
expected = self._generate_signature(body)
is_valid = hmac.compare_digest(expected, str(signature))
if not is_valid:
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
return is_valid
+111
View File
@@ -0,0 +1,111 @@
import asyncio
import json
import logging
from typing import Any, Dict, Optional
from aiohttp import web
from app.config import settings
from app.database.database import get_db
from app.external.heleket import HeleketService
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class HeleketWebhookHandler:
def __init__(self, payment_service: PaymentService) -> None:
self.payment_service = payment_service
self.service = HeleketService()
async def handle(self, request: web.Request) -> web.Response:
if not settings.is_heleket_enabled():
logger.warning("Получен Heleket webhook, но сервис отключен")
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
try:
payload: Dict[str, Any] = await request.json()
except json.JSONDecodeError:
logger.error("Некорректный JSON Heleket webhook")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
if not self.service.verify_webhook_signature(payload):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
processed: Optional[bool] = None
async for db in get_db():
processed = await self.payment_service.process_heleket_webhook(db, payload)
if processed:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
async def health_check(self, _: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"service": "heleket_webhook",
"enabled": settings.is_heleket_enabled(),
"path": settings.HELEKET_WEBHOOK_PATH,
}
)
async def options_handler(self, _: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
)
def create_heleket_app(payment_service: PaymentService) -> web.Application:
handler = HeleketWebhookHandler(payment_service)
app = web.Application()
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
app.router.add_get("/heleket/health", handler.health_check)
app.router.add_get("/health", handler.health_check)
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
return app
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_heleket_enabled():
logger.info("Heleket отключен, webhook сервер не запускается")
return
app = create_heleket_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.HELEKET_WEBHOOK_HOST,
port=settings.HELEKET_WEBHOOK_PORT,
)
try:
await site.start()
logger.info(
"Heleket webhook сервер запущен на %s:%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
)
logger.info(
"Heleket webhook URL: http://%s:%s%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
settings.HELEKET_WEBHOOK_PATH,
)
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("Heleket webhook сервер остановлен по запросу")
finally:
await site.stop()
await runner.cleanup()
logger.info("Heleket webhook сервер корректно остановлен")
+16 -9
View File
@@ -1,8 +1,9 @@
"""Flask webhook server for PayPalych postbacks."""
"""Flask webhook server for PayPalych callbacks."""
from __future__ import annotations
import asyncio
from concurrent.futures import TimeoutError as FuturesTimeoutError
import json
import logging
import threading
@@ -13,7 +14,7 @@ from flask import Flask, jsonify, request
from werkzeug.serving import make_server
from app.config import settings
from app.database.database import get_db
from app.database.database import AsyncSessionLocal
from app.services.pal24_service import Pal24Service, Pal24APIError
from app.services.payment_service import PaymentService
@@ -56,27 +57,33 @@ def create_pal24_flask_app(
logger.error("Pal24 webhook получен, но сервис не настроен")
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers))
payload = _normalize_payload()
if not payload:
logger.warning("Пустой Pal24 webhook")
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
async def process() -> bool:
async for db in get_db():
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
finally:
await db.close()
return await payment_service.process_pal24_callback(db, parsed_payload)
except Exception:
await db.rollback()
raise
try:
future = asyncio.run_coroutine_threadsafe(process(), loop)
processed = future.result()
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
except FuturesTimeoutError:
logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT)
return jsonify({"status": "error", "reason": "timeout"}), 504
except Exception as error: # pragma: no cover - defensive
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": "internal_error"}), 500
@@ -105,7 +112,7 @@ def create_pal24_flask_app(
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
"""Threaded Flask server for Pal24 callbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
+1 -2
View File
@@ -4,7 +4,6 @@ import ssl
import base64
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
from urllib.parse import urlparse
import aiohttp
import logging
from dataclasses import dataclass
@@ -164,7 +163,7 @@ class RemnaWaveAPI:
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = self._prepare_auth_headers()
+12 -10
View File
@@ -1,4 +1,5 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any
from aiogram import Bot
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
@@ -18,8 +19,9 @@ class TelegramStarsService:
return settings.rubles_to_stars(rubles)
@staticmethod
def calculate_rubles_from_stars(stars: int) -> float:
return settings.stars_to_rubles(stars)
def calculate_rubles_from_stars(stars: int) -> Decimal:
rate = Decimal(str(settings.get_stars_rate()))
return (Decimal(stars) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
async def create_invoice(
self,
@@ -31,10 +33,10 @@ class TelegramStarsService:
start_parameter: Optional[str] = None
) -> Optional[str]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
stars_rate = settings.get_stars_rate()
invoice_link = await self.bot.create_invoice_link(
title=title,
description=description,
@@ -46,7 +48,7 @@ class TelegramStarsService:
)
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}) "
f"Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) "
f"для {chat_id}, курс: {stars_rate}₽/⭐"
)
return invoice_link
@@ -65,8 +67,8 @@ class TelegramStarsService:
keyboard: Optional[InlineKeyboardMarkup] = None
) -> Optional[Dict[str, Any]]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
stars_rate = settings.get_stars_rate()
message = await self.bot.send_invoice(
@@ -82,12 +84,12 @@ class TelegramStarsService:
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{int(amount_rubles)}), курс: {stars_rate}₽/⭐"
f"(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐"
)
return {
"message_id": message.message_id,
"stars_amount": stars_amount,
"rubles_amount": amount_rubles,
"rubles_amount": float(amount_rubles),
"payload": payload
}
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
import asyncio
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import aiohttp
from aiohttp import web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from app.config import settings
from app.database.database import get_db
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class WataPublicKeyProvider:
"""Loads and caches the WATA public key used for webhook signature validation."""
def __init__(self, *, cache_seconds: Optional[int] = None) -> None:
self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS)
self._cached_key: Optional[str] = None
self._expires_at: Optional[datetime] = None
self._lock = asyncio.Lock()
async def get_public_key(self) -> Optional[str]:
"""Returns a cached public key or fetches a new one from WATA."""
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
async with self._lock:
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
key = await self._fetch_public_key()
if key:
self._cached_key = key
if self._cache_seconds > 0:
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
else:
self._expires_at = None
logger.debug("Получен и закеширован публичный ключ WATA")
return self._cached_key
if self._cached_key:
logger.warning("Используем ранее закешированный публичный ключ WATA")
return self._cached_key
logger.error("Публичный ключ WATA недоступен")
return None
async def _fetch_public_key(self) -> Optional[str]:
url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key"
timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as response:
text = await response.text()
if response.status >= 400:
logger.error(
"Ошибка получения публичного ключа WATA %s: %s",
response.status,
text,
)
return None
try:
payload = await response.json()
except aiohttp.ContentTypeError:
logger.error("Ответ WATA public-key не является JSON: %s", text)
return None
if isinstance(payload, dict):
value = payload.get("value")
if value:
return value
logger.error("Ответ WATA public-key не содержит ключ: %s", payload)
else:
logger.error("Неожиданный формат ответа WATA public-key: %s", payload)
except Exception as error:
logger.error("Ошибка запроса публичного ключа WATA: %s", error)
return None
class WataWebhookHandler:
"""Processes webhook callbacks coming from WATA."""
def __init__(
self,
payment_service: PaymentService,
*,
public_key_provider: Optional[WataPublicKeyProvider] = None,
) -> None:
self.payment_service = payment_service
self.public_key_provider = public_key_provider or WataPublicKeyProvider()
async def _verify_signature(self, raw_body: str, signature: str) -> bool:
signature = (signature or "").strip()
if not signature:
logger.error("WATA webhook без подписи")
return False
public_key_pem = await self.public_key_provider.get_public_key()
if not public_key_pem:
logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна")
return False
try:
signature_bytes = base64.b64decode(signature)
except (ValueError, TypeError):
logger.error("Некорректная подпись WATA (не Base64)")
return False
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
except ValueError as error:
logger.error("Ошибка загрузки публичного ключа WATA: %s", error)
return False
try:
public_key.verify(
signature_bytes,
raw_body.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA512(),
)
return True
except InvalidSignature:
logger.warning("Подпись WATA webhook не прошла проверку")
return False
except Exception as error:
logger.error("Ошибка проверки подписи WATA: %s", error)
return False
async def handle_webhook(self, request: web.Request) -> web.Response:
if not settings.is_wata_enabled():
logger.warning("Получен WATA webhook, но сервис отключен")
return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503)
raw_body = await request.text()
if not raw_body:
logger.warning("Получен пустой WATA webhook")
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
signature = request.headers.get("X-Signature")
if not await self._verify_signature(raw_body, signature or ""):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload: Dict[str, Any] = json.loads(raw_body)
except json.JSONDecodeError:
logger.error("Некорректный JSON WATA webhook")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
logger.info(
"Получен WATA webhook: order_id=%s, status=%s",
payload.get("orderId"),
payload.get("transactionStatus"),
)
processed: Optional[bool] = None
async for db in get_db():
processed = await self.payment_service.process_wata_webhook(db, payload)
# Allow the generator to finish naturally so it can commit/rollback.
# get_db() yields only once, so exiting the loop body without breaking
# triggers the generator cleanup logic on the next iteration attempt.
if processed is None:
logger.error("Не удалось обработать WATA webhook: нет сессии БД")
return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500)
if processed:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
async def health_check(self, request: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"service": "wata_webhook",
"enabled": settings.is_wata_enabled(),
"path": settings.WATA_WEBHOOK_PATH,
}
)
async def options_handler(self, _: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
},
)
def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
handler = WataWebhookHandler(payment_service)
app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook)
app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check)
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
app.router.add_get("/health", handler.health_check)
logger.info(
"Настроен WATA webhook endpoint на %s",
settings.WATA_WEBHOOK_PATH,
)
return app
async def start_wata_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_wata_enabled():
logger.info("WATA отключен, webhook сервер не запускается")
return
app = create_wata_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.WATA_WEBHOOK_HOST,
port=settings.WATA_WEBHOOK_PORT,
)
try:
await site.start()
logger.info(
"WATA webhook сервер запущен на %s:%s",
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
)
logger.info(
"WATA webhook URL: http://%s:%s%s",
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
settings.WATA_WEBHOOK_PATH,
)
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("WATA webhook сервер остановлен по запросу")
finally:
await site.stop()
await runner.cleanup()
logger.info("WATA webhook сервер корректно остановлен")
+165 -36
View File
@@ -1,8 +1,9 @@
import base64
import hashlib
import hmac
import logging
import json
from typing import Optional
from typing import Optional, Iterable
from aiohttp import web
from aiogram import Bot
@@ -47,7 +48,12 @@ class WebhookServer:
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
" - %s webhook: POST %s",
mulenpay_name,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
@@ -65,20 +71,39 @@ class WebhookServer:
self.site = web.TCPSite(
self.runner,
host='0.0.0.0',
host=settings.TRIBUTE_WEBHOOK_HOST,
port=settings.TRIBUTE_WEBHOOK_PORT
)
await self.site.start()
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(
"Webhook сервер запущен на %s:%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
)
logger.info(
"Tribute webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.TRIBUTE_WEBHOOK_PATH,
)
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
"%s webhook URL: http://%s:%s%s",
mulenpay_name,
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(
"CryptoBot webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.CRYPTOBOT_WEBHOOK_PATH,
)
except Exception as e:
logger.error(f"Ошибка запуска webhook сервера: {e}")
@@ -110,71 +135,175 @@ class WebhookServer:
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
"%s webhook: %s %s",
mulenpay_name,
request.method,
request.path,
)
logger.info(
"%s webhook headers: %s",
mulenpay_name,
dict(request.headers),
)
raw_body = await request.read()
if not raw_body:
logger.warning("Пустой Mulen Pay webhook")
logger.warning("Пустой %s webhook", mulenpay_name)
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
# Временно отключаем проверку подписи для отладки
# TODO: Включить обратно после настройки MulenPay
if not self._verify_mulenpay_signature(request, raw_body):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
logger.warning(
"%s webhook signature verification failed, but processing anyway for debugging",
mulenpay_name,
)
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError as error:
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
payment_service = PaymentService(self.bot)
async for db in get_db():
# Получаем соединение с БД
db_generator = get_db()
db = await db_generator.__anext__()
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(
f"Ошибка обработки {mulenpay_name} webhook: {error}",
exc_info=True,
)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
break
await db_generator.__anext__()
except StopAsyncIteration:
pass
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
mulenpay_name = settings.get_mulenpay_display_name()
logger.error(
f"Критическая ошибка {mulenpay_name} webhook: {error}",
exc_info=True,
)
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
@staticmethod
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
for header_name in header_names:
value = request.headers.get(header_name)
if value:
return value.strip()
return None
@staticmethod
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
display_name = settings.get_mulenpay_display_name()
if not secret_key:
logger.error("Mulen Pay secret key is not configured")
logger.error("%s secret key is not configured", display_name)
return False
signature = request.headers.get('X-MulenPay-Signature')
# Логируем все заголовки для отладки
logger.info("%s webhook headers for signature verification:", display_name)
for header_name, header_value in request.headers.items():
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
logger.info(f" {header_name}: {header_value}")
signature = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Signature',
'X-Mulenpay-Signature',
'X-MULENPAY-SIGNATURE',
'X-MulenPay-Webhook-Signature',
'X-Mulenpay-Webhook-Signature',
'X-MULENPAY-WEBHOOK-SIGNATURE',
'X-Signature',
'Signature',
'X-MulenPay-Sign',
'X-Mulenpay-Sign',
'X-MULENPAY-SIGN',
'MulenPay-Signature',
'Mulenpay-Signature',
'MULENPAY-SIGNATURE',
'signature',
'sign',
)
)
if signature:
expected_signature = hmac.new(
normalized_signature = signature
if normalized_signature.lower().startswith('sha256='):
normalized_signature = normalized_signature.split('=', 1)[1].strip()
hmac_digest = hmac.new(
secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
).digest()
expected_hex_signature = hmac_digest.hex()
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()):
normalized_signature_lower = normalized_signature.lower()
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
return True
logger.error("Неверная подпись Mulen Pay webhook")
normalized_signature_no_padding = normalized_signature.rstrip('=')
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
return True
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
return True
logger.error("Неверная подпись %s webhook", display_name)
return False
authorization_header = request.headers.get('Authorization')
if authorization_header and authorization_header.startswith('Bearer '):
token = authorization_header.split(' ', 1)[1].strip()
if hmac.compare_digest(token, secret_key):
if authorization_header:
scheme, _, value = authorization_header.partition(' ')
scheme_lower = scheme.lower()
token = value.strip() if value else scheme.strip()
if scheme_lower in ('bearer', 'token'):
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен %s webhook", scheme, display_name)
return False
if not value and hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный Bearer токен Mulen Pay webhook")
return False
fallback_token = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Token',
'X-Mulenpay-Token',
'X-Webhook-Token',
)
)
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.error("Отсутствует подпись Mulen Pay webhook")
logger.info(
"%s webhook headers received: %s",
display_name,
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
)
logger.error("Отсутствует подпись %s webhook", display_name)
return False
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
+283 -131
View File
@@ -1,12 +1,16 @@
import asyncio
import logging
import json
import hashlib
import hmac
import base64
from typing import Optional, Dict, Any
from ipaddress import (
IPv4Address,
IPv4Network,
IPv6Address,
IPv6Network,
ip_address,
ip_network,
)
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
@@ -15,155 +19,294 @@ from app.database.database import get_db
logger = logging.getLogger(__name__)
IPAddress = Union[IPv4Address, IPv6Address]
IPNetwork = Union[IPv4Network, IPv6Network]
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("185.71.76.0/27"),
ip_network("185.71.77.0/27"),
ip_network("77.75.153.0/25"),
ip_network("77.75.154.128/25"),
ip_network("77.75.156.11/32"),
ip_network("77.75.156.35/32"),
ip_network("2a02:5180::/32"),
)
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("173.245.48.0/20"),
ip_network("103.21.244.0/22"),
ip_network("103.22.200.0/22"),
ip_network("103.31.4.0/22"),
ip_network("141.101.64.0/18"),
ip_network("108.162.192.0/18"),
ip_network("190.93.240.0/20"),
ip_network("188.114.96.0/20"),
ip_network("197.234.240.0/22"),
ip_network("198.41.128.0/17"),
ip_network("162.158.0.0/15"),
ip_network("104.16.0.0/13"),
ip_network("104.24.0.0/14"),
ip_network("172.64.0.0/13"),
ip_network("131.0.72.0/22"),
ip_network("2400:cb00::/32"),
ip_network("2606:4700::/32"),
ip_network("2803:f800::/32"),
ip_network("2405:b500::/32"),
ip_network("2405:8100::/32"),
ip_network("2a06:98c0::/29"),
ip_network("2c0f:f248::/32"),
)
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
"payment.succeeded",
"payment.waiting_for_capture",
"payment.canceled",
)
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
candidates: List[str] = []
for value in values:
if not value:
continue
for part in value.split(","):
normalized = part.strip()
if normalized:
candidates.append(normalized)
return candidates
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
value = candidate.strip()
if not value:
return None
if value.startswith("[") and "]" in value:
value = value[1:value.index("]")]
if "%" in value:
value = value.split("%", 1)[0]
if value.count(":") == 1 and "." in value:
host, _, port = value.rpartition(":")
if port.isdigit():
value = host
try:
return ip_address(value)
except ValueError:
return None
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
if remote_ip is None:
return True
if _is_trusted_proxy_ip(remote_ip):
return True
return any(
getattr(remote_ip, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
)
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
global _TRUSTED_PROXY_NETWORKS_CACHE
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
if raw_value == cached_raw:
return cached_networks
networks: List[IPNetwork] = []
for part in raw_value.split(","):
candidate = part.strip()
if not candidate:
continue
try:
networks.append(ip_network(candidate, strict=False))
except ValueError:
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
cached_networks = tuple(networks)
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
return cached_networks
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
if any(
getattr(ip_object, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
):
return True
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
return True
return any(ip_object in network for network in _get_trusted_proxy_networks())
def resolve_yookassa_ip(
candidates: Iterable[str],
*,
remote: Optional[str] = None,
) -> Optional[IPAddress]:
remote_ip = _parse_candidate_ip(remote) if remote else None
if (
remote_ip is not None
and remote_ip.is_global
and not _is_trusted_proxy_ip(remote_ip)
):
return remote_ip
candidate_list = list(candidates)
if _should_trust_forwarded_headers(remote_ip):
last_hop = remote_ip
for candidate in reversed(candidate_list):
ip_object = _parse_candidate_ip(candidate)
if ip_object is not None:
if last_hop is None or _is_trusted_proxy_ip(last_hop):
if _is_trusted_proxy_ip(ip_object):
last_hop = ip_object
continue
return ip_object
break
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
return last_hop
return remote_ip if remote_ip is not None else next(
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
None,
)
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
try:
signature_parts = signature.strip().split(' ')
if len(signature_parts) < 4:
logger.error(f"Неверный формат подписи YooKassa: {signature}")
return False
version = signature_parts[0]
payment_id = signature_parts[1]
timestamp = signature_parts[2]
received_signature = signature_parts[3]
if version != "v1":
logger.error(f"Неподдерживаемая версия подписи: {version}")
return False
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
expected_signature_1 = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
expected_signature_2 = hmac.new(
secret.encode('utf-8'),
signed_payload_2.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
signed_payload_3 = f"{timestamp}.{body}"
expected_signature_3 = hmac.new(
secret.encode('utf-8'),
signed_payload_3.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
logger.debug(f"Получена подпись: {received_signature}")
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
is_valid = (
hmac.compare_digest(received_signature, expected_signature_1_b64) or
hmac.compare_digest(received_signature, expected_signature_2_b64) or
hmac.compare_digest(received_signature, expected_signature_3_b64)
)
if is_valid:
logger.info("✅ Подпись YooKassa webhook проверена успешно")
else:
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
return False
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
header_ip_candidates = collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
client_ip = resolve_yookassa_ip(
header_ip_candidates,
remote=request.remote,
)
if client_ip is None:
logger.warning(
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
header_ip_candidates + ([request.remote] if request.remote else []),
)
return web.Response(status=403, text="Forbidden")
if not is_yookassa_ip_allowed(client_ip):
logger.warning(
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
client_ip,
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
)
return web.Response(status=403, text="Forbidden")
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
body = await request.text()
if not body:
logger.warning("⚠️ Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
logger.info(f"📄 Body: {body}")
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
logger.info(f"🔐 Получена подпись: {signature}")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
else:
logger.info("✅ Подпись webhook проверена успешно")
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
else:
logger.info("ℹ️ Проверка подписи отключена")
if signature:
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment id")
if event_type not in YOOKASSA_ALLOWED_EVENTS:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async for db in get_db():
try:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id
existing_transaction = None
if yookassa_payment_id and hasattr(db, "execute"):
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
if existing_transaction and event_type == "payment.succeeded":
logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
return web.Response(status=200, text="OK")
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
@@ -172,7 +315,7 @@ class YooKassaWebhookHandler:
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
@@ -185,49 +328,58 @@ class YooKassaWebhookHandler:
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host='0.0.0.0',
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
logger.info(
" YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
)
logger.info(
"🎯 YooKassa webhook URL: http://%s:%s%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
@@ -237,7 +389,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
raise
+3
View File
@@ -0,0 +1,3 @@
from . import balance
__all__ = ["balance"]
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str:
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
else:
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
bonus_info = (
"📱 Подписка: <b>{days} д.</b>\n"
"🌐 Трафик: <b>{traffic}</b>\n"
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
).format(
days=campaign.subscription_duration_days or 0,
traffic=traffic_text,
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
devices=device_limit,
)
return (
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
campaign_edit_message_is_caption=is_caption,
)
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
current_devices = campaign.subscription_device_limit
if current_devices is None:
current_devices = settings.DEFAULT_DEVICE_LIMIT
await callback.message.edit_text(
(
File diff suppressed because it is too large Load Diff
+44 -31
View File
@@ -71,10 +71,10 @@ async def show_users_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"👥 **Управление пользователями и подписками**\n\n"
"Выберите нужный раздел:",
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -89,10 +89,10 @@ async def show_promo_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"💰 **Промокоды и статистика**\n\n"
"Выберите нужный раздел:",
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -107,10 +107,10 @@ async def show_communications_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"📨 **Коммуникации**\n\n"
"Управление рассылками и текстами интерфейса:",
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -128,16 +128,19 @@ async def show_support_submenu(
# Moderators have access only to tickets and not to settings
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
from app.keyboards.admin import get_admin_support_submenu_keyboard
kb = get_admin_support_submenu_keyboard(db_user.language)
if is_moderator_only:
# Rebuild keyboard to include only tickets and back to main menu
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
await callback.message.edit_text(
"🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"),
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
if is_moderator_only
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
),
reply_markup=kb,
parse_mode="Markdown"
)
@@ -150,12 +153,14 @@ async def show_moderator_panel(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")]
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
])
await callback.message.edit_text(
"🧑‍⚖️ <b>Модерация поддержки</b>\n\nДоступ к тикетам поддержки.",
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑‍⚖️ <b>Модерация поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
parse_mode="HTML",
reply_markup=kb
)
@@ -169,6 +174,7 @@ async def show_support_audit(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# pagination
page = 1
if callback.data.startswith("admin_support_audit_page_"):
@@ -186,18 +192,23 @@ async def show_support_audit(
offset = (page - 1) * per_page
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
lines = ["🧾 <b>Аудит модераторов</b>", ""]
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
if not logs:
lines.append("Пока пусто")
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
else:
for log in logs:
role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ"
role = (
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
if getattr(log, 'is_moderator', False)
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
)
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
action_map = {
'close_ticket': 'Закрытие тикета',
'block_user_timed': 'Блокировка (время)',
'block_user_perm': 'Блокировка (навсегда)',
'unblock_user': 'Снятие блока',
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"),
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
}
action_text = action_map.get(log.action, log.action)
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
@@ -205,6 +216,8 @@ async def show_support_audit(
extra = ""
if log.action == 'block_user_timed' and 'minutes' in details:
extra = f" ({details['minutes']} мин)"
elif log.action == 'close_all_tickets' and 'count' in details:
extra = f" ({details['count']})"
lines.append(f"{ts}{role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
# keyboard with pagination
@@ -219,7 +232,7 @@ async def show_support_audit(
kb_rows = []
if nav_row:
kb_rows.append(nav_row)
kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")])
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
@@ -234,10 +247,10 @@ async def show_settings_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"⚙️ **Настройки системы**\n\n"
"Управление Remnawave, мониторингом и другими настройками:",
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -252,10 +265,10 @@ async def show_system_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"🛠️ **Системные функции**\n\n"
"Отчеты, обновления, резервные копии и системные операции:",
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -436,4 +449,4 @@ def register_handlers(dp: Dispatcher):
dp.message.register(
admin_commands_help,
Command("admin_help")
)
)
+6 -1
View File
@@ -74,6 +74,7 @@ async def show_maintenance_panel(
{status_emoji} <b>Режим техработ:</b> {status_text}
{api_emoji} <b>API Remnawave:</b> {api_text}
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
🛠 <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
<b>Интервал проверки:</b> {status_info['check_interval']}с
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
{panel_info}
@@ -233,7 +234,11 @@ async def check_panel_status(
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
]
attempts_used = status_data.get("attempts_used")
if attempts_used:
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
if status_data.get("api_error"):
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
+194 -58
View File
@@ -3,8 +3,10 @@ import asyncio
from datetime import datetime, timedelta
from typing import Optional
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
@@ -16,25 +18,33 @@ 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,
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
BROADCAST_BUTTON_LABELS
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
get_broadcast_button_config, get_broadcast_button_labels
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
from app.database.crud.subscription import get_expiring_subscriptions
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
BUTTON_CONFIG = BROADCAST_BUTTONS
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
"balance",
"referrals",
"promocode",
"connect",
"subscription",
}
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
@@ -45,22 +55,31 @@ def get_updated_message_buttons_selector_keyboard(selected_buttons: list, langua
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
button_config_map = get_broadcast_button_config(language)
for row in BUTTON_ROWS:
row_buttons: list[types.InlineKeyboardButton] = []
for button_key in row:
if button_key not in selected_buttons:
continue
button_config = BUTTON_CONFIG[button_key]
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
callback_data=button_config["callback"]
button_config = button_config_map[button_key]
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
row_buttons.append(
build_miniapp_or_callback_button(
text=button_config["text"],
callback_data=button_config["callback"],
)
)
else:
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
callback_data=button_config["callback"]
)
)
)
if row_buttons:
keyboard.append(row_buttons)
@@ -70,6 +89,56 @@ def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKe
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(
@@ -628,7 +697,8 @@ async def confirm_button_selection(
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
button_labels = get_broadcast_button_labels(db_user.language)
selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
if selected_names:
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
else:
@@ -745,56 +815,105 @@ async def confirm_broadcast(
sent_count = 0
failed_count = 0
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
for user in users:
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
max_concurrent_sends = 5
per_message_delay = 0.05
semaphore = asyncio.Semaphore(max_concurrent_sends)
async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения"""
async with semaphore:
for attempt in range(3):
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
await asyncio.sleep(per_message_delay)
return True, user.telegram_id
except TelegramRetryAfter as e:
retry_delay = min(e.retry_after + 1, 30)
logger.warning(
f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
await asyncio.sleep(retry_delay)
except TelegramForbiddenError:
# Пользователь мог удалить бота или запретить сообщения
logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden")
return False, user.telegram_id
except TelegramBadRequest as e:
logger.error(
f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}"
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
return False, user.telegram_id
except Exception as e:
logger.error(
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
sent_count += 1
if sent_count % 20 == 0:
await asyncio.sleep(1)
except Exception as e:
failed_count += 1
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
await asyncio.sleep(0.5 * (attempt + 1))
return False, user.telegram_id
# Отправляем сообщения пакетами для эффективности
batch_size = 50
for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size]
tasks = [send_single_broadcast(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, tuple): # (success, telegram_id)
success, _ = result
if success:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на 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:
@@ -830,7 +949,24 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
async def get_target_users(db: AsyncSession, target: str) -> list:
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
# Загружаем всех активных пользователей батчами, чтобы не ограничиваться 10к
users: list[User] = []
offset = 0
batch_size = 5000
while True:
batch = await get_users_list(
db,
offset=offset,
limit=batch_size,
status=UserStatus.ACTIVE,
)
if not batch:
break
users.extend(batch)
offset += batch_size
if target == "all":
return users
+79 -10
View File
@@ -37,6 +37,9 @@ def _build_notification_settings_view(language: str):
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
trial_channel_status = _format_toggle(
config["trial_channel_unsubscribed"].get("enabled", True)
)
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
@@ -45,6 +48,7 @@ def _build_notification_settings_view(language: str):
"🔔 <b>Уведомления пользователям</b>\n\n"
f"• 1 час после триала: {trial_1h_status}\n"
f"• 24 часа после триала: {trial_24h_status}\n"
f"• Отписка от канала: {trial_channel_status}\n"
f"• 1 день после истечения: {expired_1d_status}\n"
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
f"{third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
@@ -57,6 +61,8 @@ def _build_notification_settings_view(language: str):
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
@@ -153,6 +159,36 @@ def _build_notification_preview_message(language: str, notification_type: str):
],
]
)
elif notification_type == "trial_channel_unsubscribed":
template = texts.get(
"TRIAL_CHANNEL_UNSUBSCRIBED",
(
"🚫 <b>Доступ приостановлен</b>\n\n"
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
),
)
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
message = template.format(check_button=check_button)
buttons: list[list[InlineKeyboardButton]] = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data="sub_channel_check",
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
elif notification_type == "expired_1d":
template = texts.get(
"SUBSCRIPTION_EXPIRED_1D",
@@ -190,18 +226,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
elif notification_type == "expired_2d":
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
trigger_days=3,
)
@@ -237,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
)
@@ -368,7 +400,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
🔧 Выберите действие:
"""
keyboard = get_monitoring_keyboard()
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
keyboard = get_monitoring_keyboard(language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -461,6 +494,27 @@ async def preview_trial_24h_notification(callback: CallbackQuery):
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
@admin_required
async def preview_trial_channel_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial channel preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
@admin_required
async def toggle_expired_1d_notification(callback: CallbackQuery):
@@ -533,6 +587,7 @@ async def preview_all_notifications(callback: CallbackQuery):
for notification_type in [
"trial_inactive_1h",
"trial_inactive_24h",
"trial_channel_unsubscribed",
"expired_1d",
"expired_2d",
"expired_nd",
@@ -972,6 +1027,20 @@ async def process_notification_value_input(message: Message, state: FSMContext):
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
# Добавляем дополнительные проверки диапазона значений
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
if value < 0 or value > 100:
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
return
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
await message.answer("❌ Количество часов должно быть от 1 до 168.")
return
elif key == "expired_third_wave" and field == "trigger":
if value < 2: # Минимум 2 дня
await message.answer("❌ Количество дней должно быть не менее 2.")
return
success = False
if key == "expired_second_wave" and field == "percent":
success = NotificationSettingsService.set_second_wave_discount_percent(value)
@@ -1020,4 +1089,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
def register_handlers(dp):
dp.include_router(router)
dp.include_router(router)
+612
View File
@@ -0,0 +1,612 @@
from __future__ import annotations
import html
import math
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, User
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
PendingPayment,
SUPPORTED_MANUAL_CHECK_METHODS,
get_payment_record,
list_recent_pending_payments,
run_manual_check,
)
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago, format_username
PAGE_SIZE = 6
def _method_display(method: PaymentMethod) -> str:
if method == PaymentMethod.MULENPAY:
return settings.get_mulenpay_display_name()
if method == PaymentMethod.PAL24:
return "PayPalych"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.HELEKET:
return "Heleket"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
return "Telegram Stars"
return method.value
def _status_info(
record: PendingPayment,
*,
texts,
) -> tuple[str, str]:
status = (record.status or "").lower()
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if record.method == PaymentMethod.PAL24:
mapping = {
"new": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"process": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"fail": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.MULENPAY:
mapping = {
"created": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.WATA:
mapping = {
"opened": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"closed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"declined": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.PLATEGA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"inprogress": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"confirmed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"failed": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancelled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.HELEKET:
if status in {"pending", "created", "waiting", "check", "processing"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
if status in {"paid", "paid_over"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
if record.method == PaymentMethod.YOOKASSA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"waiting_for_capture": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"succeeded": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.CRYPTOBOT:
mapping = {
"active": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.TELEGRAM_STARS:
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
def _is_checkable(record: PendingPayment) -> bool:
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
return False
if not record.is_recent():
return False
status = (record.status or "").lower()
if record.method == PaymentMethod.PAL24:
return status in {"new", "process"}
if record.method == PaymentMethod.MULENPAY:
return status in {"created", "processing", "hold"}
if record.method == PaymentMethod.WATA:
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
if record.method == PaymentMethod.PLATEGA:
return status in {"pending", "inprogress", "in_progress"}
if record.method == PaymentMethod.HELEKET:
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
if record.method == PaymentMethod.YOOKASSA:
return status in {"pending", "waiting_for_capture"}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {"active"}
return False
def _record_display_number(record: PendingPayment) -> str:
if record.identifier:
return str(record.identifier)
return str(record.local_id)
def _build_list_keyboard(
records: list[PendingPayment],
*,
page: int,
total_pages: int,
language: str,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
texts = get_texts(language)
for record in records:
number = _record_display_number(record)
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
try:
button_text = details_template.format(number=number)
except Exception: # pragma: no cover - fallback for broken localization
button_text = f"📄 {number}"
buttons.append(
[
InlineKeyboardButton(
text=button_text,
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
)
]
)
if total_pages > 1:
navigation_row: list[InlineKeyboardButton] = []
if page > 1:
navigation_row.append(
InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_payments_page_{page - 1}",
)
)
navigation_row.append(
InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data="admin_payments_page_current",
)
)
if page < total_pages:
navigation_row.append(
InlineKeyboardButton(
text="➡️",
callback_data=f"admin_payments_page_{page + 1}",
)
)
buttons.append(navigation_row)
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_detail_keyboard(
record: PendingPayment,
*,
language: str,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
rows: list[list[InlineKeyboardButton]] = []
payment = record.payment
payment_url = getattr(payment, "payment_url", None)
if record.method == PaymentMethod.PAL24:
payment_url = payment.link_url or payment.link_page_url or payment_url
elif record.method == PaymentMethod.WATA:
payment_url = payment.url or payment_url
elif record.method == PaymentMethod.YOOKASSA:
payment_url = getattr(payment, "confirmation_url", None) or payment_url
elif record.method == PaymentMethod.CRYPTOBOT:
payment_url = (
payment.bot_invoice_url
or payment.mini_app_invoice_url
or payment.web_app_invoice_url
or payment_url
)
if payment_url:
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
url=payment_url,
)
]
)
if _is_checkable(record):
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
)
]
)
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def _format_user_line(user: User) -> str:
username = format_username(user.username, user.telegram_id, user.full_name)
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
def _build_record_lines(
record: PendingPayment,
*,
index: int,
texts,
language: str,
) -> list[str]:
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
identifier = (
html.escape(str(record.identifier)) if record.identifier else ""
)
display_number = html.escape(_record_display_number(record))
lines = [
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
f" {emoji} {status_text}",
f" 🕒 {created} ({age})",
_format_user_line(record.user),
]
if identifier:
lines.append(f" 🆔 ID: <code>{identifier}</code>")
else:
lines.append(f" 🆔 ID: <code>{display_number}</code>")
return lines
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
raw_identifier = record.identifier if record.identifier else record.local_id
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else ""
lines = [
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
"",
f"<b>{html.escape(method_name)}</b>",
f"{emoji} {status_text}",
"",
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
f"🆔 ID: <code>{identifier}</code>",
_format_user_line(record.user),
]
if record.expires_at:
expires_at = format_datetime(record.expires_at)
lines.append(f"{texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
payment = record.payment
if record.method == PaymentMethod.PAL24:
if getattr(payment, "payment_status", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.payment_status))}"
)
if getattr(payment, "payment_method", None):
lines.append(
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method))}"
)
if getattr(payment, "balance_amount", None):
lines.append(
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
f"{html.escape(str(payment.balance_amount))}"
)
if getattr(payment, "payer_account", None):
lines.append(
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
f"{html.escape(str(payment.payer_account))}"
)
if record.method == PaymentMethod.MULENPAY:
if getattr(payment, "mulen_payment_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.mulen_payment_id))}"
)
if record.method == PaymentMethod.WATA:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "terminal_public_id", None):
lines.append(
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
)
if record.method == PaymentMethod.HELEKET:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
)
if record.method == PaymentMethod.YOOKASSA:
if getattr(payment, "payment_method_type", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method_type))}"
)
if getattr(payment, "confirmation_url", None):
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
if record.method == PaymentMethod.CRYPTOBOT:
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
)
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
lines.append(
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
)
if getattr(payment, "status", None):
lines.append(
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.status))}"
)
if record.method == PaymentMethod.TELEGRAM_STARS:
description = getattr(payment, "description", "") or ""
if description:
lines.append(f"📝 {html.escape(description)}")
if getattr(payment, "external_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.external_id))}"
)
if _is_checkable(record):
lines.append("")
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "️ You can trigger a manual status check."))
return "\n".join(lines)
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
suffix = payload[len(prefix) :]
try:
method_str, identifier = suffix.rsplit("_", 1)
method = PaymentMethod(method_str)
payment_id = int(identifier)
return method, payment_id
except (ValueError, KeyError):
return None
@admin_required
@error_handler
async def show_payments_overview(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
texts = get_texts(db_user.language)
page = 1
if callback.data.startswith("admin_payments_page_"):
try:
page = int(callback.data.split("_")[-1])
except ValueError:
page = 1
records = await list_recent_pending_payments(db)
total = len(records)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
if page < 1:
page = 1
if page > total_pages:
page = total_pages
start_index = (page - 1) * PAGE_SIZE
page_records = records[start_index : start_index + PAGE_SIZE]
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
description = texts.t(
"ADMIN_PAYMENTS_DESCRIPTION",
"Pending invoices created during the last 24 hours.",
)
notice = texts.t(
"ADMIN_PAYMENTS_NOTICE",
"Only invoices younger than 24 hours and waiting for payment can be checked.",
)
lines = [header, "", description]
if page_records:
for idx, record in enumerate(page_records, start=start_index + 1):
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
lines.append("")
lines.append(notice)
else:
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
lines.append("")
lines.append(empty_text)
keyboard = _build_list_keyboard(
page_records,
page=page,
total_pages=total_pages,
language=db_user.language,
)
await callback.message.edit_text(
"\n".join(line for line in lines if line is not None),
parse_mode="HTML",
reply_markup=keyboard,
)
await callback.answer()
async def _render_payment_details(
callback: types.CallbackQuery,
db_user: User,
record: PendingPayment,
) -> None:
texts = get_texts(db_user.language)
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
keyboard = _build_detail_keyboard(record, language=db_user.language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
@admin_required
@error_handler
async def show_payment_details(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
if not record:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
await _render_payment_details(callback, db_user, record)
await callback.answer()
@admin_required
@error_handler
async def manual_check_payment(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
texts = get_texts(db_user.language)
if not record:
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
return
if not _is_checkable(record):
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
show_alert=True,
)
return
payment_service = PaymentService(callback.bot)
updated = await run_manual_check(db, method, payment_id, payment_service)
if not updated:
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
show_alert=True,
)
return
await _render_payment_details(callback, db_user, updated)
if updated.status != record.status or updated.is_paid != record.is_paid:
emoji, status_text = _status_info(updated, texts=texts)
message = texts.t(
"ADMIN_PAYMENT_CHECK_SUCCESS",
"Status updated: {status}",
).format(status=f"{emoji} {status_text}")
else:
message = texts.t(
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
"Status is unchanged after the check.",
)
await callback.answer(message, show_alert=True)
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
dp.callback_query.register(
show_payment_details,
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
)
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.privacy_policy_service import PrivacyPolicyService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
has_content = bool(policy and policy.content and policy.content.strip())
description = texts.t(
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
"Политика конфиденциальности отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
"⚠️ Показ политики выключен или текст отсутствует.",
)
if policy and policy.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
"✅ Политика активна и показывается пользователям.",
)
elif policy and policy.is_enabled:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = policy.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PRIVACY_POLICY_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
actions_prompt = texts.t(
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_privacy_policy_view",
)
])
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
"✅ Включить показ",
)
if policy and policy.is_enabled:
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_privacy_policy_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
@admin_required
@error_handler
async def show_privacy_policy_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ политики конфиденциальности: %s",
db_user.telegram_id,
"enabled" if updated_policy.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
if updated_policy.is_enabled
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if policy and policy.content:
preview = policy.content.strip()[:400]
if len(policy.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
callback_data="admin_privacy_policy_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_privacy_policy)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def process_privacy_policy_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_TOO_LONG",
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст политики конфиденциальности (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PRIVACY_POLICY_SAVED",
"✅ Политика конфиденциальности обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
if not policy or not policy.content or not policy.content.strip():
await callback.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
"Текст политики пока не задан.",
),
show_alert=True,
)
return
content = policy.content.strip()
truncated = False
max_length = 3800
if len(content) > max_length:
content = content[: max_length - 3] + "..."
truncated = True
header = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
"👀 <b>Текущий текст политики</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{content}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_privacy_policy_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_privacy_policy.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_privacy_policy_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_privacy_policy_management,
F.data == "admin_privacy_policy",
)
dp.callback_query.register(
toggle_privacy_policy,
F.data == "admin_privacy_policy_toggle",
)
dp.callback_query.register(
start_edit_privacy_policy,
F.data == "admin_privacy_policy_edit",
)
dp.callback_query.register(
cancel_edit_privacy_policy,
F.data == "admin_privacy_policy_cancel",
)
dp.callback_query.register(
view_privacy_policy,
F.data == "admin_privacy_policy_view",
)
dp.callback_query.register(
show_privacy_policy_html_help,
F.data == "admin_privacy_policy_help",
)
dp.message.register(
process_privacy_policy_edit,
AdminStates.editing_privacy_policy,
)
+104 -1
View File
@@ -211,6 +211,14 @@ def _format_rubles(amount_kopeks: int) -> str:
return formatted.replace(",", " ")
def _format_priority_line(texts, group: PromoGroup) -> str:
priority = getattr(group, "priority", 0)
return texts.t(
"ADMIN_PROMO_GROUP_PRIORITY_LINE",
"🎯 Приоритет: {priority}",
).format(priority=priority)
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
@@ -294,6 +302,7 @@ def _build_edit_menu_content(
lines = [header]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_addon_discounts_line(texts, group))
lines.append(_format_priority_line(texts, group))
lines.append(_format_auto_assign_line(texts, group))
period_lines = _format_period_discounts_lines(texts, group, language)
@@ -318,6 +327,15 @@ def _build_edit_menu_content(
callback_data=f"promo_group_edit_field_{group.id}_name",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY",
"🎯 Приоритет",
),
callback_data=f"promo_group_edit_field_{group.id}_priority",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
@@ -640,6 +658,32 @@ async def process_create_group_name(message: types.Message, state: FSMContext):
return
await state.update_data(new_group_name=name)
await state.set_state(AdminStates.creating_promo_group_priority)
texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT",
"Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
)
)
async def process_create_group_priority(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
await state.update_data(new_group_priority=priority)
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
await _prompt_for_discount(
message,
@@ -772,6 +816,7 @@ async def process_create_group_auto_assign(
group = await create_promo_group(
db,
data["new_group_name"],
priority=data.get("new_group_priority", 0),
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
device_discount_percent=data["new_group_devices"],
@@ -862,6 +907,12 @@ async def prompt_edit_promo_group_field(
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
"Введите новое название промогруппы (текущее: {name}):",
).format(name=group.name)
elif field == "priority":
await state.set_state(AdminStates.editing_promo_group_priority)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT",
"Введите новый приоритет (текущий: {current}):",
).format(current=getattr(group, "priority", 0))
elif field == "traffic":
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
prompt = texts.t(
@@ -935,6 +986,48 @@ async def process_edit_group_name(
)
@admin_required
@error_handler
async def process_edit_group_priority(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, priority=priority)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_traffic(
@@ -1158,8 +1251,9 @@ async def show_promo_group_members(
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f"@{user.username}" if user.username else ""
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
lines.append(
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
)
body = "\n".join(lines)
@@ -1181,6 +1275,7 @@ async def show_promo_group_members(
await callback.message.edit_text(
f"{title}\n\n{body}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML",
)
await callback.answer()
@@ -1323,6 +1418,10 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
dp.message.register(
process_create_group_priority,
AdminStates.creating_promo_group_priority,
)
dp.message.register(
process_create_group_traffic,
AdminStates.creating_promo_group_traffic_discount,
@@ -1345,6 +1444,10 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
process_edit_group_priority,
AdminStates.editing_promo_group_priority,
)
dp.message.register(
process_edit_group_traffic,
AdminStates.editing_promo_group_traffic_discount,
File diff suppressed because it is too large Load Diff
+141 -42
View File
@@ -15,8 +15,9 @@ from app.localization.texts import get_texts
from app.database.crud.promocode import (
get_promocodes_list, get_promocodes_count, create_promocode,
get_promocode_statistics, get_promocode_by_code, update_promocode,
delete_promocode
delete_promocode, get_promocode_by_id
)
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime
@@ -81,16 +82,24 @@ async def show_promocodes_list(
for promo in promocodes:
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 Дней: {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
if promo.valid_until:
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
@@ -129,15 +138,20 @@ async def show_promocode_management(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text = f"""
🎫 <b>Управление промокодом</b>
@@ -145,12 +159,17 @@ async def show_promocode_management(
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
"""
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
elif promo.promo_group_id:
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
if promo.valid_until:
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
@@ -200,8 +219,8 @@ async def show_promocode_edit_menu(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -445,13 +464,14 @@ async def select_promocode_type(
state: FSMContext
):
promo_type = callback.data.split('_')[-1]
type_names = {
"balance": "💰 Пополнение баланса",
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка"
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка",
"group": "🏷️ Промогруппа"
}
await state.update_data(promocode_type=promo_type)
await callback.message.edit_text(
@@ -509,6 +529,77 @@ async def process_promocode_code(
f"Введите количество дней тестовой подписки:"
)
await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "group":
# Show promo group selection
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
if not groups_with_counts:
await message.answer(
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
])
)
await state.clear()
return
keyboard = []
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
for promo_group, user_count in groups_with_counts:
text += f"{promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
keyboard.append([
types.InlineKeyboardButton(
text=f"{promo_group.name} (↑{promo_group.priority})",
callback_data=f"promo_select_group_{promo_group.id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
])
await message.answer(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await state.set_state(AdminStates.selecting_promo_group)
@admin_required
@error_handler
async def process_promo_group_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
"""Handle promo group selection for promocode"""
try:
promo_group_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
return
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return
await state.update_data(
promo_group_id=promo_group_id,
promo_group_name=promo_group.name
)
await callback.message.edit_text(
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
f"Промогруппа: {promo_group.name}\n"
f"Приоритет: {promo_group.priority}\n\n"
f"📊 Введите количество использований промокода (или 0 для безлимита):"
)
await state.set_state(AdminStates.setting_promocode_uses)
await callback.answer()
@admin_required
@@ -557,8 +648,8 @@ async def handle_edit_value(
data = await state.get_data()
promo_id = data.get('editing_promo_id')
edit_action = data.get('edit_action')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -643,8 +734,8 @@ async def handle_edit_uses(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -708,17 +799,20 @@ async def process_promocode_expiry(
promo_type = data.get('promocode_type')
value = data.get('promocode_value', 0)
max_uses = data.get('promocode_max_uses', 1)
promo_group_id = data.get('promo_group_id')
promo_group_name = data.get('promo_group_name')
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
type_map = {
"balance": PromoCodeType.BALANCE,
"days": PromoCodeType.SUBSCRIPTION_DAYS,
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
"group": PromoCodeType.PROMO_GROUP
}
promocode = await create_promocode(
db=db,
code=code,
@@ -727,27 +821,31 @@ async def process_promocode_expiry(
subscription_days=value if promo_type in ["days", "trial"] else 0,
max_uses=max_uses,
valid_until=valid_until,
created_by=db_user.id
created_by=db_user.id,
promo_group_id=promo_group_id if promo_type == "group" else None
)
type_names = {
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка"
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка",
"group": "Промогруппа"
}
summary_text = f"""
<b>Промокод создан!</b>
🎫 <b>Код:</b> <code>{promocode.code}</code>
📝 <b>Тип:</b> {type_names.get(promo_type)}
"""
if promo_type == "balance":
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
elif promo_type in ["days", "trial"]:
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
elif promo_type == "group" and promo_group_name:
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
if promocode.valid_until:
@@ -775,8 +873,8 @@ async def handle_edit_expiry(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -822,8 +920,8 @@ async def toggle_promocode_status(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -849,8 +947,8 @@ async def confirm_delete_promocode(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -897,8 +995,8 @@ async def delete_promocode_confirmed(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -921,8 +1019,8 @@ async def show_promocode_stats(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
+530
View File
@@ -0,0 +1,530 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.public_offer_service import PublicOfferService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
normalized_language = PublicOfferService.normalize_language(db_user.language)
has_content = bool(offer and offer.content and offer.content.strip())
description = texts.t(
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
"Публичная оферта отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
"⚠️ Показ оферты выключен или текст отсутствует.",
)
if offer and offer.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
"✅ Оферта активна и показывается пользователям.",
)
elif offer and offer.is_enabled:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = offer.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PUBLIC_OFFER_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
actions_prompt = texts.t(
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_public_offer_view",
)
])
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
"✅ Включить показ",
)
if offer and offer.is_enabled:
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_public_offer_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
@admin_required
@error_handler
async def show_public_offer_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ публичной оферты: %s",
db_user.telegram_id,
"enabled" if updated_offer.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
if updated_offer.is_enabled
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if offer and offer.content:
preview = offer.content.strip()[:400]
if len(offer.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
callback_data="admin_public_offer_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_public_offer)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
"Редактирование оферты отменено.",
)
)
@admin_required
@error_handler
async def process_public_offer_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_TOO_LONG",
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PublicOfferService.save_offer(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст публичной оферты (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PUBLIC_OFFER_SAVED",
"✅ Публичная оферта обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
if not offer or not offer.content or not offer.content.strip():
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
content = offer.content.strip()
max_length = 3800
pages = PublicOfferService.split_content_into_pages(
content,
max_length=max_length,
)
if not pages:
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
preview = pages[0]
truncated = len(pages) > 1
header = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
"👀 <b>Текущий текст оферты</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{preview}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_public_offer_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_public_offer.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_public_offer_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_public_offer_management,
F.data == "admin_public_offer",
)
dp.callback_query.register(
toggle_public_offer,
F.data == "admin_public_offer_toggle",
)
dp.callback_query.register(
start_edit_public_offer,
F.data == "admin_public_offer_edit",
)
dp.callback_query.register(
cancel_edit_public_offer,
F.data == "admin_public_offer_cancel",
)
dp.callback_query.register(
view_public_offer,
F.data == "admin_public_offer_view",
)
dp.callback_query.register(
show_public_offer_html_help,
F.data == "admin_public_offer_help",
)
dp.message.register(
process_public_offer_edit,
AdminStates.editing_public_offer,
)
-3
View File
@@ -68,7 +68,6 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
@@ -105,7 +104,6 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
@@ -190,7 +188,6 @@ async def show_referral_settings(
Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
File diff suppressed because it is too large Load Diff
+198 -4
View File
@@ -1,3 +1,4 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -15,6 +16,7 @@ from app.database.crud.server_squad import (
create_server_squad,
get_available_server_squads,
update_server_squad_promo_groups,
get_server_connected_users,
)
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.services.remnawave_service import RemnaWaveService
@@ -33,6 +35,8 @@ def _build_server_edit_view(server):
else "Не выбраны"
)
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
text = f"""
🌐 <b>Редактирование сервера</b>
@@ -49,6 +53,7 @@ def _build_server_edit_view(server):
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
Промогруппы: {promo_groups_text}
Выдача триала: {trial_status}
<b>Описание:</b>
{server.description or 'Не указано'}
@@ -73,6 +78,17 @@ def _build_server_edit_view(server):
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад",
callback_data=f"admin_server_trial_{server.id}",
),
],
[
types.InlineKeyboardButton(
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
@@ -164,7 +180,7 @@ async def show_servers_menu(
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
]
@@ -276,7 +292,7 @@ async def sync_servers_with_remnawave(
)
return
created, updated, disabled = await sync_with_remnawave(db, squads)
created, updated, removed = await sync_with_remnawave(db, squads)
await cache.delete_pattern("available_countries*")
@@ -286,7 +302,7 @@ async def sync_servers_with_remnawave(
📊 <b>Результаты:</b>
Создано новых серверов: {created}
Обновлено существующих: {updated}
Отключено неактивных: {disabled}
Удалено отсутствующих: {removed}
Всего обработано: {len(squads)}
Новые серверы созданы как недоступные.
@@ -314,7 +330,7 @@ async def sync_servers_with_remnawave(
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
await callback.answer()
@@ -343,6 +359,150 @@ async def show_server_edit_menu(
await callback.answer()
@admin_required
@error_handler
async def show_server_users(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
payload = callback.data.split("admin_server_users_", 1)[-1]
payload_parts = payload.split("_")
server_id = int(payload_parts[0])
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
page = max(page, 1)
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
users = await get_server_connected_users(db, server_id)
total_users = len(users)
page_size = 10
total_pages = max((total_users + page_size - 1) // page_size, 1)
if page > total_pages:
page = total_pages
start_index = (page - 1) * page_size
end_index = start_index + page_size
page_users = users[start_index:end_index]
safe_name = html.escape(server.display_name or "")
safe_uuid = html.escape(server.squad_uuid or "")
header = [
"🌐 <b>Пользователи сервера</b>",
"",
f"• Сервер: {safe_name}",
f"• UUID: <code>{safe_uuid}</code>",
f"• Подключений: {total_users}",
]
if total_pages > 1:
header.append(f"• Страница: {page}/{total_pages}")
header.append("")
text = "\n".join(header)
def _get_status_icon(status_text: str) -> str:
if not status_text:
return ""
parts = status_text.split(" ", 1)
return parts[0] if parts else status_text
if users:
lines = []
for index, user in enumerate(page_users, start=start_index + 1):
safe_user_name = html.escape(user.full_name)
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
lines.append(f"{index}. {user_link}")
text += "\n" + "\n".join(lines)
else:
text += "Пользователи не найдены."
keyboard: list[list[types.InlineKeyboardButton]] = []
for user in page_users:
display_name = user.full_name
if len(display_name) > 30:
display_name = display_name[:27] + "..."
subscription_status = (
user.subscription.status_display
if user.subscription
else "❌ Нет подписки"
)
status_icon = _get_status_icon(subscription_status)
if status_icon:
button_text = f"{status_icon} {display_name}"
else:
button_text = display_name
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_manage_{user.id}",
)
])
if total_pages > 1:
navigation_buttons: list[types.InlineKeyboardButton] = []
if page > 1:
navigation_buttons.append(
types.InlineKeyboardButton(
text="⬅️ Предыдущая",
callback_data=f"admin_server_users_{server_id}_{page - 1}",
)
)
navigation_buttons.append(
types.InlineKeyboardButton(
text=f"Стр. {page}/{total_pages}",
callback_data=f"admin_server_users_{server_id}_{page}",
)
)
if page < total_pages:
navigation_buttons.append(
types.InlineKeyboardButton(
text="Следующая ➡️",
callback_data=f"admin_server_users_{server_id}_{page + 1}",
)
)
keyboard.append(navigation_buttons)
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
)
])
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К списку", callback_data="admin_servers_list"
)
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def toggle_server_availability(
@@ -377,6 +537,38 @@ async def toggle_server_availability(
)
@admin_required
@error_handler
async def toggle_server_trial_assignment(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
new_status = not server.is_trial_eligible
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = "будет выдаваться" if new_status else "перестанет выдаваться"
await callback.answer(f"✅ Сквад {status_text} в триал")
server = await get_server_squad_by_id(db, server_id)
text, keyboard = _build_server_edit_view(server)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
@admin_required
@error_handler
async def start_server_edit_price(
@@ -1105,6 +1297,8 @@ def register_handlers(dp: Dispatcher):
& ~F.data.contains("promo"),
)
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
-56
View File
@@ -4,7 +4,6 @@ from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.config import settings
from app.states import AdminStates
from app.database.models import User
from app.keyboards.admin import get_admin_subscriptions_keyboard
@@ -87,10 +86,6 @@ async def show_subscriptions_menu(
],
[
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
],
[
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
],
[
@@ -277,56 +272,6 @@ async def show_subscriptions_stats(
@admin_required
@error_handler
async def show_pricing_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = f"""
<b>Настройки цен</b>
<b>Периоды подписки:</b>
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
<b>Трафик-пакеты:</b>
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
<b>Дополнительно:</b>
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
"""
keyboard = [
# [
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
# ],
# [
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
# ],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_countries_management(
callback: types.CallbackQuery,
db_user: User,
@@ -487,7 +432,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
+110 -79
View File
@@ -4,6 +4,7 @@ import html
import contextlib
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
@@ -28,33 +29,63 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
rows: list[list[types.InlineKeyboardButton]] = []
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
def mode_button(label_key: str, default: str, active: bool) -> str:
prefix = "🔘" if active else ""
return f"{prefix} {texts.t(label_key, default)}"
rows.append([
types.InlineKeyboardButton(
text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"),
text=(
f"{'' if menu_enabled else '🚫'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
),
callback_data="admin_support_toggle_menu"
)
])
rows.append([
types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"),
types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"),
types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
callback_data="admin_support_mode_tickets"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
callback_data="admin_support_mode_contact"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
callback_data="admin_support_mode_both"
),
])
rows.append([
types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
callback_data="admin_support_edit_desc"
)
])
# Notifications block
rows.append([
types.InlineKeyboardButton(
text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"),
text=(
f"{'🔔' if admin_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
f"{status_enabled if admin_notif else status_disabled}"
),
callback_data="admin_support_toggle_admin_notifications"
)
])
rows.append([
types.InlineKeyboardButton(
text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"),
text=(
f"{'🔔' if user_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
f"{status_enabled if user_notif else status_disabled}"
),
callback_data="admin_support_toggle_user_notifications"
)
])
@@ -62,13 +93,17 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
# SLA block
rows.append([
types.InlineKeyboardButton(
text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"),
text=(
f"{'' if sla_enabled else '⏹️'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
f"{status_enabled if sla_enabled else status_disabled}"
),
callback_data="admin_support_toggle_sla"
)
])
rows.append([
types.InlineKeyboardButton(
text=f"⏳ Время SLA: {sla_minutes} мин",
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
callback_data="admin_support_set_sla_minutes"
)
])
@@ -78,15 +113,18 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
mod_count = len(moderators)
rows.append([
types.InlineKeyboardButton(
text=f"🧑‍⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators"
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑‍⚖️ Модераторы: {count}").format(count=mod_count),
callback_data="admin_support_list_moderators"
)
])
rows.append([
types.InlineKeyboardButton(
text="➕ Назначить модератора", callback_data="admin_support_add_moderator"
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
callback_data="admin_support_add_moderator"
),
types.InlineKeyboardButton(
text="➖ Удалить модератора", callback_data="admin_support_remove_moderator"
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
callback_data="admin_support_remove_moderator"
)
])
@@ -107,8 +145,8 @@ async def show_support_settings(
texts = get_texts(db_user.language)
desc = SupportSettingsService.get_support_info_text(db_user.language)
await callback.message.edit_text(
"🛟 <b>Настройки поддержки</b>\n\n" +
"Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" +
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
desc,
reply_markup=_get_support_settings_keyboard(db_user.language),
parse_mode="HTML"
@@ -152,24 +190,6 @@ async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSess
await show_support_settings(callback, db_user, db)
from app.states import SupportSettingsStates
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
await callback.message.edit_text(
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below
# we'll manage separate state below
from aiogram.fsm.state import State, StatesGroup
class SupportAdvancedStates(StatesGroup):
waiting_for_sla_minutes = State()
waiting_for_moderator_id = State()
@@ -178,11 +198,15 @@ class SupportAdvancedStates(StatesGroup):
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
texts.t(
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
@@ -192,63 +216,53 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db
@admin_required
@error_handler
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
text = (message.text or "").strip()
try:
minutes = int(text)
if minutes <= 0 or minutes > 1440:
raise ValueError()
except Exception:
await message.answer("❌ Введите корректное число минут (1-1440)")
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
return
SupportSettingsService.set_sla_minutes(minutes)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer("✅ Значение SLA сохранено", reply_markup=markup)
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
@admin_required
@error_handler
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
texts.t(
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
await callback.answer()
@admin_required
@error_handler
async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer("❌ Введите корректный Telegram ID (число)")
return
if SupportSettingsService.add_moderator(tid):
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup)
else:
await message.answer("❌ Не удалось сохранить")
await state.clear()
@admin_required
@error_handler
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
texts.t(
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
@@ -260,24 +274,32 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d
@admin_required
@error_handler
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
data = await state.get_data()
action = data.get("action", "add")
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer("❌ Введите корректный Telegram ID (число)")
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
return
ok = False
if action == "remove_moderator":
ok = SupportSettingsService.remove_moderator(tid)
msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить"
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
)
else:
ok = SupportSettingsService.add_moderator(tid)
msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить"
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(msg, reply_markup=markup)
@@ -285,13 +307,17 @@ async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSe
@admin_required
@error_handler
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
moderators = SupportSettingsService.get_moderators()
if not moderators:
await callback.answer("Список пуст", show_alert=True)
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
return
text = "🧑‍⚖️ <b>Модераторы</b>\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
text = (
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑‍⚖️ <b>Модераторы</b>") +
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
)
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
await callback.answer()
@@ -328,7 +354,10 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
kb_rows: list[list[types.InlineKeyboardButton]] = []
kb_rows.append([
types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc")
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
callback_data="admin_support_send_desc"
)
])
# Подготовим блок контакта (отдельным инлайном)
from app.config import settings
@@ -338,19 +367,19 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
])
text_parts = [
"📝 <b>Редактирование описания поддержки</b>",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
"",
"Текущее описание:",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
"",
f"<code>{html.escape(current_desc_plain)}</code>",
]
if support_contact_display:
text_parts += [
"",
"<b>Контакт для режима \u00abКонтакт\u00bb</b>",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
f"<code>{html.escape(support_contact_display)}</code>",
"",
"Добавьте в описание при необходимости.",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
]
await callback.message.edit_text(
"\n".join(text_parts),
@@ -364,24 +393,26 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
@admin_required
@error_handler
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
new_text = message.html_text or message.text
SupportSettingsService.set_support_info_text(db_user.language, new_text)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer("✅ Описание обновлено.", reply_markup=markup)
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
@admin_required
@error_handler
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# send plain text for easy copying
texts = get_texts(db_user.language)
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
# attach delete button to the sent message
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
if len(current_desc_plain) <= 4000:
await callback.message.answer(current_desc_plain, reply_markup=markup)
@@ -393,7 +424,7 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async
is_last = (chunk + 4000) >= len(current_desc_plain)
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
chunk += 4000
await callback.answer("Текст отправлен ниже")
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
@error_handler
@@ -403,15 +434,15 @@ async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db:
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
except Exception:
may_delete = False
texts = get_texts(db_user.language if db_user else 'ru')
if not may_delete:
texts = get_texts(db_user.language if db_user else 'ru')
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
try:
await callback.message.delete()
finally:
with contextlib.suppress(Exception):
await callback.answer("Сообщение удалено")
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
def register_handlers(dp: Dispatcher):
+165
View File
@@ -0,0 +1,165 @@
import logging
from datetime import datetime
from html import escape
from pathlib import Path
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
LOG_PREVIEW_LIMIT = 2300
def _resolve_log_path() -> Path:
log_path = Path(settings.LOG_FILE)
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
return log_path
def _format_preview_block(text: str) -> str:
escaped_text = escape(text) if text else ""
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
def _build_logs_message(log_path: Path) -> str:
if not log_path.exists():
message = (
"🧾 <b>Системные логи</b>\n\n"
f"Файл <code>{log_path}</code> пока не создан.\n"
"Логи появятся автоматически после первой записи."
)
return message
try:
content = log_path.read_text(encoding="utf-8", errors="ignore")
except Exception as error: # pragma: no cover - защита от проблем чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
message = (
"❌ <b>Ошибка чтения логов</b>\n\n"
f"Не удалось прочитать файл <code>{log_path}</code>."
)
return message
total_length = len(content)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime)
if not content:
preview_text = "Лог-файл пуст."
truncated = False
else:
preview_text = content[-LOG_PREVIEW_LIMIT:]
truncated = total_length > LOG_PREVIEW_LIMIT
details_lines = [
"🧾 <b>Системные логи</b>",
"",
f"📁 <b>Файл:</b> <code>{log_path}</code>",
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
f"🧮 <b>Размер:</b> {total_length} символов",
(
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
if truncated
else "📄 Показано все содержимое файла."
),
"",
_format_preview_block(preview_text),
]
return "\n".join(details_lines)
def _get_logs_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
]
)
@admin_required
@error_handler
async def show_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer()
@admin_required
@error_handler
async def refresh_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("🔄 Обновлено")
@admin_required
@error_handler
async def download_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
if not log_path.exists() or not log_path.is_file():
await callback.answer("❌ Лог-файл не найден", show_alert=True)
return
try:
await callback.answer("⬇️ Отправляю лог...")
document = FSInputFile(log_path)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
caption = (
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
f"📁 Путь: <code>{log_path}</code>\n"
f"🕒 Обновлен: {updated_at}"
)
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
except Exception as error: # pragma: no cover - защита от ошибок отправки
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
await callback.message.answer(
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
"Проверьте журналы приложения или повторите попытку позже.",
parse_mode="HTML",
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_system_logs,
F.data == "admin_system_logs",
)
dp.callback_query.register(
refresh_system_logs,
F.data == "admin_system_logs_refresh",
)
dp.callback_query.register(
download_system_logs,
F.data == "admin_system_logs_download",
)
+235 -28
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
from datetime import datetime, timedelta
import time
import html
from app.database.models import User, Ticket, TicketStatus
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
@@ -79,12 +80,16 @@ async def show_admin_tickets(
ticket_data = []
for ticket in tickets:
user_name = ticket.user.full_name if ticket.user else "Unknown"
username = ticket.user.username if ticket.user else None
telegram_id = ticket.user.telegram_id if ticket.user else None
ticket_data.append({
'id': ticket.id,
'title': ticket.title,
'status_emoji': ticket.status_emoji,
'priority_emoji': ticket.priority_emoji,
'user_name': user_name,
'username': username,
'telegram_id': telegram_id,
'is_closed': ticket.is_closed,
'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "")
})
@@ -169,9 +174,25 @@ async def view_admin_ticket(
}.get(ticket.status, ticket.status)
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_value = ticket.user.username if ticket.user else None
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
if username_value:
safe_username = html.escape(username_value)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
if ticket.user and ticket.user.telegram_id:
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
@@ -201,6 +222,29 @@ async def view_admin_ticket(
db_user.language,
is_user_blocked=ticket.is_user_reply_blocked
)
# Кнопка открытия профиля пользователя в админке
try:
if ticket.user:
admin_profile_btn = types.InlineKeyboardButton(
text="👤 К пользователю",
callback_data=f"admin_user_manage_{ticket.user.id}_from_ticket_{ticket.id}"
)
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id and ticket.user.username:
safe_username = html.escape(ticket.user.username)
buttons_row = []
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
keyboard.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
if has_photos:
try:
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
@@ -336,7 +380,7 @@ async def handle_admin_ticket_reply(
return
# Обычный режим ответа админа
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
texts = get_texts(db_user.language)
await message.answer(
@@ -426,6 +470,81 @@ async def mark_ticket_as_answered(
)
async def close_all_open_admin_tickets(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Закрыть все открытые тикеты."""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
texts = get_texts(db_user.language)
try:
closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db)
except Exception as error:
logger.error("Error closing all open tickets: %s", error)
await callback.answer(
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
show_alert=True
)
return
closed_count = len(closed_ticket_ids)
if closed_count == 0:
await callback.answer(
texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."),
show_alert=True
)
return
try:
is_moderator = (
not settings.is_admin(callback.from_user.id)
and SupportSettingsService.is_moderator(callback.from_user.id)
)
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
actor_telegram_id=callback.from_user.id,
is_moderator=is_moderator,
action="close_all_tickets",
ticket_id=None,
target_user_id=None,
details={
"count": closed_count,
"ticket_ids": closed_ticket_ids,
}
)
except Exception as audit_error:
logger.warning("Failed to add support audit for bulk close: %s", audit_error)
# Обновляем список тикетов
await show_admin_tickets(callback, db_user, db)
success_text = texts.t(
"ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS",
"✅ Закрыто открытых тикетов: {count}"
).format(count=closed_count)
notification_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
try:
await callback.message.answer(success_text, reply_markup=notification_keyboard)
except Exception:
# Если не удалось отправить отдельное сообщение, пробуем ответить алертом
try:
await callback.answer(success_text, show_alert=True)
except Exception:
pass
async def close_admin_ticket(
callback: types.CallbackQuery,
db_user: User,
@@ -445,6 +564,17 @@ async def close_admin_ticket(
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
# обогатим details контактами пользователя тикета
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -453,7 +583,7 @@ async def close_admin_ticket(
action="close_ticket",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
@@ -629,7 +759,21 @@ async def handle_admin_block_duration_input(
ticket_text += f"📝 Заголовок: {updated.title}\n"
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n"
if updated.user and updated.user.telegram_id:
ticket_text += f"🆔 Telegram ID: <code>{updated.user.telegram_id}</code>\n"
if updated.user.username:
safe_username = html.escape(updated.user.username)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
chat_link = f"tg://user?id={int(updated.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
if updated.is_user_reply_blocked:
if updated.user_reply_block_permanent:
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
@@ -645,6 +789,29 @@ async def handle_admin_block_duration_input(
ticket_text += "📎 Вложение: фото\n\n"
kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked)
# Кнопка открытия профиля пользователя в админке
try:
if updated.user:
admin_profile_btn = types.InlineKeyboardButton(
text="👤 К пользователю",
callback_data=f"admin_user_manage_{updated.user.id}_from_ticket_{updated.id}"
)
kb.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль при обновлении карточки
try:
if updated.user and updated.user.telegram_id and updated.user.username:
safe_username = html.escape(updated.user.username)
buttons_row = []
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
profile_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
kb.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or [])
if has_photos:
try:
@@ -699,6 +866,16 @@ async def unblock_user_in_ticket(
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -707,7 +884,7 @@ async def unblock_user_in_ticket(
action="unblock_user",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
@@ -741,6 +918,16 @@ async def block_user_permanently(
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -749,7 +936,7 @@ async def block_user_permanently(
action="block_user_perm",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
@@ -768,52 +955,71 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
except Exception:
pass
from app.localization.texts import get_texts
# Получаем тикет с пользователем
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
if not ticket_with_user or not ticket_with_user.user:
# Обеспечим наличие данных пользователя в объекте тикета
ticket_with_user = ticket
if not getattr(ticket_with_user, "user", None):
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
user = getattr(ticket_with_user, "user", None)
if not user:
logger.error(f"User not found for ticket #{ticket.id}")
return
texts = get_texts(ticket_with_user.user.language)
if not getattr(user, "telegram_id", None):
logger.error(
"Cannot notify ticket #%s user without telegram_id (username=%s)",
ticket.id,
getattr(user, "username", None),
)
return
chat_id = int(user.telegram_id)
texts = get_texts(user.language)
# Формируем уведомление
base_text = texts.t(
"TICKET_REPLY_NOTIFICATION",
"TICKET_REPLY_NOTIFICATION",
"🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:"
).format(
ticket_id=ticket.id,
reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
])
# Если было фото в последнем ответе админа — отправим как фото
last_message = await TicketMessageCRUD.get_last_message(db, ticket.id)
if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin:
caption = base_text
try:
await bot.send_photo(
chat_id=ticket_with_user.user.telegram_id,
chat_id=chat_id,
photo=last_message.media_file_id,
caption=caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
])
reply_markup=keyboard,
)
return
except TelegramBadRequest as photo_error:
logger.error(
"Не удалось отправить фото-уведомление пользователю %s для тикета %s: %s",
chat_id,
ticket.id,
photo_error,
)
except Exception as e:
logger.error(f"Не удалось отправить фото-уведомление: {e}")
# Фоллбек: текстовое уведомление
await bot.send_message(
chat_id=ticket_with_user.user.telegram_id,
chat_id=chat_id,
text=base_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
])
reply_markup=keyboard,
)
logger.info(f"Ticket #{ticket.id} reply notification sent to user {ticket_with_user.user.telegram_id}")
logger.info(f"Ticket #{ticket.id} reply notification sent to user {chat_id}")
except Exception as e:
logger.error(f"Error notifying user about ticket reply: {e}")
@@ -825,7 +1031,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
# Ответы на тикеты
+86
View File
@@ -0,0 +1,86 @@
import logging
from aiogram import Dispatcher, F, types
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.subscription import (
get_trial_statistics,
reset_trials_for_users_without_paid_subscription,
)
from app.database.models import User
from app.keyboards.admin import get_admin_trials_keyboard
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_trials_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
stats = await get_trial_statistics(db)
message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t(
"ADMIN_TRIALS_STATS",
"• Использовано всего: {used}\n"
"• Активно сейчас: {active}\n"
"• Доступно к сбросу: {resettable}",
).format(
used=stats.get("used_trials", 0),
active=stats.get("active_trials", 0),
resettable=stats.get("resettable_trials", 0),
)
await callback.message.edit_text(
message,
reply_markup=get_admin_trials_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def reset_trials(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
reset_count = await reset_trials_for_users_without_paid_subscription(db)
stats = await get_trial_statistics(db)
message = texts.t(
"ADMIN_TRIALS_RESET_RESULT",
"♻️ Сбросили {reset_count} триалов.\n\n"
"• Использовано всего: {used}\n"
"• Активно сейчас: {active}\n"
"• Доступно к сбросу: {resettable}",
).format(
reset_count=reset_count,
used=stats.get("used_trials", 0),
active=stats.get("active_trials", 0),
resettable=stats.get("resettable_trials", 0),
)
await callback.message.edit_text(
message,
reply_markup=get_admin_trials_keyboard(db_user.language),
)
await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен"))
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_trials_panel,
F.data == "admin_trials",
)
dp.callback_query.register(
reset_trials,
F.data == "admin_trials_reset",
)
+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"
)
+1985 -164
View File
File diff suppressed because it is too large Load Diff
+123 -31
View File
@@ -1,9 +1,9 @@
import logging
import re
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.states import AdminStates
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
@@ -19,6 +19,92 @@ from app.database.crud.welcome_text import (
logger = logging.getLogger(__name__)
def validate_html_tags(text: str) -> tuple[bool, str]:
"""
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
Args:
text: Текст для проверки
Returns:
Кортеж из (валидно ли, сообщение об ошибке или None)
"""
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
allowed_tags = {
'b', 'strong', # жирный
'i', 'em', # курсив
'u', 'ins', # подчеркнуто
's', 'strike', 'del', # зачеркнуто
'code', # моноширинный для коротких фрагментов
'pre', # моноширинный блок кода
'a' # ссылки
}
# Убираем плейсхолдеры из строки перед проверкой тегов
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
placeholder_pattern = r'\{[^{}]+\}'
clean_text = re.sub(placeholder_pattern, '', text)
# Находим все открывающие и закрывающие теги
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
# Проверяем, является ли тег поддерживаемым
if tag_lower not in allowed_tags:
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
# Проверяем атрибуты для тега <a>
if tag_lower == 'a':
if closing:
continue # Для закрывающего тега не нужно проверять атрибуты
if not attrs:
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем, что есть атрибут href
if 'href=' not in attrs.lower():
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем формат URL
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
if href_match:
url = href_match.group(1)
# Проверяем, что URL начинается с поддерживаемой схемы
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
else:
return False, "Не удалось извлечь URL из атрибута href тега <a>"
# Проверяем парность тегов с использованием стека
stack = []
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
if tag_lower not in allowed_tags:
continue
if closing:
# Это закрывающий тег
if not stack:
return False, f"Лишний закрывающий тег: </{tag}>"
last_opening_tag = stack.pop()
if last_opening_tag.lower() != tag_lower:
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
else:
# Это открывающий тег
stack.append(tag)
# Если остались незакрытые теги
if stack:
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
return False, f"Незакрытые теги: {unclosed_tags}"
return True, None
def get_telegram_formatting_info() -> str:
return """
📝 <b>Поддерживаемые теги форматирования:</b>
@@ -44,16 +130,16 @@ async def show_welcome_text_panel(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"👋 Управление приветственным текстом\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
f"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -89,11 +175,11 @@ async def show_current_welcome_text(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
is_enabled = settings['is_enabled']
if not settings['id']:
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
is_enabled = welcome_settings['is_enabled']
if not welcome_settings['id']:
status = "📝 Используется стандартный текст:"
else:
status = "📝 Текущий приветственный текст:"
@@ -121,7 +207,7 @@ async def show_placeholders_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
@@ -137,7 +223,7 @@ async def show_placeholders_help(
await callback.message.edit_text(
help_text,
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -149,12 +235,12 @@ async def show_formatting_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
formatting_info = get_telegram_formatting_info()
await callback.message.edit_text(
formatting_info,
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -167,8 +253,8 @@ async def start_edit_welcome_text(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
@@ -203,12 +289,18 @@ async def process_welcome_text_edit(
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
return
# Проверяем HTML-теги на валидность
is_valid, error_msg = validate_html_tags(new_text)
if not is_valid:
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
return
success = await set_welcome_text(db, new_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
@@ -219,14 +311,14 @@ async def process_welcome_text_edit(
f"Новый текст:\n"
f"<code>{new_text}</code>\n\n"
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
await message.answer(
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await state.clear()
@@ -242,9 +334,9 @@ async def reset_welcome_text(
success = await set_welcome_text(db, default_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"✅ Приветственный текст сброшен на стандартный!\n\n"
@@ -252,14 +344,14 @@ async def reset_welcome_text(
f"Стандартный текст:\n"
f"<code>{default_text}</code>\n\n"
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
await callback.message.edit_text(
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await callback.answer()
@@ -281,14 +373,14 @@ async def show_preview_welcome_text(
test_user = TestUser()
preview_text = await get_welcome_text_for_user(db, test_user)
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
if preview_text:
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
f"<code>{preview_text}</code>",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
@@ -296,7 +388,7 @@ async def show_preview_welcome_text(
f"👁️ Предварительный просмотр\n\n"
f"🔴 Приветственные сообщения отключены.\n"
f"Новые пользователи не будут получать приветственный текст после регистрации.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from .main import register_balance_handlers
__all__ = ["register_balance_handlers"]
+272
View File
@@ -0,0 +1,272 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_cryptobot_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
return
from app.utils.currency_converter import currency_converter
try:
current_rate = await currency_converter.get_usd_to_rub_rate()
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f}"
except Exception as e:
logger.warning(f"Не удалось получить курс валют: {e}")
current_rate = 95.0
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f}"
available_assets = settings.get_cryptobot_assets()
assets_text = ", ".join(available_assets)
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от 100 до 100,000 ₽:\n\n"
f"💰 Доступные активы: {assets_text}\n"
f"⚡ Мгновенное зачисление на баланс\n"
f"🔒 Безопасная оплата через CryptoBot\n\n"
f"{rate_text}\n"
f"Сумма будет автоматически конвертирована в USD для оплаты."
)
else:
message_text = (
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
f"Введите сумму для пополнения от 100 до 100,000 ₽:\n\n"
f"💰 Доступные активы: {assets_text}\n"
f"⚡ Мгновенное зачисление на баланс\n"
f"🔒 Безопасная оплата через CryptoBot\n\n"
f"{rate_text}\n"
f"Сумма будет автоматически конвертирована в USD для оплаты."
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
payment_method="cryptobot",
current_rate=current_rate,
cryptobot_prompt_message_id=callback.message.message_id,
cryptobot_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_cryptobot_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await message.answer("❌ Оплата криптовалютой временно недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
try:
data = await state.get_data()
current_rate = data.get('current_rate')
if not current_rate:
from app.utils.currency_converter import currency_converter
current_rate = await currency_converter.get_usd_to_rub_rate()
amount_usd = amount_rubles / current_rate
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
return
if amount_usd > 1000:
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
return
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_cryptobot_payment(
db=db,
user_id=db_user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
bot_invoice_url = payment_result.get("bot_invoice_url")
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
payment_url = bot_invoice_url or mini_app_invoice_url
if not payment_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
state_data = await state.get_data()
prompt_message_id = state_data.get("cryptobot_prompt_message_id")
prompt_chat_id = state_data.get("cryptobot_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить сообщение с суммой CryptoBot: %s",
delete_error,
)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось удалить сообщение с запросом суммы CryptoBot: %s",
delete_error,
)
invoice_message = await message.answer(
f"🪙 <b>Оплата криптовалютой</b>\n\n"
f"💰 Сумма к зачислению: {amount_rubles:.0f}\n"
f"💵 К оплате: {amount_usd:.2f} USD\n"
f"🪙 Актив: {payment_result['asset']}\n"
f"💱 Курс: 1 USD = {current_rate:.2f}\n"
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить'\n"
f"2. Выберите удобный актив\n"
f"3. Переведите указанную сумму\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.update_data(
cryptobot_invoice_message_id=invoice_message.message_id,
cryptobot_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
except Exception as e:
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_cryptobot_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"active": "",
"paid": "",
"expired": ""
}
status_text = {
"active": "Ожидает оплаты",
"paid": "Оплачен",
"expired": "Истек"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"🪙 Статус платежа:\n\n"
f"🆔 ID: {payment.invoice_id[:8]}...\n"
f"💰 Сумма: {payment.amount} {payment.asset}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_paid:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_expired:
message_text += (
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+309
View File
@@ -0,0 +1,309 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_heleket_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
return
markup = settings.get_heleket_markup_percent()
markup_text: Optional[str]
if markup > 0:
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
markup_text = f"{label}: {markup:.0f}%"
elif markup < 0:
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
markup_text = f"{label}: {abs(markup):.0f}%"
else:
markup_text = None
message_lines = [
"🪙 <b>Пополнение через Heleket</b>",
"\n",
"Введите сумму пополнения от 100 до 100,000 ₽:",
"",
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
]
if markup_text:
message_lines.extend(["", markup_text])
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
"\n".join(filter(None, message_lines)),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
payment_method="heleket",
heleket_prompt_message_id=callback.message.message_id,
heleket_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_heleket_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await message.answer("❌ Оплата через Heleket недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
payment_service = PaymentService(message.bot)
result = await payment_service.create_heleket_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {amount_rubles:.0f}",
language=db_user.language,
)
if not result:
await message.answer(
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
)
await state.clear()
return
payment_url = result.get("payment_url")
if not payment_url:
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
await state.clear()
return
payer_amount = result.get("payer_amount")
payer_currency = result.get("payer_currency")
exchange_rate = result.get("exchange_rate")
discount_percent = result.get("discount_percent")
details = [
"🪙 <b>Оплата через Heleket</b>",
"",
f"💰 Сумма к зачислению: {amount_rubles:.0f}",
]
if payer_amount and payer_currency:
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
markup_percent: Optional[float] = None
if discount_percent is not None:
try:
discount_int = int(discount_percent)
markup_percent = -discount_int
except (TypeError, ValueError):
markup_percent = None
if markup_percent:
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
absolute = abs(markup_percent)
if markup_percent > 0:
details.append(f"📈 {label_markup}: +{absolute}%")
else:
details.append(f"📉 {label_discount}: {absolute}%")
if payer_amount and payer_currency:
try:
payer_amount_float = float(payer_amount)
if payer_amount_float > 0:
rub_per_currency = amount_rubles / payer_amount_float
details.append(
f"💱 Курс: 1 {payer_currency}{rub_per_currency:.2f}"
)
except (TypeError, ValueError, ZeroDivisionError):
pass
details.extend(
[
"",
"📱 Инструкция:",
"1. Нажмите кнопку 'Оплатить'",
"2. Перейдите на страницу Heleket",
"3. Оплатите указанную сумму",
"4. Баланс пополнится автоматически",
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_heleket_{result['local_payment_id']}"
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
])
state_data = await state.get_data()
prompt_message_id = state_data.get("heleket_prompt_message_id")
prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы Heleket: %s",
delete_error,
)
invoice_message = await message.answer(
"\n".join(details), parse_mode="HTML", reply_markup=keyboard
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"])
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение Heleket: %s", error)
await state.update_data(
heleket_invoice_message_id=invoice_message.message_id,
heleket_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
@error_handler
async def check_heleket_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
) -> None:
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
return
from app.database.crud.heleket import get_heleket_payment_by_id
payment = await get_heleket_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("Платёж не найден", show_alert=True)
return
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
if payment.is_paid:
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
await callback.answer(message, show_alert=True)
return
payment_service = PaymentService(callback.bot)
updated_payment = await payment_service.sync_heleket_payment_status(
db,
local_payment_id=local_payment_id,
)
if updated_payment:
payment = updated_payment
if payment.is_paid:
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
await callback.answer(message, show_alert=True)
return
status_normalized = (payment.status or "").lower()
status_messages = {
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
"wrong_amount_waiting": texts.t(
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
"❗️ Недостаточная сумма, ожидаем доплату",
),
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
}
message = status_messages.get(status_normalized)
if message is None:
template = texts.t("HELEKET_STATUS_UNKNOWN", "️ Статус платежа: {status}")
status_value = payment.status or status_normalized or ""
try:
message = template.format(status=status_value)
except Exception: # pragma: no cover - defensive formatting
message = f"️ Статус платежа: {status_value}"
await callback.answer(message, show_alert=True)
+983
View File
@@ -0,0 +1,983 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import BalanceStates
from app.database.crud.user import add_user_balance
from app.utils.price_display import calculate_user_price, format_price_button
from app.utils.pricing_utils import format_period_description
from app.database.crud.transaction import (
get_user_transactions, get_user_transactions_count,
create_transaction
)
from app.database.models import User, TransactionType, PaymentMethod
from app.keyboards.inline import (
get_balance_keyboard, get_payment_methods_keyboard,
get_back_keyboard, get_pagination_keyboard
)
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.pagination import paginate_list
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
Returns:
List of button rows for inline keyboard
"""
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
return []
from app.localization.texts import get_texts
texts = get_texts(language)
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6] # Limit to 6 periods
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
base_price_kopeks = getattr(settings, price_attr)
# Calculate price with user's promo group discount using unified system
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
callback_data = f"quick_amount_{price_info.final_price}"
# Format button text with discount display
period_label = f"{period} дней"
# For balance buttons, use simpler format without emoji and period label prefix
if price_info.has_discount:
button_text = (
f"{texts.format_price(price_info.base_price)}"
f"{texts.format_price(price_info.final_price)} "
f"(-{price_info.discount_percent}%) • {period_label}"
)
else:
button_text = f"{texts.format_price(price_info.final_price)}{period_label}"
buttons.append(
types.InlineKeyboardButton(
text=button_text,
callback_data=callback_data
)
)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
return keyboard_rows
@error_handler
async def show_balance_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
balance_text = texts.BALANCE_INFO.format(
balance=texts.format_price(db_user.balance_kopeks)
)
await callback.message.edit_text(
balance_text,
reply_markup=get_balance_keyboard(db_user.language)
)
await callback.answer()
@error_handler
async def show_balance_history(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
texts = get_texts(db_user.language)
offset = (page - 1) * TRANSACTIONS_PER_PAGE
raw_transactions = await get_user_transactions(
db, db_user.id,
limit=TRANSACTIONS_PER_PAGE * 3,
offset=offset
)
seen_transactions = set()
unique_transactions = []
for transaction in raw_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_transactions:
seen_transactions.add(transaction_key)
unique_transactions.append(transaction)
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
break
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
seen_all = set()
total_unique = 0
for transaction in all_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_all:
seen_all.add(transaction_key)
total_unique += 1
if not unique_transactions:
await callback.message.edit_text(
"📊 История операций пуста",
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
text = "📊 <b>История операций</b>\n\n"
for transaction in unique_transactions:
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
text += f"{emoji} {amount_text}\n"
text += f"📝 {transaction.description}\n"
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard = []
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
if total_pages > 1:
pagination_row = get_pagination_keyboard(
page, total_pages, "balance_history", db_user.language
)
keyboard.extend(pagination_row)
keyboard.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_balance_history_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = int(callback.data.split('_')[-1])
await show_balance_history(callback, db_user, db, page)
@error_handler
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
from app.utils.payment_utils import get_payment_methods_text
from app.database.crud.subscription import get_subscription_by_user_id
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
from app.config import settings
from app.services.subscription_service import SubscriptionService
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text(db_user.language)
# Добавляем информацию о текущем тарифе пользователя
subscription = await get_subscription_by_user_id(db, db_user.id)
tariff_info = ""
if subscription and not subscription.is_trial:
# Рассчитываем приблизительную стоимость продления на 30 дней
duration_days = 30 # Берем для примера 30 дней
current_traffic = subscription.traffic_limit_gb
current_connected_squads = subscription.connected_squads or []
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(duration_days, 0)
period_discount_percent = db_user.get_promo_discount("period", duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
# Рассчитываем стоимость серверов
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
# Рассчитываем стоимость трафика
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
# Рассчитываем стоимость устройств
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Общая стоимость
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price +
total_servers_price * months_in_period +
traffic_discounted_per_month * months_in_period +
devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f"📱 Подписка: {len(current_connected_squads)} серверов, "
f"{traffic_display}, {current_device_limit} устр."
)
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
except Exception as e:
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
tariff_info = ""
full_text = payment_text + tariff_info
keyboard = get_payment_methods_keyboard(0, db_user.language)
try:
await callback.message.edit_text(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.edit_caption(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.delete()
except TelegramBadRequest:
pass
await callback.message.answer(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_payment_methods_unavailable(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
),
show_alert=True
)
@error_handler
async def handle_successful_topup_with_cart(
user_id: int,
amount_kopeks: int,
bot,
db: AsyncSession
):
from app.database.crud.user import get_user_by_id
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey
from app.bot import dp
user = await get_user_by_id(db, user_id)
if not user:
return
storage = dp.storage
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
try:
state_data = await storage.get_data(key)
current_state = await storage.get_state(key)
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
state_data.get('saved_cart')):
texts = get_texts(user.language)
total_price = state_data.get('total_price', 0)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="🛒 Вернуться к оформлению подписки",
callback_data="return_to_saved_cart"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
success_text = (
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"🛒 У вас есть сохраненная корзина подписки\n"
f"Стоимость: {texts.format_price(total_price)}\n\n"
f"Хотите продолжить оформление?"
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
@error_handler
async def request_support_topup(
callback: types.CallbackQuery,
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>
Для пополнения баланса обратитесь в техподдержку:
{settings.get_support_contact_display_html()}
Укажите:
ID: {db_user.telegram_id}
Сумму пополнения
Способ оплаты
Время обработки: 1-24 часа
<b>Доступные способы:</b>
Криптовалюта
Переводы между банками
Другие платежные системы
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="💬 Написать в поддержку",
url=settings.get_support_contact_url() or "https://t.me/"
)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
support_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
try:
if not message.text:
if message.successful_payment:
logger.info(
"Получено сообщение об успешном платеже без текста, "
"обработчик суммы пополнения завершает работу"
)
await state.clear()
return
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
return
amount_text = message.text.strip()
if not amount_text:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
return
amount_rubles = float(amount_text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
data = await state.get_data()
payment_method = data.get("payment_method", "stars")
if payment_method in ["yookassa", "yookassa_sbp"]:
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}".replace(',', ' '))
return
if payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def handle_sbp_payment(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
import json
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
confirmation_token = metadata.get("confirmation_token")
if not confirmation_token:
await callback.answer("❌ Токен подтверждения не найден", show_alert=True)
return
await callback.message.answer(
f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n"
f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n"
f"1. Скопировать этот токен: <code>{confirmation_token}</code>\\n"
f"2. Открыть приложение вашего банка\\n"
f"3. Найти функцию оплаты по токену\\n"
f"4. Вставить токен и подтвердить платеж",
parse_mode="HTML"
)
await callback.answer("Информация об оплате отправлена", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки embedded платежа СБП: {e}")
await callback.answer("❌ Ошибка обработки платежа", show_alert=True)
@error_handler
async def handle_quick_amount_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Обработчик выбора суммы через кнопки быстрого выбора
"""
# Извлекаем сумму из callback_data
try:
amount_kopeks = int(callback.data.split('_')[-1])
amount_rubles = amount_kopeks / 100
# Получаем метод оплаты из состояния
data = await state.get_data()
payment_method = data.get("payment_method", "yookassa")
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
if payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
except ValueError:
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки быстрого выбора суммы: {e}")
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
@error_handler
async def handle_topup_amount_callback(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
_, method, amount_str = callback.data.split("|", 2)
amount_kopeks = int(amount_str)
except ValueError:
await callback.answer("❌ Некорректный запрос", show_alert=True)
return
if amount_kopeks <= 0:
await callback.answer("❌ Некорректная сумма", show_alert=True)
return
try:
if method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount, start_platega_payment
data = await state.get_data()
method_code = int(data.get("platega_method", 0)) if data else 0
if method_code > 0:
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await state.update_data(platega_pending_amount=amount_kopeks)
await start_platega_payment(callback, db_user, state)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
elif method == "tribute":
from .tribute import start_tribute_payment
await start_tribute_payment(callback, db_user)
return
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
await callback.answer()
except Exception as error:
logger.error(f"Ошибка быстрого пополнения: {error}")
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(
show_balance_menu,
F.data == "menu_balance"
)
dp.callback_query.register(
show_balance_history,
F.data == "balance_history"
)
dp.callback_query.register(
handle_balance_history_pagination,
F.data.startswith("balance_history_page_")
)
dp.callback_query.register(
show_payment_methods,
F.data == "balance_topup"
)
from .stars import start_stars_payment
dp.callback_query.register(
start_stars_payment,
F.data == "topup_stars"
)
from .yookassa import start_yookassa_payment
dp.callback_query.register(
start_yookassa_payment,
F.data == "topup_yookassa"
)
from .yookassa import start_yookassa_sbp_payment
dp.callback_query.register(
start_yookassa_sbp_payment,
F.data == "topup_yookassa_sbp"
)
from .mulenpay import start_mulenpay_payment
dp.callback_query.register(
start_mulenpay_payment,
F.data == "topup_mulenpay"
)
from .wata import start_wata_payment
dp.callback_query.register(
start_wata_payment,
F.data == "topup_wata"
)
from .pal24 import start_pal24_payment
dp.callback_query.register(
start_pal24_payment,
F.data == "topup_pal24"
)
from .pal24 import handle_pal24_method_selection
dp.callback_query.register(
handle_pal24_method_selection,
F.data.startswith("pal24_method_"),
)
from .platega import start_platega_payment, handle_platega_method_selection
dp.callback_query.register(
start_platega_payment,
F.data == "topup_platega"
)
dp.callback_query.register(
handle_platega_method_selection,
F.data.startswith("platega_method_"),
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
from .tribute import start_tribute_payment
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
)
dp.callback_query.register(
request_support_topup,
F.data == "topup_support"
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
)
from .cryptobot import start_cryptobot_payment
dp.callback_query.register(
start_cryptobot_payment,
F.data == "topup_cryptobot"
)
from .cryptobot import check_cryptobot_payment_status
dp.callback_query.register(
check_cryptobot_payment_status,
F.data.startswith("check_cryptobot_")
)
from .heleket import start_heleket_payment, check_heleket_payment_status
dp.callback_query.register(
start_heleket_payment,
F.data == "topup_heleket"
)
dp.callback_query.register(
check_heleket_payment_status,
F.data.startswith("check_heleket_")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
F.data.startswith("check_mulenpay_")
)
from .wata import check_wata_payment_status
dp.callback_query.register(
check_wata_payment_status,
F.data.startswith("check_wata_")
)
from .pal24 import check_pal24_payment_status
dp.callback_query.register(
check_pal24_payment_status,
F.data.startswith("check_pal24_")
)
from .platega import check_platega_payment_status
dp.callback_query.register(
check_platega_payment_status,
F.data.startswith("check_platega_")
)
dp.callback_query.register(
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"
)
# Регистрируем обработчик для кнопок быстрого выбора суммы
dp.callback_query.register(
handle_quick_amount_selection,
F.data.startswith("quick_amount_")
)
dp.callback_query.register(
handle_topup_amount_callback,
F.data.startswith("topup_amount|")
)
+306
View File
@@ -0,0 +1,306 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_mulenpay_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await callback.answer(
f"❌ Оплата через {mulenpay_name} временно недоступна",
show_alert=True,
)
return
message_template = texts.t(
"MULENPAY_TOPUP_PROMPT",
(
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
"Оплата происходит через защищенную платформу {mulenpay_name}."
),
)
message_text = message_template.format(
mulenpay_name=mulenpay_name,
mulenpay_name_html=mulenpay_name_html,
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
payment_method="mulenpay",
mulenpay_prompt_message_id=callback.message.message_id,
mulenpay_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_mulenpay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
if not settings.is_mulenpay_enabled():
await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна")
return
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
await message.answer(
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
)
return
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
await message.answer(
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
)
return
amount_rubles = amount_kopeks / 100
state_data = await state.get_data()
prompt_message_id = state_data.get("mulenpay_prompt_message_id")
prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot permissions
logger.warning(
"Не удалось удалить сообщение с суммой MulenPay: %s", delete_error
)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы MulenPay: %s",
delete_error,
)
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_mulenpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
if not payment_result or not payment_result.get("payment_url"):
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
).format(mulenpay_name=mulenpay_name)
)
await state.clear()
return
payment_url = payment_result.get("payment_url")
mulen_payment_id = payment_result.get("mulen_payment_id")
local_payment_id = payment_result.get("local_payment_id")
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"MULENPAY_PAY_BUTTON",
"💳 Оплатить через {mulenpay_name}",
).format(mulenpay_name=mulenpay_name),
url=payment_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_mulenpay_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
message_template = texts.t(
"MULENPAY_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
payment_id=payment_id_display,
support=settings.get_support_contact_display_html(),
mulenpay_name=mulenpay_name,
mulenpay_name_html=mulenpay_name_html,
)
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
if payment:
payment_metadata = dict(
getattr(payment, "metadata_json", {}) or {}
)
payment_metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=payment_metadata,
)
except Exception as error: # pragma: no cover - diagnostic logging only
logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error)
await state.update_data(
mulenpay_invoice_message_id=invoice_message.message_id,
mulenpay_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
mulenpay_name,
db_user.telegram_id,
amount_rubles,
payment_id_display,
)
except Exception as e:
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
).format(mulenpay_name=mulenpay_name)
)
await state.clear()
@error_handler
async def check_mulenpay_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
status_labels = {
"created": ("", "Ожидает оплаты"),
"processing": ("", "Обрабатывается"),
"success": ("", "Оплачен"),
"canceled": ("", "Отменен"),
"error": ("⚠️", "Ошибка"),
"hold": ("🔒", "Холд"),
"unknown": ("", "Неизвестно"),
}
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
mulenpay_name = settings.get_mulenpay_display_name()
message_lines = [
f"💳 Статус платежа {mulenpay_name}:\n\n",
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
f"📊 Статус: {emoji} {status_text}\n",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"created", "processing"}:
message_lines.append(
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
)
if payment.payment_url:
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
elif payment.status in {"canceled", "error"}:
message_lines.append(
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
)
message_text = "".join(message_lines)
if len(message_text) > 190:
await callback.message.answer(message_text)
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
else:
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(
f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
)
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+644
View File
@@ -0,0 +1,644 @@
import html
import logging
from datetime import datetime
from typing import Any, Optional
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
def _get_available_pal24_methods() -> list[str]:
methods: list[str] = []
if settings.is_pal24_sbp_button_visible():
methods.append("sbp")
if settings.is_pal24_card_button_visible():
methods.append("card")
if not methods:
methods.append("sbp")
return methods
async def _send_pal24_payment_message(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
payment_method: str,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_pal24_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method=payment_method,
)
if not payment_result:
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
sbp_url = (
payment_result.get("sbp_url")
or payment_result.get("transfer_url")
)
card_url = payment_result.get("card_url")
fallback_url = (
payment_result.get("link_page_url")
or payment_result.get("link_url")
)
if not (sbp_url or card_url or fallback_url):
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
if not sbp_url:
sbp_url = fallback_url
bill_id = payment_result.get("bill_id")
local_payment_id = payment_result.get("local_payment_id")
pay_buttons: list[list[types.InlineKeyboardButton]] = []
steps: list[str] = []
step_counter = 1
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_url and settings.is_pal24_sbp_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_url and card_url != sbp_url and settings.is_pal24_card_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(card_button_text))
)
step_counter += 1
if not pay_buttons and fallback_url and settings.is_pal24_sbp_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=fallback_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
follow_template = texts.t(
"PAL24_INSTRUCTION_FOLLOW",
"{step}. Следуйте подсказкам платёжной системы",
)
steps.append(follow_template.format(step=step_counter))
step_counter += 1
confirm_template = texts.t(
"PAL24_INSTRUCTION_CONFIRM",
"{step}. Подтвердите перевод",
)
steps.append(confirm_template.format(step=step_counter))
step_counter += 1
success_template = texts.t(
"PAL24_INSTRUCTION_COMPLETE",
"{step}. Средства зачислятся автоматически",
)
steps.append(success_template.format(step=step_counter))
message_template = texts.t(
"PAL24_PAYMENT_INSTRUCTIONS",
(
"🏦 <b>Оплата через PayPalych</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID счета: {bill_id}\n\n"
"📱 <b>Инструкция:</b>\n{steps}\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
keyboard_rows = pay_buttons + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
bill_id=bill_id,
steps="\n".join(steps),
support=settings.get_support_contact_display_html(),
)
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение PayPalych: %s", error)
await state.update_data(
pal24_invoice_message_id=invoice_message.message_id,
pal24_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s",
db_user.telegram_id,
amount_kopeks / 100,
bill_id,
payment_method,
)
except Exception as error:
logger.error(f"Ошибка создания PayPalych платежа: {error}")
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
@error_handler
async def start_pal24_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Формируем текст сообщения в зависимости от доступных способов оплаты
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
payment_methods_text = "СБП и банковской картой"
elif settings.is_pal24_sbp_button_visible():
payment_methods_text = "СБП"
elif settings.is_pal24_card_button_visible():
payment_methods_text = "банковской картой"
else:
# Если обе кнопки отключены, используем общий текст
payment_methods_text = "доступными способами"
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
f"Оплата проходит через PayPalych ({payment_methods_text})."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
payment_method="pal24",
pal24_prompt_message_id=callback.message.message_id,
pal24_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_pal24_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await message.answer("❌ Оплата через PayPalych временно недоступна")
return
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}")
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(
f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}".replace(',', ' ')
)
return
available_methods = _get_available_pal24_methods()
state_data = await state.get_data()
prompt_message_id = state_data.get("pal24_prompt_message_id")
prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы PayPalych: %s",
delete_error,
)
if len(available_methods) == 1:
await _send_pal24_payment_message(
message,
db_user,
db,
amount_kopeks,
available_methods[0],
state,
)
return
await state.update_data(pal24_amount_kopeks=amount_kopeks)
await state.set_state(BalanceStates.waiting_for_pal24_method)
method_buttons: list[list[types.InlineKeyboardButton]] = []
if "sbp" in available_methods:
method_buttons.append(
[
types.InlineKeyboardButton(
text=settings.get_pal24_sbp_button_text(
texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)")
),
callback_data="pal24_method_sbp",
)
]
)
if "card" in available_methods:
method_buttons.append(
[
types.InlineKeyboardButton(
text=settings.get_pal24_card_button_text(
texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)")
),
callback_data="pal24_method_card",
)
]
)
method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
await message.answer(
texts.t(
"PAL24_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты PayPalych:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
@error_handler
async def handle_pal24_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
data = await state.get_data()
amount_kopeks = data.get("pal24_amount_kopeks")
if not amount_kopeks:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
),
show_alert=True,
)
await state.clear()
return
method = "sbp" if callback.data.endswith("_sbp") else "card"
await callback.answer()
async with AsyncSessionLocal() as db:
await _send_pal24_payment_message(
callback.message,
db_user,
db,
int(amount_kopeks),
method,
state,
)
@error_handler
async def check_pal24_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split('_')[-1])
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
status_labels = {
"NEW": ("", "Ожидает оплаты"),
"PROCESS": ("", "Обрабатывается"),
"SUCCESS": ("", "Оплачен"),
"FAIL": ("", "Отменен"),
"UNDERPAID": ("⚠️", "Недоплата"),
"OVERPAID": ("⚠️", "Переплата"),
}
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
metadata = payment.metadata_json or {}
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
if not isinstance(links_meta, dict):
links_meta = {}
links_info = status_info.get("links") or {}
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
stack: list[Any] = [source]
while stack:
current = stack.pop()
if isinstance(current, dict):
for key in keys:
value = current.get(key)
if value:
return str(value)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return None
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
remote_data = status_info.get("remote_data")
transfer_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
extra_sbp_link = (
_extract_link(raw_response, transfer_keys)
if raw_response
else None
)
if not extra_sbp_link and remote_data:
extra_sbp_link = _extract_link(remote_data, transfer_keys)
extra_card_link = (
_extract_link(raw_response, card_keys)
if raw_response
else None
)
if not extra_card_link and remote_data:
extra_card_link = _extract_link(remote_data, card_keys)
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or extra_sbp_link
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
or extra_card_link
)
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
message_lines = [
"🏦 Статус платежа PayPalych:",
"",
f"🆔 ID счета: {payment.bill_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {emoji} {status_text}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
]
if payment.is_paid:
message_lines.append("")
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"NEW", "PROCESS"}:
message_lines.append("")
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
if sbp_link:
message_lines.append("")
message_lines.append(f"🏦 СБП: {sbp_link}")
if card_link and card_link != sbp_link:
message_lines.append(f"💳 Банковская карта: {card_link}")
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
message_lines.append("")
message_lines.append(
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
)
from app.localization.texts import get_texts
db_user = getattr(callback, 'db_user', None)
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard_rows = pay_rows + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.answer()
try:
await callback.message.edit_text(
"\n".join(message_lines),
reply_markup=keyboard,
disable_web_page_preview=True,
)
except TelegramBadRequest as error:
if "message is not modified" in str(error).lower():
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
else:
raise
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+403
View File
@@ -0,0 +1,403 @@
"""Handlers for Platega balance interactions."""
import logging
from typing import List
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
def _get_active_methods() -> List[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
async def _prompt_amount(
message: types.Message,
db_user: User,
state: FSMContext,
method_code: int,
) -> None:
texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code)
# Всегда фиксируем выбранный метод для последующей обработки
await state.update_data(payment_method="platega", platega_method=method_code)
data = await state.get_data()
pending_amount = int(data.get("platega_pending_amount") or 0)
if pending_amount > 0:
# Если сумма уже известна (например, после быстрого выбора),
# сразу создаём платеж и сбрасываем временное значение.
await state.update_data(platega_pending_amount=None)
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message,
db_user,
db,
pending_amount,
state,
)
return
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
max_amount_label = (
settings.format_price(max_amount_kopeks)
if max_amount_kopeks and max_amount_kopeks > 0
else ""
)
default_prompt_body = (
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
if max_amount_kopeks and max_amount_kopeks > 0
else "Введите сумму для пополнения от {min_amount}.\n"
)
prompt_template = texts.t(
"PLATEGA_TOPUP_PROMPT",
(
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
f"{default_prompt_body}"
"Оплата происходит через Platega."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(
method_name=method_name,
min_amount=min_amount_label,
max_amount=max_amount_label,
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
platega_prompt_message_id=message.message_id,
platega_prompt_chat_id=message.chat.id,
)
@error_handler
async def start_platega_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
),
show_alert=True,
)
return
active_methods = _get_active_methods()
if not active_methods:
await callback.answer(
texts.t(
"PLATEGA_METHODS_NOT_CONFIGURED",
"⚠️ На стороне Platega нет доступных методов оплаты",
),
show_alert=True,
)
return
await state.update_data(payment_method="platega")
data = await state.get_data()
has_pending_amount = bool(int(data.get("platega_pending_amount") or 0))
if len(active_methods) == 1:
await _prompt_amount(callback.message, db_user, state, active_methods[0])
await callback.answer()
return
method_buttons: list[list[types.InlineKeyboardButton]] = []
for method_code in active_methods:
label = settings.get_platega_method_display_title(method_code)
method_buttons.append(
[
types.InlineKeyboardButton(
text=label,
callback_data=f"platega_method_{method_code}",
)
]
)
method_buttons.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
)
await callback.message.edit_text(
texts.t(
"PLATEGA_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты Platega:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
if not has_pending_amount:
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer()
@error_handler
async def handle_platega_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
method_code = int(callback.data.rsplit("_", 1)[-1])
except ValueError:
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
return
if method_code not in _get_active_methods():
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
return
await _prompt_amount(callback.message, db_user, state, method_code)
await callback.answer()
@error_handler
async def process_platega_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await message.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
)
)
return
data = await state.get_data()
method_code = int(data.get("platega_method", 0))
if method_code not in _get_active_methods():
await message.answer(
texts.t(
"PLATEGA_METHOD_SELECTION_REQUIRED",
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
)
)
await state.set_state(BalanceStates.waiting_for_platega_method)
return
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_LOW",
"Минимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_HIGH",
"Максимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
)
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method_code=method_code,
)
except Exception as error:
logger.exception("Ошибка создания платежа Platega: %s", error)
payment_result = None
if not payment_result or not payment_result.get("redirect_url"):
await message.answer(
texts.t(
"PLATEGA_PAYMENT_ERROR",
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
redirect_url = payment_result.get("redirect_url")
local_payment_id = payment_result.get("local_payment_id")
transaction_id = payment_result.get("transaction_id")
method_title = settings.get_platega_method_display_title(method_code)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"PLATEGA_PAY_BUTTON",
"💳 Оплатить через {method}",
).format(method=method_title),
url=redirect_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_platega_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
instructions_template = texts.t(
"PLATEGA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Platega ({method})</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID транзакции: {transaction}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку «Оплатить»\n"
"2. Следуйте подсказкам платёжной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
state_data = await state.get_data()
prompt_message_id = state_data.get("platega_prompt_message_id")
prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы Platega: %s",
delete_error,
)
invoice_message = await message.answer(
instructions_template.format(
method=method_title,
amount=settings.format_price(amount_kopeks),
transaction=transaction_id or local_payment_id,
support=settings.get_support_contact_display_html(),
),
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if payment:
payment_metadata = dict(getattr(payment, "metadata_json", {}) or {})
payment_metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await payment_module.update_platega_payment(
db,
payment=payment,
metadata=payment_metadata,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить данные сообщения Platega: %s", error)
await state.update_data(
platega_invoice_message_id=invoice_message.message_id,
platega_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
@error_handler
async def check_platega_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except ValueError:
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
try:
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
except Exception as error:
logger.exception("Ошибка проверки статуса Platega: %s", error)
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
return
if not status_info:
await callback.answer("⚠️ Платёж не найден", show_alert=True)
return
payment = status_info.get("payment")
status = status_info.get("status")
is_paid = status_info.get("is_paid")
language = "ru"
user = getattr(payment, "user", None)
if user and getattr(user, "language", None):
language = user.language
texts = get_texts(language)
if is_paid:
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
else:
await callback.answer(
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
show_alert=True,
)
+132
View File
@@ -0,0 +1,132 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
from app.external.telegram_stars import TelegramStarsService
logger = logging.getLogger(__name__)
@error_handler
async def start_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"⭐ <b>Пополнение через Telegram Stars</b>\n\n"
f"Выберите сумму пополнения или введите вручную:"
)
else:
message_text = texts.TOP_UP_AMOUNT
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard
)
await state.update_data(
stars_prompt_message_id=callback.message.message_id,
stars_prompt_chat_id=callback.message.chat.id,
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@error_handler
async def process_stars_payment_amount(
message: types.Message,
db_user: User,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer("⚠️ Оплата Stars временно недоступна")
return
try:
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
state_data = await state.get_data()
prompt_message_id = state_data.get("stars_prompt_message_id")
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы Stars: %s",
delete_error,
)
invoice_message = await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.update_data(
stars_invoice_message_id=invoice_message.message_id,
stars_invoice_chat_id=invoice_message.chat.id,
)
await state.set_state(None)
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("⚠️ Ошибка создания платежа")
+71
View File
@@ -0,0 +1,71 @@
import logging
from aiogram import types
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User,
):
texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED:
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
return
try:
from app.services.tribute_service import TributeService
tribute_service = TributeService(callback.bot)
payment_url = await tribute_service.create_payment_link(
user_id=db_user.telegram_id,
amount_kopeks=0,
description="Пополнение баланса VPN",
)
if not payment_url:
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
message_text = (
"💳 <b>Пополнение банковской картой</b>\n\n"
"• Введите любую сумму от 100₽\n"
"• Безопасная оплата через Tribute\n"
"• Мгновенное зачисление на баланс\n"
"• Принимаем карты Visa, MasterCard, МИР\n\n"
"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
"Нажмите кнопку для перехода к оплате:"
)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
TributeService.remember_invoice_message(
db_user.telegram_id,
callback.message.chat.id,
callback.message.message_id,
)
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
+282
View File
@@ -0,0 +1,282 @@
import logging
from datetime import datetime
from typing import Dict
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService, get_user_by_id as fetch_user_by_id
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_wata_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_wata_enabled():
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
return
message_text = texts.t(
"WATA_TOPUP_PROMPT",
(
"💳 <b>Оплата через WATA</b>\n\n"
"Введите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\n"
"Оплата происходит через защищенную форму WATA."
),
).format(
min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS),
max_amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
payment_method="wata",
wata_prompt_message_id=callback.message.message_id,
wata_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_wata_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_wata_enabled():
await message.answer("❌ Оплата через WATA временно недоступна")
return
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
)
return
payment_service = PaymentService(message.bot)
try:
result = await payment_service.create_wata_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
except Exception as error: # pragma: no cover - handled by decorator logs
logger.exception("Ошибка создания WATA платежа: %s", error)
result = None
if not result or not result.get("payment_url"):
await message.answer(
texts.t(
"WATA_PAYMENT_ERROR",
"❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
payment_url = result["payment_url"]
payment_link_id = result["payment_link_id"]
local_payment_id = result["local_payment_id"]
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"),
url=payment_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_wata_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
message_template = texts.t(
"WATA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через WATA</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через WATA'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
payment_id=payment_link_id,
support=settings.get_support_contact_display_html(),
)
state_data = await state.get_data()
prompt_message_id = state_data.get("wata_prompt_message_id")
prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы WATA: %s",
delete_error,
)
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение WATA: %s", error)
await state.update_data(
wata_invoice_message_id=invoice_message.message_id,
wata_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
"Создан WATA платеж для пользователя %s: %s₽, ссылка: %s",
db_user.telegram_id,
amount_kopeks / 100,
payment_link_id,
)
@error_handler
async def check_wata_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_wata_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
user_language = "ru"
try:
user = await fetch_user_by_id(db, payment.user_id)
if user and getattr(user, "language", None):
user_language = user.language
except Exception as error:
logger.debug("Не удалось получить пользователя для WATA статуса: %s", error)
texts = get_texts(user_language)
status_labels: Dict[str, Dict[str, str]] = {
"Opened": {"emoji": "", "label": texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")},
"Closed": {"emoji": "", "label": texts.t("WATA_STATUS_CLOSED", "Обрабатывается")},
"Paid": {"emoji": "", "label": texts.t("WATA_STATUS_PAID", "Оплачен")},
"Declined": {"emoji": "", "label": texts.t("WATA_STATUS_DECLINED", "Отклонен")},
}
label_info = status_labels.get(payment.status, {"emoji": "", "label": texts.t("WATA_STATUS_UNKNOWN", "Неизвестно")})
message_lines = [
texts.t("WATA_STATUS_TITLE", "💳 <b>Статус платежа WATA</b>"),
"",
f"🆔 ID: {payment.payment_link_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {label_info['emoji']} {label_info['label']}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else ''}",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"Opened", "Closed"}:
message_lines.append(
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
)
await callback.message.answer("\n".join(message_lines), parse_mode="HTML")
await callback.answer()
+533
View File
@@ -0,0 +1,533 @@
import logging
from datetime import datetime
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_yookassa_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"💳 <b>Оплата банковской картой</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
else:
message_text = (
f"💳 <b>Оплата банковской картой</b>\n\n"
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa")
await state.update_data(
yookassa_prompt_message_id=callback.message.message_id,
yookassa_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def start_yookassa_sbp_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"🏦 <b>Оплата через СБП</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
else:
message_text = (
f"🏦 <b>Оплата через СБП</b>\n\n"
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa_sbp")
await state.update_data(
yookassa_prompt_message_id=callback.message.message_id,
yookassa_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_yookassa_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
state_data = await state.get_data()
prompt_message_id = state_data.get("yookassa_prompt_message_id")
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы YooKassa: %s",
delete_error,
)
invoice_message = await message.answer(
f"💳 <b>Оплата банковской картой</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить картой'\n"
f"2. Введите данные вашей карты\n"
f"3. Подтвердите платеж\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_yookassa_payment_by_local_id(
db, payment_result["local_payment_id"]
)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить сообщение YooKassa: %s", error)
await state.update_data(
yookassa_invoice_message_id=invoice_message.message_id,
yookassa_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def process_yookassa_sbp_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await message.answer("❌ Оплата через СБП временно недоступна")
return
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup_sbp"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
qr_confirmation_data = payment_result.get("qr_confirmation_data")
if not confirmation_url and not qr_confirmation_data:
await message.answer("❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.")
await state.clear()
return
# Подготовим QR-код для вставки в основное сообщение
qr_photo = None
if qr_confirmation_data:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Создаем QR-код из полученных данных
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(qr_confirmation_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода: {e}")
# Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL
if not qr_photo and confirmation_url:
try:
# Импортируем необходимые модули для генерации QR-кода
import base64
from io import BytesIO
import qrcode
from aiogram.types import BufferedInputFile
# Создаем QR-код из URL
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(confirmation_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Сохраняем изображение в байты
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png")
except ImportError:
logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован")
except Exception as e:
logger.error(f"Ошибка генерации QR-кода из URL: {e}")
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
keyboard_buttons = []
# Добавляем кнопку оплаты, если доступна ссылка
if confirmation_url:
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
else:
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
# Добавляем общие кнопки
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
state_data = await state.get_data()
prompt_message_id = state_data.get("yookassa_prompt_message_id")
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s",
delete_error,
)
# Подготавливаем текст сообщения
message_text = (
f"🔗 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
message_text += (
f"📱 <b>Инструкция по оплате:</b>\n"
f"1. Откройте приложение вашего банка\n"
f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n"
f"3. Введите ID платежа: <code>{payment_result['yookassa_payment_id']}</code>\n"
f"4. Подтвердите платеж в приложении банка\n"
f"5. Деньги поступят на баланс автоматически\n\n"
)
message_text += (
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
)
# Отправляем сообщение с инструкциями и клавиатурой
# Если есть QR-код, отправляем его как медиа-сообщение
if qr_photo:
# Используем метод отправки медиа-группы или фото с описанием
invoice_message = await message.answer_photo(
photo=qr_photo,
caption=message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_yookassa_payment_by_local_id(
db, payment_result["local_payment_id"]
)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error)
await state.update_data(
yookassa_invoice_message_id=invoice_message.message_id,
yookassa_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa СБП платежа: {e}")
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_yookassa_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"pending": "",
"waiting_for_capture": "",
"succeeded": "",
"canceled": "",
"failed": ""
}
status_text = {
"pending": "Ожидает оплаты",
"waiting_for_capture": "Ожидает подтверждения",
"succeeded": "Оплачен",
"canceled": "Отменен",
"failed": "Ошибка"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"💳 Статус платежа:\n\n"
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_succeeded:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_failed:
message_text += (
f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+968 -54
View File
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
import asyncio
import logging
from datetime import datetime
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.database.models import PollQuestion, User
from app.localization.texts import get_texts
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
logger = logging.getLogger(__name__)
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
try:
await asyncio.sleep(delay)
await bot.delete_message(chat_id, message_id)
except Exception as error: # pragma: no cover - cleanup best effort
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
async def _render_question_text(
poll_title: str,
question: PollQuestion,
current_index: int,
total: int,
language: str,
) -> str:
texts = get_texts(language)
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
current=current_index,
total=total,
)
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
return "\n".join(lines)
async def _update_poll_message(
message: types.Message,
text: str,
*,
reply_markup: types.InlineKeyboardMarkup | None = None,
parse_mode: str | None = "HTML",
) -> bool:
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return True
except TelegramBadRequest as error:
error_text = str(error).lower()
if "message is not modified" in error_text:
logger.debug(
"Опросное сообщение уже актуально, пропускаем обновление: %s",
error,
)
return True
logger.warning(
"Не удалось обновить сообщение опроса %s: %s",
message.message_id,
error,
)
except Exception as error: # pragma: no cover - defensive logging
logger.exception(
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
message.message_id,
error,
)
return False
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
buttons: list[list[types.InlineKeyboardButton]] = []
for option in sorted(question.options, key=lambda o: o.order):
buttons.append(
[
types.InlineKeyboardButton(
text=option.text,
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
)
]
)
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
async def handle_poll_start(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
response_id = int(callback.data.split(":")[1])
except (IndexError, ValueError):
await callback.answer("❌ Опрос не найден", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
texts = get_texts(db_user.language)
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
if not response.poll or not response.poll.questions:
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
return
if not response.started_at:
response.started_at = datetime.utcnow()
await db.commit()
index, question = await get_next_question(response)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
return
question_text = await _render_question_text(
response.poll.title,
question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
await callback.answer()
async def handle_poll_answer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
_, response_id, question_id, option_id = callback.data.split(":", 3)
response_id = int(response_id)
question_id = int(question_id)
option_id = int(option_id)
except (ValueError, IndexError):
await callback.answer("❌ Некорректные данные", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
texts = get_texts(db_user.language)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
if not response.poll:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
return
option = await get_question_option(question, option_id)
if not option:
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
return
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
try:
await db.refresh(response, attribute_names=["answers"])
except Exception as error: # pragma: no cover - defensive cache busting
logger.debug(
"Не удалось обновить локальные ответы опроса %s: %s",
response.id,
error,
)
response = await get_poll_response_by_id(db, response.id)
if not response:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
index, next_question = await get_next_question(response)
if next_question:
question_text = await _render_question_text(
response.poll.title,
next_question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, next_question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
await callback.answer()
return
response.completed_at = datetime.utcnow()
await db.commit()
reward_amount = await reward_user_for_poll(db, response)
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
if reward_amount:
thanks_lines.append(
texts.t(
"POLL_REWARD_GRANTED",
"Награда {amount} зачислена на ваш баланс.",
).format(amount=settings.format_price(reward_amount))
)
if not await _update_poll_message(
callback.message,
"\n\n".join(thanks_lines),
):
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
return
asyncio.create_task(
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
+50 -11
View File
@@ -1,14 +1,14 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram import Dispatcher, types, F, Bot
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.promocode_service import PromoCodeService
from app.services.admin_notification_service import AdminNotificationService
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@@ -31,6 +31,47 @@ async def show_promocode_menu(
await callback.answer()
async def activate_promocode_for_registration(
db: AsyncSession,
user_id: int,
code: str,
bot: Bot = None
) -> dict:
"""
Активирует промокод для пользователя во время регистрации.
Возвращает результат активации без отправки сообщений.
"""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, user_id, code)
if result["success"]:
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
# Отправляем уведомление админу, если бот доступен
if bot:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
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(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
return result
@error_handler
async def process_promocode(
message: types.Message,
@@ -39,9 +80,9 @@ async def process_promocode(
db: AsyncSession
):
texts = get_texts(db_user.language)
code = message.text.strip()
if not code:
await message.answer(
texts.t(
@@ -51,16 +92,14 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
return
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, db_user.id, code)
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
if result["success"]:
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
@@ -69,13 +108,13 @@ async def process_promocode(
"already_used_by_user": texts.PROMOCODE_USED,
"server_error": texts.ERROR
}
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
await message.answer(
error_text,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
+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"
File diff suppressed because it is too large Load Diff
+53 -11
View File
@@ -1,8 +1,10 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.config import settings
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
@@ -16,9 +18,11 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
logger.info(
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
)
allowed_prefixes = ("balance_", "admin_stars_test_")
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
@@ -33,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
from app.database.database import get_db
async for db in get_db():
user = await get_user_by_telegram_id(db, query.from_user.id)
if not user:
@@ -75,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
async def handle_successful_payment(
message: types.Message,
db: AsyncSession,
state: FSMContext,
**kwargs
):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -104,6 +110,27 @@ async def handle_successful_payment(
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
prompt_message_id = state_data.get("stars_prompt_message_id")
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
invoice_message_id = state_data.get("stars_invoice_message_id")
invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id)
for chat_id, message_id, label in [
(prompt_chat_id, prompt_message_id, "запрос суммы"),
(invoice_chat_id, invoice_message_id, "инвойс Stars"),
]:
if message_id:
try:
await message.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning(
"Не удалось удалить сообщение %s после оплаты Stars: %s",
label,
delete_error,
)
success = await payment_service.process_stars_payment(
db=db,
user_id=user.id,
@@ -111,9 +138,18 @@ async def handle_successful_payment(
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id
)
await state.update_data(
stars_prompt_message_id=None,
stars_prompt_chat_id=None,
stars_invoice_message_id=None,
stars_invoice_chat_id=None,
)
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
amount_text = settings.format_price(amount_kopeks).replace("", "")
keyboard = await payment_service.build_topup_success_keyboard(user)
@@ -126,10 +162,14 @@ async def handle_successful_payment(
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
amount=int(rubles_amount),
amount=amount_text,
transaction_id=transaction_id_short,
),
parse_mode="HTML",
@@ -137,8 +177,10 @@ async def handle_successful_payment(
)
logger.info(
f"✅ Stars платеж успешно обработан: "
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}"
"✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s",
user.id,
payment.total_amount,
settings.format_price(amount_kopeks),
)
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
@@ -162,15 +204,15 @@ async def handle_successful_payment(
def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register(
handle_pre_checkout_query,
F.currency == "XTR"
F.currency == "XTR"
)
dp.message.register(
handle_successful_payment,
F.successful_payment
)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
+843 -182
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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