Compare commits

...

200 Commits

Author SHA1 Message Date
Egor 26e0800d70 Update Dockerfile 2025-09-21 08:19:43 +03:00
Egor d0197aacd7 Update docker-registry.yml 2025-09-21 08:19:16 +03:00
Egor b097c5db44 Update docker-hub.yml 2025-09-21 08:19:06 +03:00
Egor 302f64ad5c Merge pull request #178 from Fr1ngg/dev3
Вывод скидок за длительный период, за промогруппу при покупке/продлении подписки
2025-09-21 08:09:02 +03:00
Egor 23cd2097da Merge pull request #177 from Fr1ngg/embv4q-bedolaga/add-navigation-to-promo-groups-after-creation
Add return button after creating promo group
2025-09-21 08:05:56 +03:00
Egor 58257a6eac Add return button after creating promo group 2025-09-21 08:01:44 +03:00
Egor ea7b982b7b Merge pull request #176 from Fr1ngg/0h9kvj-bedolaga/display-promo-group-discounts-on-subscription-page
Show promo group discounts during subscription flow
2025-09-21 07:56:00 +03:00
Egor 1fcd76db9e Show promo discounts during subscription flow 2025-09-21 07:55:31 +03:00
Egor 66d92cfa51 Merge pull request #175 from Fr1ngg/dev3
Фикс покупки/продления подпсики для режима FIXED трафика
2025-09-21 06:43:43 +03:00
Egor 9dc5ee22ee Merge pull request #174 from Fr1ngg/p3trbh-bedolaga/fix-pricing-calculation-error-in-fixed-mode
Handle missing traffic package prices
2025-09-21 06:38:57 +03:00
Egor b5df729d97 Handle missing traffic package prices 2025-09-21 06:38:34 +03:00
Egor 56e3541b80 Merge pull request #173 from Fr1ngg/dev3
Фикс продления подписки
2025-09-21 06:31:24 +03:00
Egor 9681bce540 Merge pull request #172 from Fr1ngg/2osn6z-bedolaga/fix-subscription-renewal-error
Fix subscription extension success message
2025-09-21 06:27:40 +03:00
Egor 7330c729e0 Fix subscription extension success message 2025-09-21 06:27:10 +03:00
Egor 977a19a4c9 Merge pull request #171 from Fr1ngg/revert-170-a3l29i-bedolaga/fix-subscription-renewal-error
Revert "Fix edit_text patch signature"
2025-09-21 06:25:49 +03:00
Egor d226fedf75 Revert "Fix edit_text patch signature" 2025-09-21 06:24:59 +03:00
Egor c9ee1da728 Merge pull request #170 from Fr1ngg/a3l29i-bedolaga/fix-subscription-renewal-error
Fix edit_text patch signature
2025-09-21 06:14:37 +03:00
Egor d6753501e7 Fix edit_text patch signature 2025-09-21 06:13:59 +03:00
Egor 48101eee39 Merge pull request #168 from Fr1ngg/dev3
Фикс удаления промогрупп
2025-09-20 10:38:01 +03:00
Egor 16e771f1c3 Merge pull request #167 from Fr1ngg/dbzwiq-bedolaga/fix-promo-group-deletion-not-working
Fix promo group deletion confirm handling
2025-09-20 10:31:45 +03:00
Egor 0aa49a6a94 Fix promo group delete confirmation handler 2025-09-20 10:31:31 +03:00
Egor c810619a13 Merge pull request #166 from Fr1ngg/revert-165-pa311q-bedolaga/fix-promo-group-deletion-not-working
Revert "Handle promo group deletion when default group is missing"
2025-09-20 10:30:58 +03:00
Egor 817d277091 Revert "Handle promo group deletion when default group is missing" 2025-09-20 10:30:35 +03:00
Egor d3a822e8b3 Merge pull request #165 from Fr1ngg/pa311q-bedolaga/fix-promo-group-deletion-not-working
Handle promo group deletion when default group is missing
2025-09-20 10:28:31 +03:00
Egor ec8c37744a Handle missing default promo group on deletion 2025-09-20 10:27:54 +03:00
Egor 732c36630c Merge pull request #163 from Fr1ngg/dev3
Промогруппы + Скидка базовым юзерам на доп услуги за продолжительный период подписки
2025-09-20 09:46:59 +03:00
Egor 498d2f04b6 Update .env.example 2025-09-20 09:42:14 +03:00
Egor d167147725 Update README.md 2025-09-20 09:41:45 +03:00
Egor 19800971d6 Merge pull request #162 from Fr1ngg/bedolaga
Add configurable base promo group period discounts
2025-09-20 09:12:58 +03:00
Egor 4b457240b9 Add configurable base promo group period discounts 2025-09-20 09:12:26 +03:00
Egor b63c5867d4 Merge pull request #161 from Fr1ngg/feature
Add promo group management to admin user profile
2025-09-20 08:35:23 +03:00
Egor 598dd8b6c1 Add promo group management to admin user profile 2025-09-20 08:35:06 +03:00
Egor 6cd399cea7 Merge pull request #160 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column-anc9e0
Expand universal migration to provision promo groups
2025-09-20 08:25:39 +03:00
Egor 466033d04c Delete migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py 2025-09-20 08:25:29 +03:00
Egor 99bca0220b Delete migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-09-20 08:25:14 +03:00
Egor a10b9f0ad7 Expand universal migration to provision promo groups 2025-09-20 08:23:57 +03:00
Egor 825d549822 Delete migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-09-20 08:15:21 +03:00
Egor 03cb06e757 Delete migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py 2025-09-20 08:15:09 +03:00
Egor 113b33e68d Delete migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py 2025-09-20 08:14:37 +03:00
Egor f3a327c9c7 Merge pull request #158 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column-rg4e47
Add promo groups migration
2025-09-20 08:07:51 +03:00
Egor 2ca4a989e2 Handle existing sent_notifications table in migration 2025-09-20 08:07:23 +03:00
Egor 7339f2cd4f Merge pull request #157 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column
Add promo groups migration
2025-09-20 07:57:26 +03:00
Egor 827897b771 Add promo groups table and link users 2025-09-20 07:57:06 +03:00
Egor eb5064d591 Merge pull request #156 from Fr1ngg/codex/add-promo-group-section-in-admin-panel
Add admin promo groups and integrate discounts
2025-09-20 07:49:12 +03:00
Egor 177862f178 Add admin promo groups and integrate discounts 2025-09-20 07:48:41 +03:00
Egor 7276dcf1e0 Merge pull request #155 from Fr1ngg/main
w
2025-09-20 06:58:06 +03:00
Egor 7587aec913 Update Dockerfile 2025-09-20 03:31:36 +03:00
Egor 7186c93078 Update docker-registry.yml 2025-09-20 03:31:22 +03:00
Egor 727e20584f Update docker-hub.yml 2025-09-20 03:31:11 +03:00
Egor 9c13775eec Merge pull request #153 from Fr1ngg/codex/update-readme-with-available-features
docs: expand functionality overview
2025-09-20 03:09:16 +03:00
Egor e767ef7d92 docs: expand functionality overview 2025-09-20 03:08:24 +03:00
Egor ce22df4d60 Update inline.py 2025-09-20 02:45:49 +03:00
Egor 7c4567ae70 Merge pull request #150 from Fr1ngg/dev3
Фиксы покупки подписки в апдейте с локалью
2025-09-20 02:24:19 +03:00
Egor af1125f6ed Merge pull request #151 from Fr1ngg/codex/fix-subscription-country-change-issue
Fix NameError in manage countries keyboard
2025-09-20 02:23:54 +03:00
Egor ee65e65108 Fix missing texts initialization in manage countries keyboard 2025-09-20 02:23:20 +03:00
Egor 5c1f7d54ba Merge pull request #149 from Fr1ngg/codex/fix-subscription-purchase-button-issue
Fix subscription checkout resume callbacks registration
2025-09-20 02:15:48 +03:00
Egor 21e5af9e9e Fix subscription checkout resume callbacks registration 2025-09-20 02:15:38 +03:00
Egor f7d674adf4 Merge pull request #148 from Fr1ngg/codex/fix-subscription-purchase-error
Add missing localization for subscription checkout button
2025-09-20 02:09:10 +03:00
Egor 5a7de9731a Add missing subscription checkout localization 2025-09-20 02:08:10 +03:00
Egor f987dc2716 фикс регистрации
Dev3
2025-09-20 01:58:14 +03:00
Egor bbdc1274e4 Merge pull request #146 from Fr1ngg/codex/fix-registration-issue-for-new-users-1a7pwj
Fix async rules loading for new users
2025-09-20 01:49:46 +03:00
Egor feb95fe105 Fix async rules loading for new users 2025-09-20 01:49:29 +03:00
Egor 91b0d3782a Merge pull request #131 from yazhog/feature/locale
Feature/locale
2025-09-20 01:02:40 +03:00
yazhog a794f5da70 Merge pull request #40 from yazhog/codex/add-localization-for-campaign-keys
Add campaign localization strings to internal locales
2025-09-19 18:06:47 +03:00
yazhog e571edb354 Remove legacy YAML locale files 2025-09-19 18:06:37 +03:00
yazhog 84dece39a7 Add campaign localization strings to internal locales 2025-09-19 18:04:28 +03:00
yazhog f49bf21caa Merge branch 'main' into feature/locale 2025-09-19 18:03:17 +03:00
Egor 64da0ef94f Merge pull request #142 from Fr1ngg/dev3
Правки в меню редактирования профиля юзера
2025-09-19 15:48:46 +03:00
Egor 3177b5ab80 Merge pull request #141 from Fr1ngg/codex/refactor-user-edit-menu-for-subscriptions
Unify admin subscription and settings menu
2025-09-19 15:36:55 +03:00
Egor ec1b459689 Unify admin subscription and settings menu 2025-09-19 15:36:11 +03:00
Egor 08f7f87035 Merge pull request #140 from Fr1ngg/main
w
2025-09-19 15:25:12 +03:00
Egor 59189cae2a Merge pull request #114 from Gy9vin/dev
Добавлена фильтрация пользователей по балансу
2025-09-19 15:16:26 +03:00
Egor 573dcd034a Merge pull request #138 from Fr1ngg/dev_test
Редактирование РК + фикс удаления
2025-09-19 13:40:12 +03:00
Egor 0f29a8a33b Merge pull request #137 from Fr1ngg/codex/fix-editing-of-ad-campaigns
Fix campaign edit menu for captioned messages
2025-09-19 13:36:38 +03:00
Egor 98ec7e4165 Fix campaign edit menu for captioned messages 2025-09-19 13:36:27 +03:00
Egor 46c7d53f6e Merge pull request #136 from Fr1ngg/revert-135-codex/fix-campaign-deletion-and-add-editing-yipzjx
Revert "Handle campaign edit menu for media messages"
2025-09-19 13:27:07 +03:00
Egor 61758b8c2a Revert "Handle campaign edit menu for media messages" 2025-09-19 13:26:54 +03:00
Egor 42ac9555ac Merge pull request #135 from Fr1ngg/codex/fix-campaign-deletion-and-add-editing-yipzjx
Handle campaign edit menu for media messages
2025-09-19 13:25:20 +03:00
Egor f7416f2255 Merge branch 'dev_test' into codex/fix-campaign-deletion-and-add-editing-yipzjx 2025-09-19 13:25:12 +03:00
Egor 509c92217f Fix campaign edit menu fallback for non-text messages 2025-09-19 13:23:30 +03:00
Egor c0d5ae1e9e Merge pull request #134 from Fr1ngg/codex/fix-campaign-deletion-and-add-editing
Fix campaign deletion confirmation and add editing workflow
2025-09-19 13:15:35 +03:00
Egor cf9fbc5ccc Fix campaign deletion confirmation and add editing workflow 2025-09-19 13:15:17 +03:00
gy9vin ad19be83d2 Merge branch 'dev' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot into dev 2025-09-19 13:14:00 +03:00
yazhog 3dd5e41bda Merge pull request #39 from yazhog/codex/fix-syntaxerror-in-subscription.py
Fix subscription success text formatting
2025-09-19 12:38:27 +03:00
yazhog 639a45611e Fix subscription success text formatting 2025-09-19 12:38:08 +03:00
Egor 773299c317 Merge pull request #130 from Fr1ngg/dev_test
Рекламные компании
2025-09-19 12:34:53 +03:00
yazhog ff39b10761 Merge pull request #38 from yazhog/codex/fix-f-string-syntaxerror-in-subscription.py
Fix trial success text formatting
2025-09-19 12:33:32 +03:00
yazhog 5fd5472fd3 Fix trial success text formatting 2025-09-19 12:33:20 +03:00
Egor adf94802b0 Merge pull request #129 from Fr1ngg/codex/add-advertising-link-functionality-to-admin-eu5f3n
Refresh user state after campaign bonuses
2025-09-19 12:30:22 +03:00
Egor 15378a412f Merge branch 'dev_test' into codex/add-advertising-link-functionality-to-admin-eu5f3n 2025-09-19 12:30:06 +03:00
Egor 32da16f652 Refresh user state after campaign bonuses 2025-09-19 12:29:28 +03:00
yazhog 075d92e4bd Merge pull request #37 from yazhog/codex/extract-text-for-localization
Localize user-facing texts and payment method copy
2025-09-19 12:28:04 +03:00
yazhog a7cbcd284c Localize subscription, referral, and payment texts 2025-09-19 12:27:42 +03:00
Egor 5682d508a3 Merge pull request #128 from Fr1ngg/codex/add-advertising-link-functionality-to-admin
Clean up campaign-related imports
2025-09-19 12:16:12 +03:00
Egor c738b5703f Fix import cleanup 2025-09-19 12:15:56 +03:00
yazhog 6875fce2cb Merge pull request #36 from yazhog/codex/locate-missing-localization-keys
Add missing English localization strings for trial activation
2025-09-19 12:09:31 +03:00
yazhog 57b7ad6732 Add missing English localizations for trial activation 2025-09-19 12:09:11 +03:00
yazhog 8377db323b Merge pull request #35 from yazhog/codex/fix-language-setting-in-interface
Respect DEFAULT_LANGUAGE in onboarding
2025-09-19 12:01:13 +03:00
yazhog 78d768ea4d Respect DEFAULT_LANGUAGE in onboarding 2025-09-19 12:01:01 +03:00
yazhog 9dee654eee Merge pull request #34 from yazhog/codex/move-interface-text-to-locales
Localize interface strings in payments and shared handlers
2025-09-19 11:46:25 +03:00
Egor a02f49916a Merge pull request #126 from Fr1ngg/main
w
2025-09-19 11:44:19 +03:00
yazhog 8d25e9b760 Localize stars payment and common handler texts 2025-09-19 11:43:54 +03:00
Egor 62ebbb2b5f Merge pull request #125 from Fr1ngg/dev_test
Возможность указывать ссылку на саппорт, а не только юзернейм
2025-09-19 11:42:02 +03:00
Egor e62caa725f Merge pull request #124 from Fr1ngg/codex/allow-links-in-support-section
Allow support contact to use URLs
2025-09-19 11:38:25 +03:00
Egor db5f81e6be Allow support contact to use URLs 2025-09-19 11:38:11 +03:00
Egor 15cba3da22 Merge pull request #123 from Fr1ngg/dev_test
Сохранение истории покупки в кеше + предложение продолжить покупку после пополнения баланса
2025-09-19 11:16:11 +03:00
Egor ca93182f68 Merge pull request #122 from Fr1ngg/codex/update-bot-to-return-to-order-summary-6na3g2
Show resume checkout option after Tribute top-ups
2025-09-19 11:08:53 +03:00
Egor b1a2b6774d Show resume checkout option after Tribute top-ups 2025-09-19 11:08:41 +03:00
yazhog ebc98099d3 Merge pull request #33 from yazhog/codex/refactor-localization-for-configurable-default-language
Use configurable default language
2025-09-19 10:58:07 +03:00
yazhog e2137f94f8 Use configurable default language 2025-09-19 10:57:49 +03:00
Egor 3fdc081548 Merge pull request #121 from Fr1ngg/codex/update-bot-to-return-to-order-summary-qgf9b5
Ensure subscription checkout resume after top-ups
2025-09-19 10:54:33 +03:00
Egor 57cc2d2e3a Fix subscription checkout resume after top-ups 2025-09-19 10:54:16 +03:00
Egor b883d2a95c Merge pull request #120 from Fr1ngg/codex/update-bot-to-return-to-order-summary
Add subscription checkout resume support
2025-09-19 10:40:22 +03:00
Egor cfcb35c7ee Add subscription checkout draft resume support 2025-09-19 10:40:05 +03:00
gy9vin 7f4899b885 Добавлена фильтрация пользователей по балансу 2025-09-19 08:40:29 +03:00
Egor 719d656a64 Update docker-registry.yml 2025-09-19 05:39:04 +03:00
Egor 04476ed463 Update docker-hub.yml 2025-09-19 05:38:46 +03:00
Egor 7ef7b1d107 Update Dockerfile 2025-09-19 05:10:54 +03:00
Egor 0cb9ab5704 Update docker-registry.yml 2025-09-19 05:10:45 +03:00
Egor 4d109b948a Update docker-hub.yml 2025-09-19 05:10:33 +03:00
Egor efd8b191ff Update subscription.py 2025-09-19 04:46:31 +03:00
Egor 8273a31512 Update inline.py 2025-09-19 04:41:44 +03:00
Egor 7baebd85c7 Update balance.py 2025-09-19 04:40:56 +03:00
Egor e43f6afd8a Update subscription.py 2025-09-19 04:39:24 +03:00
Egor ec3430db27 Update states.py 2025-09-19 04:34:32 +03:00
Egor a2c0551349 Merge pull request #106 from Fr1ngg/dependabot/pip/python-dependencies-14836bb5a9
deps(deps): bump the python-dependencies group with 12 updates
2025-09-19 03:46:55 +03:00
Egor a66719b865 Merge pull request #105 from Fr1ngg/dependabot/docker/python-3.13-slim
docker(deps): bump python from 3.11-slim to 3.13-slim
2025-09-19 03:46:12 +03:00
Egor b201543014 Merge pull request #104 from Fr1ngg/dependabot/github_actions/github-actions-e8a2446fba
ci(deps): bump the github-actions group with 2 updates
2025-09-19 03:45:55 +03:00
Egor e269f31056 Update dependabot.yml 2025-09-19 03:44:57 +03:00
dependabot[bot] 0c1db66be6 deps(deps): bump the python-dependencies group with 12 updates
---
updated-dependencies:
- dependency-name: aiogram
  dependency-version: 3.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiohttp
  dependency-version: 3.12.15
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: asyncpg
  dependency-version: 0.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.16.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiosqlite
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dotenv
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: apscheduler
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dateutil
  dependency-version: 2.9.0.post0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
...

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

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

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


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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:42:59 +00:00
Egor 86ec46dbe7 Create dependabot.yml 2025-09-19 03:42:10 +03:00
Egor 863b5df0c2 Delete .github/workflows/dependabot.yml 2025-09-19 03:41:41 +03:00
Egor 929b3b8e95 Merge pull request #101 from Gy9vin/dev
Добавлена функция покупки подписки администратором с преобразованием …
2025-09-19 03:40:20 +03:00
Egor f79393c46e Create dependabot.yml 2025-09-19 03:38:52 +03:00
Egor 559dd1df71 Update docker-registry.yml 2025-09-19 03:30:37 +03:00
Egor cb959bdee7 Update docker-hub.yml 2025-09-19 03:30:14 +03:00
Egor ac1b0ec8b3 Update users.py 2025-09-19 03:25:06 +03:00
yazhog fe779a29b4 Merge branch 'Fr1ngg:main' into feature/locale 2025-09-19 01:12:27 +03:00
yazhog b4a1d10037 Промежутки.. 2025-09-19 01:04:43 +03:00
gy9vin 03856d30ec Добавлена функция покупки подписки администратором с преобразованием триала в безлимитную подписку 2025-09-18 23:50:01 +03:00
yazhog e2e3e444ec Промежуточный коммит 2025-09-18 22:05:20 +03:00
Egor cd98d150d6 Merge pull request #100 from Fr1ngg/dev
Починка покупки подписки
2025-09-18 16:29:07 +03:00
Egor 92814ae535 Update subscription.py 2025-09-18 16:22:22 +03:00
Egor 3268c2a509 Update user_utils.py 2025-09-18 16:20:06 +03:00
Egor a09b6c1431 Update subscription.py 2025-09-18 16:19:07 +03:00
yazhog 31c560093d Промежуточный этап локализации 2025-09-18 09:09:55 +03:00
Egor 728bd0450d Merge pull request #95 from Legacyyy777/DEV
№6
2025-09-18 03:13:43 +03:00
Egor 3df3fdbcfe Merge pull request #99 from Fr1ngg/dev
Dev
2025-09-18 03:13:00 +03:00
Egor 296756dac4 Merge pull request #96 from Gy9vin/dev
Пополнение баланса кнопками
2025-09-18 03:11:39 +03:00
Egor 7cb766f3d0 Merge pull request #98 from yazhog/main
Фикс отправки кнопок в рассылках, скрытие в детализации подписки нулевых значений за сервера/траффик
2025-09-18 03:10:49 +03:00
Egor 5e94b287b9 Delete .github/workflows/deploy.yml 2025-09-18 03:08:50 +03:00
yazhog e9eb8f17d3 Merge pull request #31 from yazhog/codex/remove-zero-cost-server-traffic-options-from-calculations
Hide zero-cost options from subscription pricing breakdown
2025-09-18 00:46:18 +03:00
yazhog dfbaf9de21 Filter zero-cost subscription pricing details 2025-09-18 00:37:02 +03:00
yazhog 4dcca0da4a Merge pull request #30 from yazhog/codex/replace-button-text-from-to
Rename referrals button text to Партнерка
2025-09-18 00:33:04 +03:00
yazhog ea85d4f62e Rename referrals button to partnerka 2025-09-18 00:30:01 +03:00
yazhog bef1a3ecd9 Merge pull request #29 from yazhog/codex/analyze-button-attachment-to-message-delivery
Make home button optional in broadcasts
2025-09-18 00:07:50 +03:00
yazhog 22be75508e Add optional broadcast buttons for connect, subscription, and support 2025-09-17 22:51:18 +03:00
yazhog dba8241c5c Make home button optional in broadcasts 2025-09-17 22:19:55 +03:00
gy9vin b3d0348821 2 кнопки баланса в ряду 2025-09-17 18:54:20 +03:00
gy9vin 3006a438e2 Merge branch 'dev' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot into dev 2025-09-17 18:34:24 +03:00
gy9vin d2f74481cd Добавление кнопок пополнения с суммой подписки 2025-09-17 18:31:21 +03:00
Legacyyy777 d29ad223a7 Добавлено уведомление пользователю о пополнении/списании баланса. Реализована отправка сообщений через Telegram-бота с информацией о транзакции и администраторе, выполнившем операцию. 2025-09-17 19:22:43 +05:00
Legacyyy777 c62ccd0790 Merge branch 'Fr1ngg:main' into DEV 2025-09-17 18:14:03 +05:00
Egor 8ff240cbe9 Add files via upload 2025-09-17 16:06:22 +03:00
Egor 4a5769d37e Create docker-hub.yml 2025-09-17 16:05:51 +03:00
Egor 7d27ee7937 Delete .github/workflows directory 2025-09-17 16:04:00 +03:00
Egor a927a6fa70 Create docker-registry.yml 2025-09-17 16:02:47 +03:00
Egor be4db68d00 Delete .github/ISSUE_TEMPLATE/bug_report.md 2025-09-17 16:01:36 +03:00
Egor 6e3807cf78 Delete .github/ISSUE_TEMPLATE/custom.md 2025-09-17 16:01:28 +03:00
Egor ff9adfc30f Delete .github/ISSUE_TEMPLATE/feature_request.md 2025-09-17 16:01:18 +03:00
Egor a6037b60eb Update yookassa_service.py 2025-09-17 15:58:27 +03:00
Egor a0b95cc24c Update payment_service.py 2025-09-17 15:57:27 +03:00
Egor a2536b576e Update inline.py 2025-09-17 15:55:39 +03:00
Egor 0e544040fc Update balance.py 2025-09-17 15:54:58 +03:00
Egor 940957fa98 Update config.py 2025-09-17 15:52:29 +03:00
Legacyyy777 2e6b808f6d Merge branch 'Fr1ngg:main' into DEV 2025-09-17 17:10:15 +05:00
Egor 9ff4734659 Merge pull request #93 from Fr1ngg/main
к
2025-09-17 15:09:21 +03:00
Egor 7a08f483b9 Merge pull request #91 from Fr1ngg/dev
Dev
2025-09-17 14:47:06 +03:00
Egor c2b10e969e Add files via upload 2025-09-17 14:36:29 +03:00
Egor 7bf9b10963 Update user_utils.py 2025-09-17 14:31:44 +03:00
Egor 6402cbef06 Update subscription_service.py 2025-09-17 14:28:42 +03:00
Legacyyy777 b8855bf73b Merge branch 'DEV' of https://github.com/Legacyyy777/remnawave-bedolaga-telegram-bot_777 into DEV 2025-09-17 16:10:19 +05:00
Legacyyy777 5453efadb5 Добавлен файл конфигурации для автоматического деплоя бота на VPS при пуше в ветку DEV. 2025-09-17 16:07:56 +05:00
Egor 8246405c31 Merge pull request #88 from Gy9vin/dev
Добавление СБП отдельной кнопкой для yookassa и указание кастомных сумм для yookasse
2025-09-17 14:01:58 +03:00
Egor 777f16f3c1 Delete Dockerfile 2025-09-17 13:53:01 +03:00
Egor 11860ff21a Delete .github/workflows/docker-registry.yml 2025-09-17 13:52:43 +03:00
Egor 0a6df56dd2 Delete .github/workflows/docker-hub.yml 2025-09-17 13:52:27 +03:00
Egor 9dd643ecab Режим удаления пользователей из панели Remnawave by @Legacyyy777
№5
2025-09-17 13:46:27 +03:00
Egor d8d01384b1 Delete .github/workflows/deploy.yml 2025-09-17 13:44:22 +03:00
Legacyyy777 d0b3bbd488 Update .env.example 2025-09-17 14:29:22 +05:00
Legacyyy777 e8c458f2c4 Добавлен новый параметр REMNAWAVE_USER_DELETE_MODE в конфигурацию и обновлен процесс удаления пользователя RemnaWave с учетом выбранного режима удаления или деактивации. Обработаны возможные ошибки при взаимодействии с Remnawave. 2025-09-17 14:25:37 +05:00
Legacyyy777 e324e50713 Изменен процесс удаления пользователя RemnaWave: добавлена попытка удаления пользователя из панели Remnawave с обработкой ошибок и резервным вариантом деактивации через SubscriptionService. 2025-09-17 14:04:55 +05:00
Legacyyy777 d506ba7317 Добавлен файл конфигурации для деплоя бота на VPS при пуше в ветку DEV 2025-09-17 13:46:37 +05:00
Legacyyy777 2c960813de restore deploy.yml for my fork deploy 2025-09-17 13:38:39 +05:00
gy9vin 1d98bc75b0 Fix gpg 2025-09-17 11:31:31 +03:00
gy9vin 19dddb3cc0 changing the minimum and maximum amount for yookassa 2025-09-17 11:26:25 +03:00
gy9vin 863dd2423d adding the SBP method for YooKassa 2025-09-17 10:48:58 +03:00
Egor 4cc5e92b83 Update backup_service.py 2025-09-17 03:49:21 +03:00
61 changed files with 10767 additions and 2526 deletions
+23
View File
@@ -5,6 +5,7 @@
# ===== TELEGRAM BOT =====
BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# Уведомления администраторов
@@ -33,6 +34,7 @@ POSTGRES_PASSWORD=secure_password_123
# SQLite настройки (для локального запуска)
SQLITE_PATH=./data/bot.db
LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
@@ -59,6 +61,11 @@ REMNAWAVE_SECRET_KEY=
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
@@ -106,6 +113,11 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
@@ -147,6 +159,9 @@ YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Включить оплату через СБП (Систему быстрых платежей)
YOOKASSA_SBP_ENABLED=false
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
@@ -188,6 +203,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
@@ -251,6 +273,7 @@ MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
-10
View File
@@ -1,10 +0,0 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+71
View File
@@ -0,0 +1,71 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 10
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "python"
allow:
- dependency-type: "all"
groups:
python-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "10:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 5
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "ci"
include: "scope"
labels:
- "dependencies"
- "github-actions"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "tuesday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 3
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "docker"
include: "scope"
labels:
- "dependencies"
- "docker"
+5 -5
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -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.3.4-$(git rev-parse --short HEAD)"
VERSION="v2.3.7-$(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.3.4-dev-$(git rev-parse --short HEAD)"
VERSION="v2.3.7-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.3.4-pr-$(git rev-parse --short HEAD)"
VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
@@ -68,7 +68,7 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
+31 -44
View File
@@ -24,18 +24,15 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -50,31 +47,26 @@ jobs:
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.3.4"
echo "🚀 Собираем версию из main: $VERSION"
VERSION="v2.3.7-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ PR из того же репозитория - будем пушить"
else
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR из внешнего форка - только build без push"
fi
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR - only build without push"
else
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ Push/Tag - будем пушить"
echo "✅ Push/Tag - will push"
fi
- name: Extract metadata
@@ -93,11 +85,11 @@ jobs:
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
push: ${{ steps.version.outputs.should_push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -105,18 +97,12 @@ jobs:
VERSION=${{ steps.version.outputs.version }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
VCS_REF=${{ steps.version.outputs.short_sha }}
cache-from: |
type=gha
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-contexts: |
alpine=docker-image://alpine:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
if: github.event_name == 'pull_request'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
@@ -125,30 +111,31 @@ jobs:
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Build Summary
if: steps.version.outputs.should_push == 'true'
run: |
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY
- name: Build Summary (No Push)
if: steps.version.outputs.should_push == 'false'
run: |
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY
+2
View File
@@ -12,6 +12,8 @@
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!locales/
!locales/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim AS builder
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
@@ -12,9 +12,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
FROM python:3.13-slim
ARG VERSION="v2.3.4"
ARG VERSION="v2.3.7"
ARG BUILD_DATE
ARG VCS_REF
+112 -61
View File
@@ -159,6 +159,12 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
@@ -229,6 +235,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== TELEGRAM BOT =====
BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# Уведомления администраторов
@@ -330,6 +337,11 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
@@ -371,6 +383,9 @@ YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Включить оплату через СБП (Систему быстрых платежей)
YOOKASSA_SBP_ENABLED=false
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
@@ -475,6 +490,7 @@ MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
@@ -533,35 +549,37 @@ WEBHOOK_PATH=/webhook
🛒 **Умная покупка подписок**
- 📅 Гибкие периоды (14-360 дней)
- 📊 Выбор трафика или фиксированный лимит
- 🌍 Автоматический выбор серверов(для одного сквада) или ручной выбор из множества
- 📱 Настройка количества устройств
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
- 📱 Настройка количества устройств и серверов прямо при оформлении
- 🧾 Понятный калькулятор стоимости с учетом всех опций
🧪 **Тестовая подписка**
- Настраиваемый триал-период
- Уведомления об истечении
- Плавный переход на платную версию
- Приветственное сообщение с предложением активации триала (С возможностью отключения)
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
💰 **Удобные платежи**
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa
- 💰 CryptoBot
- 🎁 Реферальные бонусы
- Детальная история транзакций
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa (включая СБП и онлайн-чек)
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
- 🎁 Реферальные и промо-бонусы
- Детальная история транзакций и чеков
📱 **Управление подписками**
- 📈 Статистика использования в реальном времени (Используемый трафик, подключенные устройства)
- 🔄 Автопродление с баланса
- 🔄 Управление трафиком
- 🌍 Переключение серверов на лету
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
- 🔄 Управление трафиком и лимитами без участия оператора
- 🌍 Переключение серверов Remnawave на лету
- 📱 Управление устройствами + сброс HWID
- 🧩 Смена языка и персональных настроек
🎁 **Бонусная система**
- 🎫 Промокоды на деньги/дни/триал
- 👥 Защищенная реферальная программа с комиссиями
- 🔔 Своевременные уведомления
- 📊 Детальная статистика рефералов
- 🎫 Промокоды на деньги/дни/длинный триал
- 👥 Защищенная реферальная программа с комиссиями и антифродом
- 📊 Детальная статистика рефералов и кампаний
- 📨 Персональные рассылки и уведомления о новых бонусах
</td>
<td width="50%" valign="top">
@@ -569,65 +587,98 @@ WEBHOOK_PATH=/webhook
### ⚙️ **Для администраторов**
📊 **Мощная аналитика**
- 👥 Детальная статистика пользователей
- 💰 Анализ подписок и платежей
- 🖥️ Мониторинг серверов Remnawave
- 📈 Финансовые отчеты и тренды
- 👥 Детальная статистика пользователей и подписок
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot)
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
👥 **Управление пользователями**
- 🔍 Поиск и редактирование профилей
- 💰 Управление балансами
- 💰 Управление балансами и ручные начисления
- 📱 Изменение лимита устройств (1-X)
- 📊 Настройка лимитов трафика (0-10000 ГБ)
- 🌍 Мультивыбор серверов
- 🔄 Сброс HWID устройств
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
- 🔄 Сброс HWID устройств и обновление конфигураций
- 👥 Детальная реферальная статистика в профиле пользователя
- 🔄 **НОВОЕ**: Изминение типа подписки триал/платная
- 🚫 Блокировка/разблокировка/удаление
- 🔄 Изменение типа подписки триал/платная и ручное продление
- 🚫 Блокировка/разблокировка/удаление аккаунтов
🎫 **Промо-система**
- 🎁 Создание промокодов (деньги/дни/длинный триал)
- 📊 Детальная статистика использования
- ⚙️ Полное редактирование промокодов
🎯 **Маркетинг и продажи**
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
- 📊 Детальная статистика использования промокодов и кампаний
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
🖥️ **Умный мониторинг**
- 💚 Состояние Remnawave панели в реальном времени
- 🔄 Автоматическая синхронизация данных
- 🌐 Управление сквадами с актуальным статусом
- 🚧 **Автоматический режим тех. работ**
- 📋 Логи и диагностика
- 💚 Контроль доступности Remnawave панели в реальном времени
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
- 📦 Проверка обновлений проекта и уведомления о новых релизах
📨 **Коммуникации**
- 📢 Рассылки по сегментам
- 🔔 Автоуведомления о продлении
- 💬 Система поддержки с HTML разметкой
- 📝 Настройка правил сервиса
📨 **Коммуникации и поддержка**
- 📢 Рассылки по сегментам и мгновенные оповещения
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
- 💬 Система поддержки с HTML разметкой и история обращений
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
📨 **Уведомления в закрытый канал**
- 🎯 Активация триала
- 💎 Покупка подписки
- 🔄 Конверсия из триала в платную
- Продление подписки
- 💰 Пополнение баланса
- ♻️ Выход обновлений бота
- 🚧 Потеря соелинения с апи Remnawave
- 🗄️ **Бекапы/Восстановление бд**
- 🗄️ Отправка бд файлов в отдельный чат/топики
- ⚙️ Изменение параметров подписки юзером (Уменьшение/Увеличение трафика/серверов/устройств на подписке)
- 🎯 Активация триала, продления и конверсии в платную подписку
- 💎 Покупка подписки и пополнение баланса
- 🔄 Изменения параметров подписки пользователем
- 🚧 Потеря соединения с Remnawave и включение техработ
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
🗄️ **Бекапы/Восстановление**
- Ручной запуск бекапа
- Восстановление бд
- Включение/Отключение автобекапов
- Отправка копии бд файла в отдеьный чат/топик
🗄️ **Бекапы и восстановление**
- Ручной запуск бекапа с выбором содержимого (включая логи)
- Автоматические бекапы по расписанию и управление retention-политикой
- Восстановление из бекапа без остановки сервиса
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
💳 **Автоплатежи и биллинг**
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
- Принудительная проверка готовности автосписаний
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
- Настройка минимального баланса и логирование всех списаний
</td>
</tr>
</table>
### 🤖 Автоматизация и экосистема
- 🔄 **Мониторинг Remnawave** — регулярная проверка API, автоматическое включение/выключение техработ и сообщения в топики.
- 🛡️ **Антифрод** — валидация обязательной подписки на канал, проверка уникальности устройств и логирование действий.
- 🧠 **Асинхронная архитектура** — aiogram 3, PostgreSQL/SQLite, Redis и очередь задач для стабильной работы под нагрузкой.
- 🌐 **Мультиязычность** — локализации в `locales/`, быстрый выбор языка пользователем, готовность к расширению.
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
### 🧭 Навигация по проекту
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
- `app/handlers/` — сценарии общения (пользовательские и административные).
- `app/services/` — бизнес-логика: подписки, платежи, мониторинг, бекапы, кампании, версия.
- `app/database/` — модели SQLAlchemy, CRUD и миграции Alembic.
- `app/localization/` и `locales/` — тексты интерфейса и переводов.
- `migrations/` — текущие миграции БД для PostgreSQL.
## Локализации
- Каталог `locales/` содержит файлы локализаций в формате `.yml` или `.json` (например, `ru.yml`, `en.yml`). Эти файлы можно редактировать напрямую на рабочем сервере.
- Структура YAML/JSON повторяет иерархию ключей, объявленных в `app/localization/texts.py`. Например, блок `MENU:` и пара `BALANCE: "Баланс"` соответствуют ключу `MENU_BALANCE`. При добавлении новых ключей сохраняйте верхний регистр и единый стиль именования.
- Порядок внесения правок:
1. Измените нужные значения в файле `locales/<язык>.yml` (или `.json`).
2. Перезапустите бота/контейнер (`docker compose restart bot` в Docker) — при старте приложение перечитает локали.
- Путь до пользовательских локалей задаётся переменной `LOCALES_PATH` (`./locales` по умолчанию; в Docker каталог монтируется в `/app/locales`, см. `docker-compose.yml`).
- При первом запуске, если каталог пуст, бот копирует шаблоны `ru.yml` и `en.yml` — благодаря этому у владельца сразу есть заготовки для редактирования.
---
## 🐳 Docker развертывание
### 📄 docker-compose.yml
@@ -887,7 +938,7 @@ bedolaga_bot/
│ │ ├── 💳 cryptobot.py # CryptoBot операции
│ │ ├── 🌐 server_squad.py # Серверы и сквады
│ │ ├── 🎁 promocode.py # Промокоды
│ │ └── 👥 referral.py # Рефералы
│ │ └── 👥 referral.py # Партнерка
│ │
│ ├── 🔧 services/ # Бизнес-логика
│ │ ├── 👤 user_service.py # Сервис пользователей
@@ -896,7 +947,7 @@ bedolaga_bot/
│ │ ├── 🗄️ backup_service.py # Бекапы
│ │ ├── 🎁 promocode_service.py # Промокоды
│ │ ├── 🚧 maintenance_service.py # Промокоды
│ │ ├── 👥 referral_service.py # Рефералы
│ │ ├── 👥 referral_service.py # Партнерка
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
│ │ ├── 🔍 monitoring_service.py # Мониторинг
│ │ ├── ♻️ version_service.py # Проверка версий бота
@@ -1060,7 +1111,7 @@ your-domain.com {
3. **💰 Баланс** → "💰 Баланс" → пополни через Stars/Tribute/YooKassa
4. **🛒 Подписка** → "🛒 Купить подписку" → выбор тарифа → оплата
5. **📱 Управление** → "📋 Мои подписки" → конфигурация → получение ссылки
6. **👥 Рефералы** → "👥 Рефералы" → поделись ссылкой
6. **👥 Партнерка** → "👥 Партнерка" → поделись ссылкой
### ⚙️ **Для администраторов**
+18 -7
View File
@@ -19,15 +19,24 @@ from app.handlers import (
referral, support, common
)
from app.handlers.admin import (
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
promocodes as admin_promocodes, messages as admin_messages,
monitoring as admin_monitoring, referrals as admin_referrals,
rules as admin_rules, remnawave as admin_remnawave,
statistics as admin_statistics, servers as admin_servers,
main as admin_main,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
messages as admin_messages,
monitoring as admin_monitoring,
referrals as admin_referrals,
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
user_messages as admin_user_messages,
updates as admin_updates, backup as admin_backup,
welcome_text as admin_welcome_text
updates as admin_updates,
backup as admin_backup,
welcome_text as admin_welcome_text,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -119,6 +128,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
+151 -10
View File
@@ -1,5 +1,6 @@
import os
import re
import html
from collections import defaultdict
from typing import List, Optional, Union, Dict
from pydantic_settings import BaseSettings
@@ -30,6 +31,7 @@ class Settings(BaseSettings):
POSTGRES_PASSWORD: str = "secure_password_123"
SQLITE_PATH: str = "./data/bot.db"
LOCALES_PATH: str = "./locales"
DATABASE_MODE: str = "auto"
@@ -43,6 +45,7 @@ class Settings(BaseSettings):
REMNAWAVE_PASSWORD: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -61,7 +64,6 @@ class Settings(BaseSettings):
NOTIFICATION_CACHE_HOURS: int = 24
BASE_SUBSCRIPTION_PRICE: int = 50000
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
PRICE_14_DAYS: int = 50000
@@ -70,7 +72,7 @@ class Settings(BaseSettings):
PRICE_90_DAYS: int = 269000
PRICE_180_DAYS: int = 499000
PRICE_360_DAYS: int = 899000
PRICE_TRAFFIC_5GB: int = 2000
PRICE_TRAFFIC_10GB: int = 3500
PRICE_TRAFFIC_25GB: int = 7000
@@ -82,9 +84,12 @@ class Settings(BaseSettings):
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
@@ -124,12 +129,16 @@ class Settings(BaseSettings):
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_SBP_ENABLED: bool = False
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -259,6 +268,11 @@ class Settings(BaseSettings):
"password": self.REMNAWAVE_PASSWORD,
"auth_type": self.REMNAWAVE_AUTH_TYPE
}
def get_remnawave_user_delete_mode(self) -> str:
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip()
return mode if mode in ["delete", "disable"] else "delete"
def format_remnawave_user_description(
self,
@@ -388,7 +402,46 @@ class Settings(BaseSettings):
def get_maintenance_check_interval(self) -> int:
return self.MAINTENANCE_CHECK_INTERVAL
def is_base_promo_group_period_discount_enabled(self) -> bool:
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
def get_base_promo_group_period_discounts(self) -> Dict[int, int]:
try:
config_str = (self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS or "").strip()
if not config_str:
return {}
discounts: Dict[int, int] = {}
for part in config_str.split(','):
part = part.strip()
if not part:
continue
period_and_discount = part.split(':')
if len(period_and_discount) != 2:
continue
period_str, discount_str = period_and_discount
try:
period_days = int(period_str.strip())
discount_percent = int(discount_str.strip())
except ValueError:
continue
discounts[period_days] = max(0, min(100, discount_percent))
return discounts
except Exception:
return {}
def get_base_promo_group_period_discount(self, period_days: Optional[int]) -> int:
if not period_days or not self.is_base_promo_group_period_discount_enabled():
return 0
discounts = self.get_base_promo_group_period_discounts()
return discounts.get(period_days, 0)
def is_maintenance_auto_enable(self) -> bool:
return self.MAINTENANCE_AUTO_ENABLE
@@ -593,12 +646,100 @@ class Settings(BaseSettings):
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
]
def get_traffic_price(self, gb: int) -> int:
def get_traffic_price(self, gb: Optional[int]) -> int:
packages = self.get_traffic_packages()
for package in packages:
if package["gb"] == gb and package["enabled"]:
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
if not enabled_packages:
return 0
if gb is None:
gb = 0
for package in enabled_packages:
if package["gb"] == gb:
return package["price"]
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
if gb <= 0:
return unlimited_package["price"] if unlimited_package else 0
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
if not finite_packages:
return unlimited_package["price"] if unlimited_package else 0
max_package = max(finite_packages, key=lambda x: x["gb"])
if gb >= max_package["gb"]:
return unlimited_package["price"] if unlimited_package else max_package["price"]
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
if suitable_packages:
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
return nearest_package["price"]
return unlimited_package["price"] if unlimited_package else 0
def _clean_support_contact(self) -> str:
return (self.SUPPORT_USERNAME or "").strip()
def get_support_contact_url(self) -> Optional[str]:
contact = self._clean_support_contact()
if not contact:
return None
if contact.startswith(("http://", "https://", "tg://")):
return contact
contact_without_prefix = contact.lstrip("@")
if contact_without_prefix.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
return f"https://{contact_without_prefix}"
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
return f"https://{contact}"
if "." in contact_without_prefix:
return f"https://{contact_without_prefix}"
if contact_without_prefix:
return f"https://t.me/{contact_without_prefix}"
return None
def get_support_contact_display(self) -> str:
contact = self._clean_support_contact()
if not contact:
return ""
if contact.startswith("@"):
return contact
if contact.startswith(("http://", "https://", "tg://")):
return contact
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
url = self.get_support_contact_url()
return url if url else contact
contact_without_prefix = contact.lstrip("@")
if "." in contact_without_prefix:
url = self.get_support_contact_url()
return url if url else contact
if re.fullmatch(r"[A-Za-z0-9_]{3,}", contact_without_prefix):
return f"@{contact_without_prefix}"
return contact
def get_support_contact_display_html(self) -> str:
return html.escape(self.get_support_contact_display())
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
+258
View File
@@ -0,0 +1,258 @@
import logging
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy import and_, func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
)
logger = logging.getLogger(__name__)
async def create_campaign(
db: AsyncSession,
*,
name: str,
start_parameter: str,
bonus_type: str,
created_by: Optional[int] = None,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
start_parameter=start_parameter,
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
subscription_traffic_gb=subscription_traffic_gb,
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
created_by=created_by,
is_active=True,
)
db.add(campaign)
await db.commit()
await db.refresh(campaign)
logger.info(
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
)
return campaign
async def get_campaign_by_id(
db: AsyncSession, campaign_id: int
) -> Optional[AdvertisingCampaign]:
result = await db.execute(
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.where(AdvertisingCampaign.id == campaign_id)
)
return result.scalar_one_or_none()
async def get_campaign_by_start_parameter(
db: AsyncSession,
start_parameter: str,
*,
only_active: bool = False,
) -> Optional[AdvertisingCampaign]:
stmt = select(AdvertisingCampaign).where(
AdvertisingCampaign.start_parameter == start_parameter
)
if only_active:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_campaigns_list(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 20,
include_inactive: bool = True,
) -> List[AdvertisingCampaign]:
stmt = (
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
.limit(limit)
)
if not include_inactive:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
result = await db.execute(stmt)
return result.scalars().all()
async def get_campaigns_count(
db: AsyncSession, *, is_active: Optional[bool] = None
) -> int:
stmt = select(func.count(AdvertisingCampaign.id))
if is_active is not None:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
result = await db.execute(stmt)
return result.scalar_one() or 0
async def update_campaign(
db: AsyncSession,
campaign: AdvertisingCampaign,
**kwargs,
) -> AdvertisingCampaign:
allowed_fields = {
"name",
"start_parameter",
"bonus_type",
"balance_bonus_kopeks",
"subscription_duration_days",
"subscription_traffic_gb",
"subscription_device_limit",
"subscription_squads",
"is_active",
}
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
if not update_data:
return campaign
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.id == campaign.id)
.values(**update_data)
)
await db.commit()
await db.refresh(campaign)
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
return campaign
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
await db.execute(
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
)
await db.commit()
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
return True
async def record_campaign_registration(
db: AsyncSession,
*,
campaign_id: int,
user_id: int,
bonus_type: str,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
) -> AdvertisingCampaignRegistration:
existing = await db.execute(
select(AdvertisingCampaignRegistration).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.user_id == user_id,
)
)
)
registration = existing.scalar_one_or_none()
if registration:
return registration
registration = AdvertisingCampaignRegistration(
campaign_id=campaign_id,
user_id=user_id,
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
)
db.add(registration)
await db.commit()
await db.refresh(registration)
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
return registration
async def get_campaign_statistics(
db: AsyncSession,
campaign_id: int,
) -> Dict[str, Optional[int]]:
result = await db.execute(
select(
func.count(AdvertisingCampaignRegistration.id),
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
),
func.max(AdvertisingCampaignRegistration.created_at),
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
)
count, total_balance, last_registration = result.one()
subscription_count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.bonus_type == "subscription",
)
)
)
return {
"registrations": count or 0,
"balance_issued": total_balance or 0,
"subscription_issued": subscription_count_result.scalar() or 0,
"last_registration": last_registration,
}
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
total = await get_campaigns_count(db)
active = await get_campaigns_count(db, is_active=True)
inactive = await get_campaigns_count(db, is_active=False)
registrations_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id))
)
balance_result = await db.execute(
select(
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
)
)
)
subscription_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == "subscription"
)
)
return {
"total": total,
"active": active,
"inactive": inactive,
"registrations": registrations_result.scalar() or 0,
"balance_total": balance_result.scalar() or 0,
"subscription_total": subscription_result.scalar() or 0,
}
+145
View File
@@ -0,0 +1,145 @@
import logging
from typing import List, Optional, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
logger = logging.getLogger(__name__)
async def get_promo_groups_with_counts(
db: AsyncSession,
) -> List[Tuple[PromoGroup, int]]:
result = await db.execute(
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)
)
return result.all()
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
return await db.get(PromoGroup, group_id)
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
result = await db.execute(
select(PromoGroup).where(PromoGroup.is_default.is_(True))
)
return result.scalars().first()
async def create_promo_group(
db: AsyncSession,
name: str,
*,
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
) -> PromoGroup:
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
is_default=False,
)
db.add(promo_group)
await db.commit()
await db.refresh(promo_group)
logger.info(
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
promo_group.name,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
)
return promo_group
async def update_promo_group(
db: AsyncSession,
group: PromoGroup,
*,
name: Optional[str] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
if server_discount_percent is not None:
group.server_discount_percent = max(0, min(100, server_discount_percent))
if traffic_discount_percent is not None:
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
if device_discount_percent is not None:
group.device_discount_percent = max(0, min(100, device_discount_percent))
await db.commit()
await db.refresh(group)
logger.info(
"Обновлена промогруппа '%s' (id=%s)",
group.name,
group.id,
)
return group
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
if group.is_default:
logger.warning("Попытка удалить базовую промогруппу запрещена")
return False
default_group = await get_default_promo_group(db)
if not default_group:
logger.error("Не найдена базовая промогруппа для reassignment")
return False
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
await db.delete(group)
await db.commit()
logger.info(
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
group.name,
group.id,
default_group.name,
)
return True
async def get_promo_group_members(
db: AsyncSession,
group_id: int,
*,
offset: int = 0,
limit: int = 20,
) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.where(User.promo_group_id == group_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
result = await db.execute(
select(func.count(User.id)).where(User.promo_group_id == group_id)
)
return result.scalar_one()
+253 -46
View File
@@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Subscription, SubscriptionStatus, User,
SubscriptionServer
Subscription,
SubscriptionStatus,
User,
SubscriptionServer,
PromoGroup,
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
@@ -442,6 +445,8 @@ async def add_subscription_servers(
server_squad_ids: List[int],
paid_prices: List[int] = None
) -> Subscription:
await db.refresh(subscription)
if paid_prices is None:
months_remaining = get_remaining_months(subscription.end_date)
paid_prices = []
@@ -458,7 +463,7 @@ async def add_subscription_servers(
for i, server_id in enumerate(server_squad_ids):
subscription_server = SubscriptionServer(
subscription_id=subscription.id,
subscription_id=subscription.id,
server_squad_id=server_id,
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
)
@@ -467,7 +472,7 @@ async def add_subscription_servers(
await db.commit()
await db.refresh(subscription)
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
logger.info(f"🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
return subscription
async def get_server_monthly_price(
@@ -493,12 +498,34 @@ async def get_servers_monthly_prices(
prices.append(price)
return prices
def _get_discount_percent(
user: Optional[User],
promo_group: Optional[PromoGroup],
category: str,
*,
period_days: Optional[int] = None,
) -> int:
if user is not None:
try:
return user.get_promo_discount(category, period_days)
except AttributeError:
pass
if promo_group is not None:
return promo_group.get_discount_percent(category, period_days)
return 0
async def calculate_subscription_total_cost(
db: AsyncSession,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
devices: int
devices: int,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> Tuple[int, dict]:
from app.config import PERIOD_PRICES
@@ -506,36 +533,98 @@ async def calculate_subscription_total_cost(
base_price = PERIOD_PRICES.get(period_days, 0)
promo_group = promo_group or (user.promo_group if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
servers_price_per_month = sum(servers_prices)
total_servers_price = servers_price_per_month * months_in_period
servers_discount_percent = _get_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_price = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
devices_discount_percent = _get_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
total_devices_discount = devices_discount_per_month * months_in_period
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
details = {
'base_price': base_price,
'traffic_price_per_month': traffic_price_per_month,
'traffic_discount_percent': traffic_discount_percent,
'traffic_discount_total': total_traffic_discount,
'total_traffic_price': total_traffic_price,
'servers_price_per_month': servers_price_per_month,
'servers_discount_percent': servers_discount_percent,
'servers_discount_total': total_servers_discount,
'total_servers_price': total_servers_price,
'devices_price_per_month': devices_price_per_month,
'devices_discount_percent': devices_discount_percent,
'devices_discount_total': total_devices_discount,
'total_devices_price': total_devices_price,
'months_in_period': months_in_period,
'servers_individual_prices': [price * months_in_period for price in servers_prices]
'servers_individual_prices': [
(price - (price * servers_discount_percent // 100)) * months_in_period
for price in servers_prices
]
}
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Базовый период: {base_price/100}")
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
if total_traffic_price > 0:
message = (
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}"
)
if total_traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = (
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}"
)
if total_servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
)
logger.info(message)
if total_devices_price > 0:
message = (
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}"
)
if total_devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_cost/100}")
return total_cost, details
@@ -609,19 +698,33 @@ async def remove_subscription_servers(
async def get_subscription_renewal_cost(
db: AsyncSession,
subscription_id: int,
period_days: int
period_days: int,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
subscription = await db.get(Subscription, subscription_id)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
)
.where(Subscription.id == subscription_id)
)
subscription = result.scalar_one_or_none()
if not subscription:
return base_price
if user is None:
user = subscription.user
promo_group = promo_group or (user.promo_group if user else None)
servers_info = await get_subscription_servers(db, subscription_id)
servers_price_per_month = 0
for server_info in servers_info:
@@ -632,23 +735,74 @@ async def get_subscription_renewal_cost(
)
current_server_price = result.scalar() or 0
servers_price_per_month += current_server_price
total_servers_cost = servers_price_per_month * months_in_period
servers_discount_percent = _get_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_cost = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_cost = traffic_price_per_month * months_in_period
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_cost = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_cost = devices_price_per_month * months_in_period
devices_discount_percent = _get_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_cost = discounted_devices_per_month * months_in_period
total_devices_discount = devices_discount_per_month * months_in_period
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}")
if total_servers_cost > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}"
)
if total_servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
)
logger.info(message)
if total_traffic_cost > 0:
message = (
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}"
)
if total_traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
)
logger.info(message)
if total_devices_cost > 0:
message = (
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}"
)
if total_devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_cost/100}")
return total_cost
@@ -663,27 +817,65 @@ async def calculate_addon_cost_for_remaining_period(
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: List[int] = None
additional_server_ids: List[int] = None,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
if additional_server_ids is None:
additional_server_ids = []
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
total_cost = 0
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_total_cost = traffic_price_per_month * months_to_pay
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
"traffic",
period_days=period_hint_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
traffic_total_cost = discounted_traffic_per_month * months_to_pay
total_cost += traffic_total_cost
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}")
message = (
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_cost = devices_price_per_month * months_to_pay
devices_discount_percent = _get_discount_percent(
user,
promo_group,
"devices",
period_days=period_hint_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
devices_total_cost = discounted_devices_per_month * months_to_pay
total_cost += devices_total_cost
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}")
message = (
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_server_ids:
from app.database.models import ServerSquad
for server_id in additional_server_ids:
@@ -694,9 +886,24 @@ async def calculate_addon_cost_for_remaining_period(
server_data = result.first()
if server_data:
server_price_per_month, server_name = server_data
server_total_cost = server_price_per_month * months_to_pay
servers_discount_percent = _get_discount_percent(
user,
promo_group,
"servers",
period_days=period_hint_days,
)
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_total_cost = discounted_server_per_month * months_to_pay
total_cost += server_total_cost
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}")
message = (
f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}"
)
if server_discount_per_month > 0:
message += (
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}")
return total_cost
+50 -10
View File
@@ -7,8 +7,9 @@ from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import User, UserStatus, Subscription, Transaction
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
logger = logging.getLogger(__name__)
@@ -22,7 +23,10 @@ def generate_referral_code() -> str:
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
@@ -36,7 +40,10 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
@@ -49,7 +56,9 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User).where(User.referral_code == referral_code)
select(User)
.options(selectinload(User.promo_group))
.where(User.referral_code == referral_code)
)
return result.scalar_one_or_none()
@@ -82,6 +91,20 @@ async def create_user(
from app.utils.user_utils import generate_unique_referral_code
referral_code = await generate_unique_referral_code(db, telegram_id)
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,
)
db.add(default_group)
await db.flush()
promo_group_id = default_group.id
user = User(
telegram_id=telegram_id,
username=username,
@@ -92,15 +115,19 @@ async def create_user(
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=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
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
return user
@@ -216,7 +243,8 @@ async def get_users_list(
offset: int = 0,
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None
status: Optional[UserStatus] = None,
order_by_balance: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
@@ -237,7 +265,13 @@ async def get_users_list(
query = query.where(or_(*conditions))
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
# Сортировка по балансу в порядке убывания, если order_by_balance=True
if order_by_balance:
query = query.order_by(User.balance_kopeks.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()
@@ -274,7 +308,10 @@ async def get_users_count(
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
@@ -286,7 +323,10 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(
and_(
User.last_activity < threshold_date,
+112 -4
View File
@@ -3,8 +3,17 @@ from typing import Optional, List
from enum import Enum
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Text,
ForeignKey, Float, JSON, BigInteger
Column,
Integer,
String,
DateTime,
Boolean,
Text,
ForeignKey,
Float,
JSON,
BigInteger,
UniqueConstraint,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped, mapped_column
@@ -146,6 +155,42 @@ class CryptoBotPayment(Base):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class PromoGroup(Base):
__tablename__ = "promo_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
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)
is_default = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
mapping = {
"servers": self.server_discount_percent,
"traffic": self.traffic_discount_percent,
"devices": self.device_discount_percent,
}
percent = mapping.get(category, 0)
if self.is_default and period_days is not None:
try:
from app.config import settings
discounts = settings.get_base_promo_group_period_discounts()
if period_days in discounts:
period_discount = discounts[period_days]
percent = period_discount
except Exception:
pass
return max(0, min(100, percent))
class User(Base):
__tablename__ = "users"
@@ -176,6 +221,8 @@ class User(Base):
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 = relationship("PromoGroup", back_populates="users")
@property
def balance_rubles(self) -> float:
@@ -185,6 +232,11 @@ class User(Base):
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_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
if not self.promo_group:
return 0
return self.promo_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -666,7 +718,7 @@ class UserMessage(Base):
class WelcomeText(Base):
__tablename__ = "welcome_texts"
id = Column(Integer, primary_key=True, index=True)
text_content = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
@@ -674,5 +726,61 @@ class WelcomeText(Base):
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User", backref="created_welcome_texts")
class AdvertisingCampaign(Base):
__tablename__ = "advertising_campaigns"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
start_parameter = Column(String(64), nullable=False, unique=True, index=True)
bonus_type = Column(String(20), nullable=False)
balance_bonus_kopeks = Column(Integer, default=0)
subscription_duration_days = Column(Integer, nullable=True)
subscription_traffic_gb = Column(Integer, nullable=True)
subscription_device_limit = Column(Integer, nullable=True)
subscription_squads = Column(JSON, default=list)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign")
@property
def is_balance_bonus(self) -> bool:
return self.bonus_type == "balance"
@property
def is_subscription_bonus(self) -> bool:
return self.bonus_type == "subscription"
class AdvertisingCampaignRegistration(Base):
__tablename__ = "advertising_campaign_registrations"
__table_args__ = (
UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
)
id = Column(Integer, primary_key=True, index=True)
campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
bonus_type = Column(String(20), nullable=False)
balance_bonus_kopeks = Column(Integer, default=0)
subscription_duration_days = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
campaign = relationship("AdvertisingCampaign", back_populates="registrations")
user = relationship("User")
@property
def balance_bonus_rubles(self) -> float:
return (self.balance_bonus_kopeks or 0) / 100
+388 -7
View File
@@ -74,6 +74,104 @@ async def check_column_exists(table_name: str, column_name: str) -> bool:
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
return False
async def check_constraint_exists(table_name: str, constraint_name: str) -> bool:
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "postgresql":
result = await conn.execute(
text(
"""
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = :table_name
AND constraint_name = :constraint_name
"""
),
{"table_name": table_name, "constraint_name": constraint_name},
)
return result.fetchone() is not None
if db_type == "mysql":
result = await conn.execute(
text(
"""
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND constraint_name = :constraint_name
"""
),
{"table_name": table_name, "constraint_name": constraint_name},
)
return result.fetchone() is not None
if db_type == "sqlite":
result = await conn.execute(text(f"PRAGMA foreign_key_list({table_name})"))
rows = result.fetchall()
return any(row[5] == constraint_name for row in rows)
return False
except Exception as e:
logger.error(
f"Ошибка проверки существования ограничения {constraint_name} для {table_name}: {e}"
)
return False
async def check_index_exists(table_name: str, index_name: str) -> bool:
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "postgresql":
result = await conn.execute(
text(
"""
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = :table_name
AND indexname = :index_name
"""
),
{"table_name": table_name, "index_name": index_name},
)
return result.fetchone() is not None
if db_type == "mysql":
result = await conn.execute(
text(
"""
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND index_name = :index_name
"""
),
{"table_name": table_name, "index_name": index_name},
)
return result.fetchone() is not None
if db_type == "sqlite":
result = await conn.execute(text(f"PRAGMA index_list({table_name})"))
rows = result.fetchall()
return any(row[1] == index_name for row in rows)
return False
except Exception as e:
logger.error(
f"Ошибка проверки существования индекса {index_name} для {table_name}: {e}"
)
return False
async def create_cryptobot_payments_table():
table_exists = await check_table_exists('cryptobot_payments')
if table_exists:
@@ -248,6 +346,276 @@ async def create_user_messages_table():
logger.error(f"Ошибка создания таблицы user_messages: {e}")
return False
async def ensure_promo_groups_setup():
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
try:
promo_table_exists = await check_table_exists("promo_groups")
async with engine.begin() as conn:
db_type = await get_database_type()
if not promo_table_exists:
if db_type == "sqlite":
await conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS promo_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
server_discount_percent INTEGER NOT NULL DEFAULT 0,
traffic_discount_percent INTEGER NOT NULL DEFAULT 0,
device_discount_percent INTEGER NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
await conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_groups_name ON promo_groups(name)"
)
)
elif db_type == "postgresql":
await conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS promo_groups (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
server_discount_percent INTEGER NOT NULL DEFAULT 0,
traffic_discount_percent INTEGER NOT NULL DEFAULT 0,
device_discount_percent INTEGER NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_promo_groups_name UNIQUE (name)
)
"""
)
)
elif db_type == "mysql":
await conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS promo_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
server_discount_percent INT NOT NULL DEFAULT 0,
traffic_discount_percent INT NOT NULL DEFAULT 0,
device_discount_percent INT NOT NULL DEFAULT 0,
is_default TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_promo_groups_name (name)
) ENGINE=InnoDB
"""
)
)
else:
logger.error(f"Неподдерживаемый тип БД для promo_groups: {db_type}")
return False
logger.info("Создана таблица promo_groups")
if db_type == "postgresql" and not await check_constraint_exists(
"promo_groups", "uq_promo_groups_name"
):
try:
await conn.execute(
text(
"ALTER TABLE promo_groups ADD CONSTRAINT uq_promo_groups_name UNIQUE (name)"
)
)
except Exception as e:
logger.warning(
f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}"
)
column_exists = await check_column_exists("users", "promo_group_id")
if not column_exists:
if db_type == "sqlite":
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER"))
elif db_type == "postgresql":
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER"))
elif db_type == "mysql":
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INT"))
else:
logger.error(f"Неподдерживаемый тип БД для promo_group_id: {db_type}")
return False
logger.info("Добавлена колонка users.promo_group_id")
index_exists = await check_index_exists("users", "ix_users_promo_group_id")
if not index_exists:
try:
if db_type == "sqlite":
await conn.execute(
text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)")
)
elif db_type == "postgresql":
await conn.execute(
text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)")
)
elif db_type == "mysql":
await conn.execute(
text("CREATE INDEX ix_users_promo_group_id ON users(promo_group_id)")
)
logger.info("Создан индекс ix_users_promo_group_id")
except Exception as e:
logger.warning(f"Не удалось создать индекс ix_users_promo_group_id: {e}")
default_group_name = "Базовый юзер"
default_group_id = None
result = await conn.execute(
text(
"SELECT id, is_default FROM promo_groups WHERE name = :name LIMIT 1"
),
{"name": default_group_name},
)
row = result.fetchone()
if row:
default_group_id = row[0]
if not row[1]:
await conn.execute(
text(
"UPDATE promo_groups SET is_default = :is_default WHERE id = :group_id"
),
{"is_default": True, "group_id": default_group_id},
)
else:
result = await conn.execute(
text(
"SELECT id FROM promo_groups WHERE is_default = :is_default LIMIT 1"
),
{"is_default": True},
)
existing_default = result.fetchone()
if existing_default:
default_group_id = existing_default[0]
else:
await conn.execute(
text(
"""
INSERT INTO promo_groups (
name,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
is_default
) VALUES (:name, 0, 0, 0, :is_default)
"""
),
{"name": default_group_name, "is_default": True},
)
result = await conn.execute(
text(
"SELECT id FROM promo_groups WHERE name = :name LIMIT 1"
),
{"name": default_group_name},
)
row = result.fetchone()
default_group_id = row[0] if row else None
if default_group_id is None:
logger.error("Не удалось определить идентификатор базовой промо-группы")
return False
await conn.execute(
text(
"""
UPDATE users
SET promo_group_id = :group_id
WHERE promo_group_id IS NULL
"""
),
{"group_id": default_group_id},
)
if db_type == "postgresql":
constraint_exists = await check_constraint_exists(
"users", "fk_users_promo_group_id_promo_groups"
)
if not constraint_exists:
try:
await conn.execute(
text(
"""
ALTER TABLE users
ADD CONSTRAINT fk_users_promo_group_id_promo_groups
FOREIGN KEY (promo_group_id)
REFERENCES promo_groups(id)
ON DELETE RESTRICT
"""
)
)
logger.info("Добавлен внешний ключ users -> promo_groups")
except Exception as e:
logger.warning(
f"Не удалось добавить внешний ключ users.promo_group_id: {e}"
)
try:
await conn.execute(
text(
"ALTER TABLE users ALTER COLUMN promo_group_id SET NOT NULL"
)
)
except Exception as e:
logger.warning(
f"Не удалось сделать users.promo_group_id NOT NULL: {e}"
)
elif db_type == "mysql":
constraint_exists = await check_constraint_exists(
"users", "fk_users_promo_group_id_promo_groups"
)
if not constraint_exists:
try:
await conn.execute(
text(
"""
ALTER TABLE users
ADD CONSTRAINT fk_users_promo_group_id_promo_groups
FOREIGN KEY (promo_group_id)
REFERENCES promo_groups(id)
ON DELETE RESTRICT
"""
)
)
logger.info("Добавлен внешний ключ users -> promo_groups")
except Exception as e:
logger.warning(
f"Не удалось добавить внешний ключ users.promo_group_id: {e}"
)
try:
await conn.execute(
text(
"ALTER TABLE users MODIFY promo_group_id INT NOT NULL"
)
)
except Exception as e:
logger.warning(
f"Не удалось сделать users.promo_group_id NOT NULL: {e}"
)
logger.info("✅ Промо группы настроены")
return True
except Exception as e:
logger.error(f"Ошибка настройки промо групп: {e}")
return False
async def add_welcome_text_is_enabled_column():
column_exists = await check_column_exists('welcome_texts', 'is_enabled')
if column_exists:
@@ -688,7 +1056,14 @@ async def run_universal_migration():
logger.info("✅ Медиа поля в broadcast_history готовы")
else:
logger.warning("⚠️ Проблемы с добавлением медиа полей")
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
promo_groups_ready = await ensure_promo_groups_setup()
if promo_groups_ready:
logger.info("✅ Промо группы готовы")
else:
logger.warning("⚠️ Проблемы с настройкой промо групп")
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:
@@ -756,10 +1131,12 @@ async def check_migration_status():
"cryptobot_table": False,
"user_messages_table": False,
"welcome_texts_table": False,
"welcome_texts_is_enabled_column": False,
"broadcast_history_media_fields": False,
"welcome_texts_is_enabled_column": False,
"broadcast_history_media_fields": False,
"subscription_duplicates": False,
"subscription_conversions_table": False
"subscription_conversions_table": False,
"promo_groups_table": False,
"users_promo_group_column": False
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
@@ -768,8 +1145,10 @@ async def check_migration_status():
status["user_messages_table"] = await check_table_exists('user_messages')
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
status["promo_groups_table"] = await check_table_exists('promo_groups')
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
@@ -797,9 +1176,11 @@ async def check_migration_status():
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок"
"subscription_duplicates": "Отсутствие дубликатов подписок",
"promo_groups_table": "Таблица промо-групп",
"users_promo_group_column": "Колонка promo_group_id у пользователей"
}
for check_key, check_status in status.items():
File diff suppressed because it is too large Load Diff
+93 -132
View File
@@ -14,7 +14,9 @@ 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
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
BROADCAST_BUTTON_LABELS
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
@@ -23,69 +25,42 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
BUTTON_CONFIG = BROADCAST_BUTTONS
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
],
[
types.InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
],
[
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language)
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
types.InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
],
[
types.InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
],
[
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
def create_broadcast_keyboard(selected_buttons: list) -> types.InlineKeyboardMarkup:
keyboard = []
button_row = []
if "balance" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup"))
if "referrals" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="menu_referrals"))
if "promocode" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="🎫 Промокод", callback_data="menu_promocode"))
if len(button_row) > 2:
keyboard.append(button_row[:2])
if len(button_row) > 2:
keyboard.append(button_row[2:])
elif button_row:
keyboard.append(button_row)
keyboard.append([types.InlineKeyboardButton(text="🏠 На главную", callback_data="back_to_menu")])
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
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"]
)
)
if row_buttons:
keyboard.append(row_buttons)
if not keyboard:
return None
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -495,7 +470,11 @@ async def show_button_selector_callback(
):
data = await state.get_data()
has_media = data.get('has_media', False)
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
media_info = ""
if has_media:
@@ -507,11 +486,14 @@ async def show_button_selector_callback(
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
💰 <b>Пополнить баланс</b> откроет методы пополнения
🤝 <b>Партнерка</b> откроет реферальную программу
🎫 <b>Промокод</b> откроет форму ввода промокода
🔗 <b>Подключиться</b> поможет подключить приложение
📱 <b>Подписка</b> покажет состояние подписки
🛠 <b>Техподдержка</b> свяжет с поддержкой
Кнопка "🏠 На главную" добавляется автоматически внизу.{media_info}
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.{media_info}
Выберите нужные кнопки и нажмите "Продолжить":
"""
@@ -535,28 +517,38 @@ async def show_button_selector(
db_user: User,
state: FSMContext
):
data = await state.get_data()
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
text = """
📘 <b>Выбор дополнительных кнопок</b>
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
💰 <b>Пополнить баланс</b> откроет методы пополнения
🤝 <b>Партнерка</b> откроет реферальную программу
🎫 <b>Промокод</b> откроет форму ввода промокода
🔗 <b>Подключиться</b> поможет подключить приложение
📱 <b>Подписка</b> покажет состояние подписки
🛠 <b>Техподдержка</b> свяжет с поддержкой
Кнопка "🏠 На главную" добавляется автоматически внизу.
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.
Выберите нужные кнопки и нажмите "Продолжить":
"""
data = await state.get_data()
if 'selected_buttons' not in data:
data['selected_buttons'] = []
await state.set_data(data)
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await message.answer(
text,
reply_markup=get_message_buttons_selector_keyboard(db_user.language),
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -570,18 +562,24 @@ async def toggle_button_selection(
):
button_type = callback.data.replace('btn_', '')
data = await state.get_data()
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
else:
selected_buttons = list(selected_buttons)
if button_type in selected_buttons:
selected_buttons.remove(button_type)
else:
selected_buttons.append(button_type)
data['selected_buttons'] = selected_buttons
await state.set_data(data)
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await callback.message.edit_reply_markup(reply_markup=keyboard)
await callback.answer()
@@ -597,7 +595,10 @@ async def confirm_button_selection(
data = await state.get_data()
target = data.get('broadcast_target')
message_text = data.get('broadcast_message')
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
media_type = data.get('media_type')
@@ -613,18 +614,12 @@ async def confirm_button_selection(
}
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
buttons_info = ""
if selected_buttons:
buttons_list = []
if "balance" in selected_buttons:
buttons_list.append("💰 Пополнить баланс")
if "referrals" in selected_buttons:
buttons_list.append("🤝 Рефералы")
if "promocode" in selected_buttons:
buttons_list.append("🎫 Промокод")
buttons_info = f"\n📘 <b>Дополнительные кнопки:</b> {', '.join(buttons_list)}"
buttons_info += "\n🏠 <b>Основная кнопка:</b> На главную"
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]
if selected_names:
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
else:
buttons_info = "\n📘 <b>Кнопки:</b> отсутствуют"
preview_text = f"""
📨 <b>Предварительный просмотр рассылки</b>
@@ -685,42 +680,6 @@ async def confirm_button_selection(
)
await callback.answer()
@admin_required
@error_handler
async def show_button_selector_callback(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
text = """
📘 <b>Выбор дополнительных кнопок</b>
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
Кнопка "🏠 На главную" добавляется автоматически внизу.
Выберите нужные кнопки и нажмите "Продолжить":
"""
data = await state.get_data()
selected_buttons = data.get('selected_buttons', [])
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def confirm_broadcast(
@@ -732,7 +691,9 @@ async def confirm_broadcast(
data = await state.get_data()
target = data.get('broadcast_target')
message_text = data.get('broadcast_message')
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
has_media = data.get('has_media', False)
media_type = data.get('media_type')
media_file_id = data.get('media_file_id')
@@ -771,7 +732,7 @@ async def confirm_broadcast(
sent_count = 0
failed_count = 0
broadcast_keyboard = create_broadcast_keyboard(selected_buttons) if selected_buttons else None
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
for user in users:
try:
+632
View File
@@ -0,0 +1,632 @@
import logging
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.promo_group import (
get_promo_groups_with_counts,
get_promo_group_by_id,
create_promo_group,
update_promo_group,
delete_promo_group,
get_promo_group_members,
count_promo_group_members,
)
from app.database.models import PromoGroup
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.keyboards.admin import (
get_admin_pagination_keyboard,
get_confirmation_keyboard,
)
logger = logging.getLogger(__name__)
def _format_discount_line(texts, group) -> str:
return texts.t(
"ADMIN_PROMO_GROUPS_DISCOUNTS",
"Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
).format(
servers=group.server_discount_percent,
traffic=group.traffic_discount_percent,
devices=group.device_discount_percent,
)
@admin_required
@error_handler
async def show_promo_groups_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
groups = await get_promo_groups_with_counts(db)
total_members = sum(count for _, count in groups)
header = texts.t("ADMIN_PROMO_GROUPS_TITLE", "💳 <b>Промогруппы</b>")
if groups:
summary = texts.t(
"ADMIN_PROMO_GROUPS_SUMMARY",
"Всего групп: {count}\nВсего участников: {members}",
).format(count=len(groups), members=total_members)
lines = [header, "", summary, ""]
keyboard_rows = []
for group, member_count in groups:
default_suffix = (
texts.t("ADMIN_PROMO_GROUPS_DEFAULT_LABEL", " (базовая)")
if group.is_default
else ""
)
lines.extend(
[
f"{'' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
_format_discount_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
"Участников: {count}",
).format(count=member_count),
"",
]
)
keyboard_rows.append([
types.InlineKeyboardButton(
text=f"{'' if group.is_default else '🎯'} {group.name}",
callback_data=f"promo_group_manage_{group.id}",
)
])
else:
lines = [header, "", texts.t("ADMIN_PROMO_GROUPS_EMPTY", "Промогруппы не найдены.")]
keyboard_rows = []
keyboard_rows.append(
[types.InlineKeyboardButton(text=" Создать", callback_data="admin_promo_group_create")]
)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")]
)
await callback.message.edit_text(
"\n".join(line for line in lines if line is not None),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
async def _get_group_or_alert(
callback: types.CallbackQuery,
db: AsyncSession,
) -> Optional[PromoGroup]:
group_id = int(callback.data.split("_")[-1])
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return None
return group
@admin_required
@error_handler
async def show_promo_group_details(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
member_count = await count_promo_group_members(db, group.id)
default_note = (
"\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
if group.is_default
else ""
)
text = "\n".join(
[
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
"💳 <b>Промогруппа:</b> {name}",
).format(name=group.name),
_format_discount_line(texts, group),
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
"Участников: {count}",
).format(count=member_count),
default_note,
]
)
keyboard_rows = []
if member_count > 0:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PROMO_GROUP_MEMBERS_BUTTON", "👥 Участники"),
callback_data=f"promo_group_members_{group.id}_page_1",
)
]
)
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PROMO_GROUP_EDIT_BUTTON", "✏️ Изменить"),
callback_data=f"promo_group_edit_{group.id}",
)
]
)
if not group.is_default:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PROMO_GROUP_DELETE_BUTTON", "🗑️ Удалить"),
callback_data=f"promo_group_delete_{group.id}",
)
]
)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
)
await callback.message.edit_text(
text.strip(),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
def _validate_percent(value: str) -> int:
percent = int(value)
if percent < 0 or percent > 100:
raise ValueError
return percent
async def _prompt_for_discount(
message: types.Message,
state: FSMContext,
prompt_key: str,
default_text: str,
):
data = await state.get_data()
texts = get_texts(data.get("language", "ru"))
await message.answer(texts.t(prompt_key, default_text))
@admin_required
@error_handler
async def start_create_promo_group(
callback: types.CallbackQuery,
db_user,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
await state.set_state(AdminStates.creating_promo_group_name)
await state.update_data(language=db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT", "Введите название новой промогруппы:"),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
]
),
)
await callback.answer()
async def process_create_group_name(message: types.Message, state: FSMContext):
name = message.text.strip()
if not name:
texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
return
await state.update_data(new_group_name=name)
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT",
"Введите скидку на трафик (0-100):",
)
async def process_create_group_traffic(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(new_group_traffic=value)
await state.set_state(AdminStates.creating_promo_group_server_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT",
"Введите скидку на серверы (0-100):",
)
async def process_create_group_servers(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(new_group_servers=value)
await state.set_state(AdminStates.creating_promo_group_device_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT",
"Введите скидку на устройства (0-100):",
)
@admin_required
@error_handler
async def process_create_group_devices(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
devices_discount = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
try:
group = await create_promo_group(
db,
data["new_group_name"],
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
device_discount_percent=devices_discount,
)
except Exception as e:
logger.error(f"Не удалось создать промогруппу: {e}")
await message.answer(texts.ERROR)
await state.clear()
return
await state.clear()
await message.answer(
texts.t("ADMIN_PROMO_GROUP_CREATED", "Промогруппа «{name}» создана.").format(
name=group.name
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON",
"↩️ К промогруппам",
),
callback_data="admin_promo_groups",
)
]
]
),
)
@admin_required
@error_handler
async def start_edit_promo_group(
callback: types.CallbackQuery,
db_user,
state: FSMContext,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
await state.set_state(AdminStates.editing_promo_group_name)
await state.update_data(edit_group_id=group.id, language=db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
"Введите новое название промогруппы (текущее: {name}):",
).format(name=group.name),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")]
]
),
)
await callback.answer()
async def process_edit_group_name(message: types.Message, state: FSMContext):
name = message.text.strip()
if not name:
texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
return
await state.update_data(edit_group_name=name)
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
"Введите новую скидку на трафик (0-100):",
)
async def process_edit_group_traffic(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(edit_group_traffic=value)
await state.set_state(AdminStates.editing_promo_group_server_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
"Введите новую скидку на серверы (0-100):",
)
async def process_edit_group_servers(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(edit_group_servers=value)
await state.set_state(AdminStates.editing_promo_group_device_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
"Введите новую скидку на устройства (0-100):",
)
@admin_required
@error_handler
async def process_edit_group_devices(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
devices_discount = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
group = await get_promo_group_by_id(db, data["edit_group_id"])
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
await update_promo_group(
db,
group,
name=data["edit_group_name"],
traffic_discount_percent=data["edit_group_traffic"],
server_discount_percent=data["edit_group_servers"],
device_discount_percent=devices_discount,
)
await state.clear()
await message.answer(
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name)
)
@admin_required
@error_handler
async def show_promo_group_members(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
parts = callback.data.split("_")
group_id = int(parts[3])
page = int(parts[-1])
limit = 10
offset = (page - 1) * limit
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return
texts = get_texts(db_user.language)
members = await get_promo_group_members(db, group_id, offset=offset, limit=limit)
total_members = await count_promo_group_members(db, group_id)
total_pages = max(1, (total_members + limit - 1) // limit)
title = texts.t(
"ADMIN_PROMO_GROUP_MEMBERS_TITLE",
"👥 Участники группы {name}",
).format(name=group.name)
if not members:
body = texts.t("ADMIN_PROMO_GROUP_MEMBERS_EMPTY", "В этой группе пока нет участников.")
else:
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f"@{user.username}" if user.username else ""
lines.append(
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
)
body = "\n".join(lines)
keyboard = []
if total_pages > 1:
pagination = get_admin_pagination_keyboard(
page,
total_pages,
f"promo_group_members_{group_id}",
f"promo_group_manage_{group_id}",
db_user.language,
)
keyboard.extend(pagination.inline_keyboard)
keyboard.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group_id}")]
)
await callback.message.edit_text(
f"{title}\n\n{body}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def request_delete_promo_group(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
if group.is_default:
await callback.answer(
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
show_alert=True,
)
return
confirm_text = texts.t(
"ADMIN_PROMO_GROUP_DELETE_CONFIRM",
"Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
).format(name=group.name)
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirmation_keyboard(
confirm_action=f"promo_group_delete_confirm_{group.id}",
cancel_action=f"promo_group_manage_{group.id}",
language=db_user.language,
),
)
await callback.answer()
@admin_required
@error_handler
async def delete_promo_group_confirmed(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
success = await delete_promo_group(db, group)
if not success:
await callback.answer(
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
show_alert=True,
)
return
await callback.message.edit_text(
texts.t("ADMIN_PROMO_GROUP_DELETED", "Промогруппа «{name}» удалена.").format(name=group.name),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
]
),
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create")
dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_"))
dp.callback_query.register(
request_delete_promo_group,
F.data.startswith("promo_group_delete_")
& ~F.data.startswith("promo_group_delete_confirm_"),
)
dp.callback_query.register(
delete_promo_group_confirmed,
F.data.startswith("promo_group_delete_confirm_"),
)
dp.callback_query.register(
show_promo_group_members,
F.data.regexp(r"^promo_group_members_\d+_page_\d+$"),
)
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
dp.message.register(
process_create_group_traffic,
AdminStates.creating_promo_group_traffic_discount,
)
dp.message.register(
process_create_group_servers,
AdminStates.creating_promo_group_server_discount,
)
dp.message.register(
process_create_group_devices,
AdminStates.creating_promo_group_device_discount,
)
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
process_edit_group_traffic,
AdminStates.editing_promo_group_traffic_discount,
)
dp.message.register(
process_edit_group_servers,
AdminStates.editing_promo_group_server_discount,
)
dp.message.register(
process_edit_group_devices,
AdminStates.editing_promo_group_device_discount,
)
File diff suppressed because it is too large Load Diff
+395 -19
View File
@@ -25,6 +25,40 @@ logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
return []
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6]
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
callback_data = f"quick_amount_{price_kopeks}"
buttons.append(
types.InlineKeyboardButton(
text=f"{price_rubles} ₽ ({period} дней)",
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
@error_handler
async def show_balance_menu(
callback: types.CallbackQuery,
@@ -152,7 +186,7 @@ async def show_payment_methods(
from app.utils.payment_utils import get_payment_methods_text
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text()
payment_text = get_payment_methods_text(db_user.language)
await callback.message.edit_text(
payment_text,
@@ -170,8 +204,10 @@ async def handle_payment_methods_unavailable(
texts = get_texts(db_user.language)
await callback.answer(
"⚠️ В данный момент автоматические способы оплаты временно недоступны. "
"Для пополнения баланса обратитесь в техподдержку.",
texts.t(
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
),
show_alert=True
)
@@ -210,10 +246,35 @@ async def start_yookassa_payment(
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:
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:
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
"💳 <b>Оплата банковской картой</b>\n\n"
"Введите сумму для пополнения от 100 до 50,000 рублей:",
reply_markup=get_back_keyboard(db_user.language),
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -222,6 +283,55 @@ async def start_yookassa_payment(
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:
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:
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
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 callback.answer()
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
@@ -269,7 +379,67 @@ async def start_tribute_payment(
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
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"🛒 У вас есть сохраненная корзина подписки\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(
@@ -282,7 +452,7 @@ async def request_support_topup(
🛠 <b>Пополнение через поддержку</b>
Для пополнения баланса обратитесь в техподдержку:
{settings.SUPPORT_USERNAME}
{settings.get_support_contact_display_html()}
Укажите:
ID: {db_user.telegram_id}
@@ -299,8 +469,8 @@ async def request_support_topup(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="💬 Написать в поддержку",
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
text="💬 Написать в поддержку",
url=settings.get_support_contact_url() or "https://t.me/"
)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
@@ -336,12 +506,27 @@ async def process_topup_amount(
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":
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
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
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
@@ -419,8 +604,14 @@ async def process_yookassa_payment_amount(
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
if amount_kopeks < 10000:
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
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:
@@ -468,7 +659,7 @@ async def process_yookassa_payment_amount(
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -484,6 +675,91 @@ async def process_yookassa_payment_amount(
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")
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")]
])
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"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
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,
@@ -529,7 +805,9 @@ async def check_yookassa_payment_status(
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_failed:
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
message_text += (
f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
@@ -602,7 +880,6 @@ async def process_cryptobot_payment_amount(
return
try:
# Получаем курс из состояния или запрашиваем заново
data = await state.get_data()
current_rate = data.get('current_rate')
@@ -610,10 +887,8 @@ async def process_cryptobot_payment_amount(
from app.utils.currency_converter import currency_converter
current_rate = await currency_converter.get_usd_to_rub_rate()
# Конвертируем рубли в доллары
amount_usd = amount_rubles / current_rate
# Округляем до 2 знаков после запятой
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
@@ -670,7 +945,7 @@ async def process_cryptobot_payment_amount(
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -726,7 +1001,9 @@ async def check_cryptobot_payment_status(
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_expired:
message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}"
message_text += (
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
@@ -736,6 +1013,89 @@ async def check_cryptobot_payment_status(
@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
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
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, 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)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -768,6 +1128,16 @@ def register_handlers(dp: Dispatcher):
F.data == "topup_yookassa"
)
dp.callback_query.register(
start_yookassa_sbp_payment,
F.data == "topup_yookassa_sbp"
)
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
@@ -802,3 +1172,9 @@ def register_handlers(dp: Dispatcher):
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"
)
# Регистрируем обработчик для кнопок быстрого выбора суммы
dp.callback_query.register(
handle_quick_amount_selection,
F.data.startswith("quick_amount_")
)
+13 -7
View File
@@ -4,7 +4,7 @@ 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.localization.texts import get_texts, get_rules
from app.keyboards.inline import get_back_keyboard
logger = logging.getLogger(__name__)
@@ -18,8 +18,11 @@ async def handle_unknown_callback(
texts = get_texts(db_user.language if db_user else "ru")
await callback.answer(
"❓ Неизвестная команда. Попробуйте ещё раз.",
show_alert=True
texts.t(
"UNKNOWN_CALLBACK_ALERT",
"❓ Неизвестная команда. Попробуйте ещё раз.",
),
show_alert=True,
)
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
@@ -49,8 +52,11 @@ async def handle_unknown_message(
texts = get_texts(db_user.language if db_user else "ru")
await message.answer(
"❓ Не понимаю эту команду. Используйте кнопки меню.",
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
texts.t(
"UNKNOWN_COMMAND_MESSAGE",
"❓ Не понимаю эту команду. Используйте кнопки меню.",
),
reply_markup=get_back_keyboard(db_user.language if db_user else "ru"),
)
@@ -61,8 +67,8 @@ async def show_rules(
):
texts = get_texts(db_user.language)
rules_text = texts.RULES_TEXT
rules_text = await get_rules(db_user.language)
await callback.message.edit_text(
rules_text,
+94 -55
View File
@@ -7,33 +7,40 @@ from datetime import datetime, timedelta
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard
from app.localization.texts import get_texts
from app.localization.texts import get_texts, get_rules
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
from app.database.crud.user_message import get_random_active_message
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
logger = logging.getLogger(__name__)
async def show_main_menu(
callback: types.CallbackQuery,
db_user: User,
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
from datetime import datetime
db_user.last_activity = datetime.utcnow()
await db.commit()
has_active_subscription = bool(db_user.subscription)
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
await callback.message.edit_text(
menu_text,
reply_markup=get_main_menu_keyboard(
@@ -44,11 +51,13 @@ async def show_main_menu(
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
),
parse_mode="HTML"
)
await callback.answer()
async def mark_user_as_had_paid_subscription(
db: AsyncSession,
user: User
@@ -61,34 +70,22 @@ async def mark_user_as_had_paid_subscription(
async def show_service_rules(
callback: types.CallbackQuery,
db_user: User,
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.database.crud.rules import get_current_rules_content
texts = get_texts(db_user.language)
rules_text = await get_current_rules_content(db, db_user.language)
if not rules_text:
texts = get_texts(db_user.language)
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
📋 <b>Правила использования сервиса</b>
rules_text = await get_rules(db_user.language)
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
await callback.message.edit_text(
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
f"{texts.t('RULES_HEADER', '📋 <b>Правила сервиса</b>')}\n\n{rules_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
[types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
)
await callback.answer()
@@ -101,17 +98,20 @@ async def handle_back_to_menu(
db: AsyncSession
):
await state.clear()
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
await callback.message.edit_text(
menu_text,
reply_markup=get_main_menu_keyboard(
@@ -121,62 +121,101 @@ async def handle_back_to_menu(
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
),
parse_mode="HTML"
)
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return "❌ Отсутствует"
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left == 1:
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
else:
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
else:
if days_left > 7:
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left > 1:
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
elif days_left == 1:
return f"💎 Активна\n⚠️ истекает завтра!"
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
else:
return f"💎 Активна\n⚠️ истекает сегодня!"
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
if not random_message:
return base_text
prompt = action_prompt or ""
if prompt and prompt in base_text:
parts = base_text.split(prompt, 1)
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}"
return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1)
return f"{base_text}\n\n{random_message}"
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
+4 -1
View File
@@ -44,7 +44,10 @@ async def process_promocode(
if not code:
await message.answer(
"❌ Введите корректный промокод",
texts.t(
"PROMOCODE_EMPTY_INPUT",
"❌ Введите корректный промокод",
),
reply_markup=get_back_keyboard(db_user.language)
)
return
+238 -72
View File
@@ -33,64 +33,149 @@ async def show_referral_info(
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
referral_text += f"📊 <b>Ваша статистика:</b>\n"
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
referral_text += f"• Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
referral_text += f"• Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
referral_text += f"• Конверсия: <b>{summary['conversion_rate']}%</b>\n"
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
referral_text += f"🎁 <b>Как работают награды:</b>\n"
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
referral_text += f"<code>{referral_link}</code>\n\n"
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
referral_text = (
texts.t("REFERRAL_PROGRAM_TITLE", "👥 <b>Реферальная программа</b>")
+ "\n\n"
+ texts.t("REFERRAL_STATS_HEADER", "📊 <b>Ваша статистика:</b>")
+ "\n"
+ texts.t(
"REFERRAL_STATS_INVITED",
"• Приглашено пользователей: <b>{count}</b>",
).format(count=summary['invited_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_FIRST_TOPUPS",
"• Сделали первое пополнение: <b>{count}</b>",
).format(count=summary['paid_referrals_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_ACTIVE",
"• Активных рефералов: <b>{count}</b>",
).format(count=summary['active_referrals_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_CONVERSION",
"• Конверсия: <b>{rate}%</b>",
).format(rate=summary['conversion_rate'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_TOTAL_EARNED",
"• Заработано всего: <b>{amount}</b>",
).format(amount=texts.format_price(summary['total_earned_kopeks']))
+ "\n"
+ texts.t(
"REFERRAL_STATS_MONTH_EARNED",
"• За последний месяц: <b>{amount}</b>",
).format(amount=texts.format_price(summary['month_earned_kopeks']))
+ "\n\n"
+ texts.t("REFERRAL_REWARDS_HEADER", "🎁 <b>Как работают награды:</b>")
+ "\n"
+ texts.t(
"REFERRAL_REWARD_NEW_USER",
"• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
).format(
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
)
+ "\n"
+ texts.t(
"REFERRAL_REWARD_INVITER",
"• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
+ "\n"
+ texts.t(
"REFERRAL_REWARD_COMMISSION",
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
+ "\n\n"
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
+ f"\n<code>{referral_link}</code>\n\n"
+ texts.t("REFERRAL_CODE_TITLE", "🆔 <b>Ваш код:</b> <code>{code}</code>").format(code=db_user.referral_code)
+ "\n\n"
)
if summary['recent_earnings']:
meaningful_earnings = [
earning for earning in summary['recent_earnings'][:5]
earning for earning in summary['recent_earnings'][:5]
if earning['amount_kopeks'] > 0
]
if meaningful_earnings:
referral_text += f"💰 <b>Последние начисления:</b>\n"
for earning in meaningful_earnings[:3]:
referral_text += texts.t(
"REFERRAL_RECENT_EARNINGS_HEADER",
"💰 <b>Последние начисления:</b>",
) + "\n"
for earning in meaningful_earnings[:3]:
reason_text = {
"referral_first_topup": "🎉 Первое пополнение",
"referral_commission_topup": "💰 Комиссия с пополнения",
"referral_commission": "💰 Комиссия с покупки"
"referral_first_topup": texts.t(
"REFERRAL_EARNING_REASON_FIRST_TOPUP",
"🎉 Первое пополнение",
),
"referral_commission_topup": texts.t(
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP",
"💰 Комиссия с пополнения",
),
"referral_commission": texts.t(
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE",
"💰 Комиссия с покупки",
),
}.get(earning['reason'], earning['reason'])
referral_text += f"{reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
referral_text += texts.t(
"REFERRAL_RECENT_EARNINGS_ITEM",
"{reason}: <b>{amount}</b> от {referral_name}",
).format(
reason=reason_text,
amount=texts.format_price(earning['amount_kopeks']),
referral_name=earning['referral_name'],
) + "\n"
referral_text += "\n"
if summary['earnings_by_type']:
referral_text += f"📈 <b>Доходы по типам:</b>\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_BY_TYPE_HEADER",
"📈 <b>Доходы по типам:</b>",
) + "\n"
if 'referral_first_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_first_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_FIRST_TOPUPS",
"• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
if 'referral_commission_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_TOPUPS",
"• Комиссии с пополнений: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
if 'referral_commission' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_PURCHASES",
"• Комиссии с покупок: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
referral_text += "\n"
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
referral_text += texts.t(
"REFERRAL_INVITE_FOOTER",
"📢 Приглашайте друзей и зарабатывайте!",
)
await edit_or_answer_photo(
callback,
@@ -128,7 +213,10 @@ async def show_referral_qr(
await callback.message.edit_media(
types.InputMediaPhoto(
media=photo,
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
caption=texts.t(
"REFERRAL_LINK_CAPTION",
"🔗 Ваша реферальная ссылка:\n{link}",
).format(link=referral_link),
),
reply_markup=keyboard,
)
@@ -136,7 +224,10 @@ async def show_referral_qr(
await callback.message.delete()
await callback.message.answer_photo(
photo,
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
caption=texts.t(
"REFERRAL_LINK_CAPTION",
"🔗 Ваша реферальная ссылка:\n{link}",
).format(link=referral_link),
reply_markup=keyboard,
)
@@ -154,7 +245,10 @@ async def show_detailed_referral_list(
if not referrals_data['referrals']:
await edit_or_answer_photo(
callback,
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
texts.t(
"REFERRAL_LIST_EMPTY",
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
),
types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
),
@@ -162,23 +256,47 @@ async def show_detailed_referral_list(
)
await callback.answer()
return
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
text = texts.t(
"REFERRAL_LIST_HEADER",
"👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
).format(
current=referrals_data['current_page'],
total=referrals_data['total_pages'],
) + "\n\n"
for i, referral in enumerate(referrals_data['referrals'], 1):
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
topup_emoji = "💰" if referral['has_made_first_topup'] else ""
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
text += texts.t(
"REFERRAL_LIST_ITEM_HEADER",
"{index}. {status} <b>{name}</b>",
).format(index=i, status=status_emoji, name=referral['full_name']) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_TOPUPS",
" {emoji} Пополнений: {count}",
).format(emoji=topup_emoji, count=referral['topups_count']) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_EARNED",
" 💎 Заработано с него: {amount}",
).format(amount=texts.format_price(referral['total_earned_kopeks'])) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_REGISTERED",
" 📅 Регистрация: {days} дн. назад",
).format(days=referral['days_since_registration']) + "\n"
if referral['days_since_activity'] is not None:
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
text += texts.t(
"REFERRAL_LIST_ITEM_ACTIVITY",
" 🕐 Активность: {days} дн. назад",
).format(days=referral['days_since_activity']) + "\n"
else:
text += f" 🕐 Активность: давно\n"
text += texts.t(
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO",
" 🕐 Активность: давно",
) + "\n"
text += "\n"
@@ -187,13 +305,13 @@ async def show_detailed_referral_list(
if referrals_data['has_prev']:
nav_buttons.append(types.InlineKeyboardButton(
text="⬅️ Назад",
text=texts.t("REFERRAL_LIST_PREV_PAGE", "⬅️ Назад"),
callback_data=f"referral_list_page_{page - 1}"
))
if referrals_data['has_next']:
nav_buttons.append(types.InlineKeyboardButton(
text="Вперед ➡️",
text=texts.t("REFERRAL_LIST_NEXT_PAGE", "Вперед ➡️"),
callback_data=f"referral_list_page_{page + 1}"
))
@@ -222,21 +340,50 @@ async def show_referral_analytics(
analytics = await get_referral_analytics(db, db_user.id)
text = f"📊 <b>Аналитика рефералов</b>\n\n"
text = texts.t("REFERRAL_ANALYTICS_TITLE", "📊 <b>Аналитика рефералов</b>") + "\n\n"
text += f"💰 <b>Доходы по периодам:</b>\n"
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_HEADER",
"💰 <b>Доходы по периодам:</b>",
) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_TODAY",
"• Сегодня: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['today'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_WEEK",
"• За неделю: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['week'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_MONTH",
"• За месяц: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['month'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_QUARTER",
"• За квартал: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['quarter'])) + "\n\n"
if analytics['top_referrals']:
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
text += texts.t(
"REFERRAL_ANALYTICS_TOP_TITLE",
"🏆 <b>Топ-{count} рефералов:</b>",
).format(count=len(analytics['top_referrals'])) + "\n"
for i, ref in enumerate(analytics['top_referrals'], 1):
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
text += texts.t(
"REFERRAL_ANALYTICS_TOP_ITEM",
"{index}. {name}: {amount} ({count} начислений)",
).format(
index=i,
name=ref['referral_name'],
amount=texts.format_price(ref['total_earned_kopeks']),
count=ref['earnings_count'],
) + "\n"
text += "\n"
text += "📈 Продолжайте развивать свою реферальную сеть!"
text += texts.t(
"REFERRAL_ANALYTICS_FOOTER",
"📈 Продолжайте развивать свою реферальную сеть!",
)
await edit_or_answer_photo(
callback,
@@ -257,16 +404,30 @@ async def create_invite_message(
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
invite_text += f"🚀 Быстрое подключение\n"
invite_text += f"🌍 Серверы по всему миру\n"
invite_text += f"🔒 Надежная защита\n\n"
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
invite_text = (
texts.t("REFERRAL_INVITE_TITLE", "🎉 Присоединяйся к VPN сервису!")
+ "\n\n"
+ texts.t(
"REFERRAL_INVITE_BONUS",
"💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
).format(
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
)
+ "\n\n"
+ texts.t("REFERRAL_INVITE_FEATURE_FAST", "🚀 Быстрое подключение")
+ "\n"
+ texts.t("REFERRAL_INVITE_FEATURE_SERVERS", "🌍 Серверы по всему миру")
+ "\n"
+ texts.t("REFERRAL_INVITE_FEATURE_SECURE", "🔒 Надежная защита")
+ "\n\n"
+ texts.t("REFERRAL_INVITE_LINK_PROMPT", "👇 Переходи по ссылке:")
+ f"\n{referral_link}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="📤 Поделиться",
text=texts.t("REFERRAL_SHARE_BUTTON", "📤 Поделиться"),
switch_inline_query=invite_text
)],
[types.InlineKeyboardButton(
@@ -278,8 +439,13 @@ async def create_invite_message(
await edit_or_answer_photo(
callback,
(
f"📝 <b>Приглашение создано!</b>\n\n"
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
texts.t("REFERRAL_INVITE_CREATED_TITLE", "📝 <b>Приглашение создано!</b>")
+ "\n\n"
+ texts.t(
"REFERRAL_INVITE_CREATED_INSTRUCTION",
"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
)
+ "\n\n"
f"<code>{invite_text}</code>"
),
keyboard,
+61 -50
View File
@@ -1,29 +1,34 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
await query.answer(
ok=False,
error_message="Ошибка валидации платежа. Попробуйте еще раз."
error_message=texts.t(
"STARS_PRECHECK_INVALID_PAYLOAD",
"Ошибка валидации платежа. Попробуйте еще раз.",
),
)
return
try:
from app.database.database import get_db
async for db in get_db():
@@ -32,26 +37,36 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
await query.answer(
ok=False,
error_message="Пользователь не найден. Обратитесь в поддержку."
error_message=texts.t(
"STARS_PRECHECK_USER_NOT_FOUND",
"Пользователь не найден. Обратитесь в поддержку.",
),
)
return
break
texts = get_texts(user.language or DEFAULT_LANGUAGE)
break
except Exception as db_error:
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
return
await query.answer(ok=True)
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
except Exception as e:
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
@@ -60,25 +75,32 @@ async def handle_successful_payment(
db: AsyncSession,
**kwargs
):
texts = get_texts(DEFAULT_LANGUAGE)
try:
payment = message.successful_payment
user_id = message.from_user.id
logger.info(
f"💳 Успешный Stars платеж от {user_id}: "
f"{payment.total_amount} XTR, "
f"payload: {payment.invoice_payload}, "
f"charge_id: {payment.telegram_payment_charge_id}"
)
user = await get_user_by_telegram_id(db, user_id)
texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE)
if not user:
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
await message.answer(
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
texts.t(
"STARS_PAYMENT_USER_NOT_FOUND",
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
)
)
return
payment_service = PaymentService(message.bot)
success = await payment_service.process_stars_payment(
db=db,
@@ -91,40 +113,23 @@ async def handle_successful_payment(
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
keyboard = await payment_service.build_topup_success_keyboard(user)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
]
)
transaction_id_short = payment.telegram_payment_charge_id[:8]
await message.answer(
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
f"⭐ Потрачено звезд: {payment.total_amount}\n"
f"💰 Зачислено на баланс: {int(rubles_amount)}\n"
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
f"Спасибо за пополнение! 🚀",
texts.t(
"STARS_PAYMENT_SUCCESS",
"🎉 <b>Платеж успешно обработан!</b>\n\n"
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
amount=int(rubles_amount),
transaction_id=transaction_id_short,
),
parse_mode="HTML",
reply_markup=keyboard,
)
@@ -136,15 +141,21 @@ async def handle_successful_payment(
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
await message.answer(
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную."
texts.t(
"STARS_PAYMENT_ENROLLMENT_ERROR",
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную.",
)
)
except Exception as e:
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
await message.answer(
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы."
texts.t(
"STARS_PAYMENT_PROCESSING_ERROR",
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы.",
)
)
+422 -202
View File
@@ -9,23 +9,67 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import RegistrationStates
from app.database.crud.user import (
get_user_by_telegram_id, create_user, get_user_by_referral_code
get_user_by_telegram_id,
create_user,
get_user_by_referral_code,
)
from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_by_id,
)
from app.database.models import UserStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
)
from app.localization.texts import get_texts
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts, get_rules
from app.services.referral_service import process_referral_registration
from app.services.campaign_service import AdvertisingCampaignService
from app.utils.user_utils import generate_unique_referral_code
from app.database.crud.user_message import get_random_active_message
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
logger = logging.getLogger(__name__)
async def _apply_campaign_bonus_if_needed(
db: AsyncSession,
user,
state_data: dict,
texts,
):
campaign_id = state_data.get("campaign_id") if state_data else None
if not campaign_id:
return None
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign or not campaign.is_active:
return None
service = AdvertisingCampaignService()
result = await service.apply_campaign_bonus(db, user, campaign)
if not result.success:
return None
if result.bonus_type == "balance":
amount_text = texts.format_price(result.balance_kopeks)
return texts.CAMPAIGN_BONUS_BALANCE.format(
amount=amount_text,
name=campaign.name,
)
if result.bonus_type == "subscription":
traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format(
name=campaign.name,
days=result.subscription_days,
traffic=traffic_text,
devices=result.subscription_device_limit,
)
return None
async def handle_potential_referral_code(
message: types.Message,
state: FSMContext,
@@ -44,34 +88,43 @@ async def handle_potential_referral_code(
user = await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status == UserStatus.ACTIVE.value:
return False
data = await state.get_data() or {}
language = (
data.get("language")
or (getattr(user, "language", None) if user else None)
or DEFAULT_LANGUAGE
)
texts = get_texts(language)
potential_code = message.text.strip()
if len(potential_code) < 4 or len(potential_code) > 20:
return False
referrer = await get_user_by_referral_code(db, potential_code)
if not referrer:
await message.answer(
await message.answer(texts.t(
"REFERRAL_CODE_INVALID_HELP",
"❌ Неверный реферальный код.\n\n"
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start"
)
return True
data = await state.get_data() or {}
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
))
return True
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer("✅ Реферальный код принят!")
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', 'ru')
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(
texts.RULES_TEXT,
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
@@ -86,13 +139,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
referral_code = None
if len(message.text.split()) > 1:
potential_code = message.text.split()[1]
referral_code = potential_code
logger.info(f"🔎 Найден реферальный код: {referral_code}")
campaign = None
start_args = message.text.split()
if len(start_args) > 1:
start_parameter = start_args[1]
campaign = await get_campaign_by_start_parameter(
db,
start_parameter,
only_active=True,
)
if campaign:
logger.info(
"📣 Найдена рекламная кампания %s (start=%s)",
campaign.id,
campaign.start_parameter,
)
await state.update_data(campaign_id=campaign.id)
else:
referral_code = start_parameter
logger.info(f"🔎 Найден реферальный код: {referral_code}")
if referral_code:
await state.set_data({'referral_code': referral_code})
await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
@@ -130,9 +199,27 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await db.commit()
texts = get_texts(user.language)
if referral_code and not user.referred_by_id:
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
await message.answer(
texts.t(
"ALREADY_REGISTERED_REFERRAL",
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
)
)
if campaign:
try:
await message.answer(
texts.t(
"CAMPAIGN_EXISTING_USERL",
"ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
)
)
except Exception as e:
logger.error(
f"Ошибка отправки уведомления о рекламной кампании: {e}"
)
has_active_subscription = user.subscription is not None
subscription_is_active = False
@@ -221,13 +308,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
else:
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
language = 'ru'
language = DEFAULT_LANGUAGE
texts = get_texts(language)
data = await state.get_data() or {}
data['language'] = language
await state.set_data(data)
logger.info(f"💾 Установлен русский язык по умолчанию")
logger.info(f"💾 Установлен язык по умолчанию: {language}")
if settings.SKIP_RULES_ACCEPT:
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
if data.get('referral_code'):
@@ -242,7 +329,10 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
else:
try:
await message.answer(
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
@@ -252,8 +342,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await complete_registration(message, state, db)
return
rules_text = await get_rules(language)
await message.answer(
texts.RULES_TEXT,
rules_text,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"📋 Правила отправлены")
@@ -276,11 +367,14 @@ async def process_rules_accept(
current_state = await state.get_state()
logger.info(f"📊 Текущее состояние: {current_state}")
language = DEFAULT_LANGUAGE
texts = get_texts(language)
try:
await callback.answer()
data = await state.get_data()
language = data.get('language', 'ru')
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
if callback.data == 'rules_accept':
@@ -293,10 +387,13 @@ async def process_rules_accept(
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
try:
await callback.message.edit_text(
"✅ Правила приняты! Завершаем регистрацию...",
texts.t(
"RULES_ACCEPTED_PROCESSING",
"✅ Правила приняты! Завершаем регистрацию...",
),
reply_markup=None
)
except:
except Exception:
pass
if data.get('referral_code'):
@@ -316,7 +413,10 @@ async def process_rules_accept(
else:
try:
await callback.message.answer(
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
@@ -328,9 +428,12 @@ async def process_rules_accept(
else:
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
rules_required_text = texts.t(
"RULES_REQUIRED",
"Для использования бота необходимо принять правила сервиса.",
)
try:
rules_required_text = getattr(texts, 'RULES_REQUIRED',
"Для использования бота необходимо принять правила сервиса.")
await callback.message.edit_text(
rules_required_text,
reply_markup=get_rules_keyboard(language)
@@ -338,7 +441,7 @@ async def process_rules_accept(
except Exception as e:
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
await callback.message.edit_text(
"Для использования бота необходимо принять правила сервиса.",
rules_required_text,
reply_markup=get_rules_keyboard(language)
)
@@ -346,13 +449,20 @@ async def process_rules_accept(
except Exception as e:
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
await callback.answer("❌ Произошла ошибка. Попробуйте еще раз.", show_alert=True)
await callback.answer(
texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."),
show_alert=True,
)
try:
data = await state.get_data()
language = data.get('language', 'ru')
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
await callback.message.answer(
"Произошла ошибка. Попробуйте принять правила еще раз:",
texts.t(
"ERROR_RULES_RETRY",
"Произошла ошибка. Попробуйте принять правила еще раз:",
),
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
@@ -368,20 +478,20 @@ async def process_referral_code_input(
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
data = await state.get_data()
language = data.get('language', 'ru')
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
referral_code = message.text.strip()
referrer = await get_user_by_referral_code(db, referral_code)
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer("✅ Реферальный код применен!")
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
logger.info(f"✅ Реферальный код применен")
else:
await message.answer("❌ Неверный реферальный код")
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
logger.info(f"❌ Неверный реферальный код")
return
@@ -389,14 +499,18 @@ async def process_referral_code_input(
async def process_referral_code_skip(
callback: types.CallbackQuery,
state: FSMContext,
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}")
await callback.answer()
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено")
@@ -404,7 +518,7 @@ async def process_referral_code_skip(
logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}")
try:
await callback.message.edit_text(
"✅ Завершаем регистрацию...",
texts.t("REGISTRATION_COMPLETING", "✅ Завершаем регистрацию..."),
reply_markup=None
)
except:
@@ -429,9 +543,14 @@ async def complete_registration_from_callback(
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
data = await state.get_data()
data = await state.get_data() or {}
if data.get('referral_code') and not existing_user.referred_by_id:
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
await callback.message.answer(
texts.t(
"ALREADY_REGISTERED_REFERRAL",
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
)
)
await db.refresh(existing_user, ['subscription'])
@@ -459,13 +578,18 @@ async def complete_registration_from_callback(
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await callback.message.answer(f"Добро пожаловать, {existing_user.full_name}!")
await callback.message.answer(
texts.t(
"WELCOME_FALLBACK",
"Добро пожаловать, {user_name}!",
).format(user_name=existing_user.full_name)
)
await state.clear()
return
data = await state.get_data()
language = data.get('language', 'ru')
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
referrer_id = data.get('referrer_id')
@@ -533,9 +657,35 @@ async def complete_registration_from_callback(
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
try:
await db.refresh(user)
except Exception as refresh_error:
logger.error(
"Ошибка обновления данных пользователя %s после бонуса кампании: %s",
user.telegram_id,
refresh_error,
)
try:
await db.refresh(user, ["subscription"])
except Exception as refresh_subscription_error:
logger.error(
"Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
user.telegram_id,
refresh_subscription_error,
)
await state.clear()
if campaign_message:
try:
await callback.message.answer(campaign_message)
except Exception as e:
logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, callback.from_user)
@@ -543,7 +693,7 @@ async def complete_registration_from_callback(
try:
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(),
reply_markup=get_post_registration_keyboard(user.language),
)
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
except Exception as e:
@@ -551,10 +701,10 @@ async def complete_registration_from_callback(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription = user.subscription is not None
has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
if user.subscription:
if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
@@ -576,7 +726,12 @@ async def complete_registration_from_callback(
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при показе главного меню: {e}")
await callback.message.answer(f"Добро пожаловать, {user.full_name}!")
await callback.message.answer(
texts.t(
"WELCOME_FALLBACK",
"Добро пожаловать, {user_name}!",
).format(user_name=user.full_name)
)
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
@@ -594,9 +749,14 @@ async def complete_registration(
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
data = await state.get_data()
data = await state.get_data() or {}
if data.get('referral_code') and not existing_user.referred_by_id:
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
await message.answer(
texts.t(
"ALREADY_REGISTERED_REFERRAL",
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
)
)
await db.refresh(existing_user, ['subscription'])
@@ -624,13 +784,18 @@ async def complete_registration(
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await message.answer(f"Добро пожаловать, {existing_user.full_name}!")
await message.answer(
texts.t(
"WELCOME_FALLBACK",
"Добро пожаловать, {user_name}!",
).format(user_name=existing_user.full_name)
)
await state.clear()
return
data = await state.get_data()
language = data.get('language', 'ru')
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
referrer_id = data.get('referrer_id')
@@ -698,9 +863,35 @@ async def complete_registration(
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
try:
await db.refresh(user)
except Exception as refresh_error:
logger.error(
"Ошибка обновления данных пользователя %s после бонуса кампании: %s",
user.telegram_id,
refresh_error,
)
try:
await db.refresh(user, ["subscription"])
except Exception as refresh_subscription_error:
logger.error(
"Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
user.telegram_id,
refresh_subscription_error,
)
await state.clear()
if campaign_message:
try:
await message.answer(campaign_message)
except Exception as e:
logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, message.from_user)
@@ -708,7 +899,7 @@ async def complete_registration(
try:
await message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(),
reply_markup=get_post_registration_keyboard(user.language),
)
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
except Exception as e:
@@ -716,10 +907,10 @@ async def complete_registration(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
has_active_subscription = user.subscription is not None
has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
if user.subscription:
if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
@@ -741,54 +932,92 @@ async def complete_registration(
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при показе главного меню: {e}")
await message.answer(f"Добро пожаловать, {user.full_name}!")
await message.answer(
texts.t(
"WELCOME_FALLBACK",
"Добро пожаловать, {user_name}!",
).format(user_name=user.full_name)
)
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
def _get_subscription_status(user, texts):
if not user or not hasattr(user, 'subscription'):
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
if not user.subscription:
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
if not user or not hasattr(user, "subscription") or not user.subscription:
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
subscription = user.subscription
from datetime import datetime
end_date = getattr(subscription, "end_date", None)
current_time = datetime.utcnow()
if hasattr(subscription, 'end_date') and subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
if hasattr(subscription, 'end_date'):
days_left = (subscription.end_date - current_time).days
else:
days_left = 0
is_trial = getattr(subscription, 'is_trial', False)
if end_date and end_date <= current_time:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date.strftime('%d.%m.%Y'))
if not end_date:
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
days_left = (end_date - current_time).days
is_trial = getattr(subscription, "is_trial", False)
if is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left == 1:
return "🎁 Тестовая подписка\n⚠️ истекает завтра!"
else:
return "🎁 Тестовая подписка\n⚠️ истекает сегодня!"
else:
if days_left > 7:
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left > 1:
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
elif days_left == 1:
return "💎 Активна\n⚠️ истекает завтра!"
else:
return "💎 Активна\n⚠️ истекает сегодня!"
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
if days_left == 1:
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
if days_left > 7:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
if days_left > 1:
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
if days_left == 1:
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
def _get_subscription_status_simple(texts):
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
if not random_message:
return base_text
prompt = action_prompt or ""
if prompt and prompt in base_text:
parts = base_text.split(prompt, 1)
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}"
return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1)
return f"{base_text}\n\n{random_message}"
def get_referral_code_keyboard(language: str):
@@ -797,59 +1026,47 @@ def get_referral_code_keyboard(language: str):
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="⭐️ Пропустить",
text=texts.t("REFERRAL_CODE_SKIP", "⭐️ Пропустить"),
callback_data="referral_skip"
)]
])
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status_simple(texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
@@ -860,90 +1077,90 @@ async def required_sub_channel_check(
db: AsyncSession,
db_user=None
):
language = DEFAULT_LANGUAGE
texts = get_texts(language)
try:
state_data = await state.get_data() or {}
user = db_user
if not user:
user = await get_user_by_telegram_id(db, query.from_user.id)
if user and getattr(user, "language", None):
language = user.language
elif state_data.get("language"):
language = state_data["language"]
texts = get_texts(language)
chat_member = await bot.get_chat_member(
chat_id=settings.CHANNEL_SUB_ID,
user_id=query.from_user.id
)
if chat_member.status not in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR]:
return await query.answer("❌ Вы не подписались на канал!", show_alert=True)
await query.answer("✅ Спасибо за подписку", show_alert=True)
return await query.answer(
texts.t("CHANNEL_SUBSCRIBE_REQUIRED_ALERT", "❌ Вы не подписались на канал!"),
show_alert=True,
)
await query.answer(
texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"),
show_alert=True,
)
try:
await query.message.delete()
except Exception as e:
logger.warning(f"Не удалось удалить сообщение: {e}")
user = await get_user_by_telegram_id(db, query.from_user.id)
if user and user.status != UserStatus.DELETED.value:
from app.localization.texts import get_texts
from app.handlers.start import get_main_menu_text
from app.keyboards.inline import get_main_menu_keyboard
texts = get_texts(user.language)
has_active_subscription = user.subscription is not None
subscription_is_active = False
if user.subscription:
subscription_is_active = user.subscription.is_active
has_active_subscription = bool(user.subscription)
subscription_is_active = bool(user.subscription and user.subscription.is_active)
menu_text = await get_main_menu_text(user, texts, db)
from app.utils.message_patch import LOGO_PATH
from aiogram.types import FSInputFile
keyboard = get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
)
if settings.ENABLE_LOGO_MODE:
await bot.send_photo(
chat_id=query.from_user.id,
photo=FSInputFile(LOGO_PATH),
caption=menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription
),
parse_mode="HTML"
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await bot.send_message(
chat_id=query.from_user.id,
text=menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription
),
parse_mode="HTML"
reply_markup=keyboard,
parse_mode="HTML",
)
else:
from app.localization.texts import get_texts
from app.keyboards.inline import get_rules_keyboard
language = 'ru'
texts = get_texts(language)
data = await state.get_data() or {}
data['language'] = language
await state.set_data(data)
state_data['language'] = language
await state.set_data(state_data)
if settings.SKIP_RULES_ACCEPT:
if settings.SKIP_REFERRAL_CODE:
from app.utils.user_utils import generate_unique_referral_code
referral_code = await generate_unique_referral_code(db, query.from_user.id)
user = await create_user(
db=db,
telegram_id=query.from_user.id,
@@ -951,44 +1168,47 @@ async def required_sub_channel_check(
first_name=query.from_user.first_name,
last_name=query.from_user.last_name,
language=language,
referral_code=referral_code
referral_code=referral_code,
)
await bot.send_message(
chat_id=query.from_user.id,
text=f"Добро пожаловать, {user.full_name}!",
text=texts.t("WELCOME_FALLBACK", "Добро пожаловать, {user_name}!").format(user_name=user.full_name),
)
else:
await bot.send_message(
chat_id=query.from_user.id,
text="У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
reply_markup=get_referral_code_keyboard(language)
text=texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
else:
from app.utils.message_patch import LOGO_PATH
from aiogram.types import FSInputFile
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE:
await bot.send_photo(
chat_id=query.from_user.id,
photo=FSInputFile(LOGO_PATH),
caption=texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
caption=rules_text,
reply_markup=get_rules_keyboard(language),
)
else:
await bot.send_message(
chat_id=query.from_user.id,
text=texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
text=rules_text,
reply_markup=get_rules_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
except Exception as e:
logger.error(f"Ошибка в required_sub_channel_check: {e}")
await query.answer("❌ Произошла ошибка!", show_alert=True)
await query.answer(f"{texts.ERROR}!", show_alert=True)
def register_handlers(dp: Dispatcher):
+1172 -362
View File
File diff suppressed because it is too large Load Diff
+248 -39
View File
@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import List, Optional, Tuple, Any
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.localization.texts import get_texts
@@ -36,12 +36,18 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
],
[
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
],
[
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
@@ -107,12 +113,26 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
],
[
InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
]
])
def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
]
])
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
@@ -147,6 +167,142 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"),
InlineKeyboardButton(text=" Создать", callback_data="admin_campaigns_create")
],
[
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
]
])
def get_campaign_management_keyboard(
campaign_id: int, is_active: bool, language: str = "ru"
) -> InlineKeyboardMarkup:
status_text = "🔴 Выключить" if is_active else "🟢 Включить"
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="📊 Статистика",
callback_data=f"admin_campaign_stats_{campaign_id}",
),
InlineKeyboardButton(
text=status_text,
callback_data=f"admin_campaign_toggle_{campaign_id}",
),
],
[
InlineKeyboardButton(
text="✏️ Редактировать",
callback_data=f"admin_campaign_edit_{campaign_id}",
)
],
[
InlineKeyboardButton(
text="🗑️ Удалить",
callback_data=f"admin_campaign_delete_{campaign_id}",
)
],
[
InlineKeyboardButton(
text="⬅️ К списку", callback_data="admin_campaigns_list"
)
],
]
)
def get_campaign_edit_keyboard(
campaign_id: int,
*,
is_balance_bonus: bool,
language: str = "ru",
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard: List[List[InlineKeyboardButton]] = [
[
InlineKeyboardButton(
text="✏️ Название",
callback_data=f"admin_campaign_edit_name_{campaign_id}",
),
InlineKeyboardButton(
text="🔗 Параметр",
callback_data=f"admin_campaign_edit_start_{campaign_id}",
),
]
]
if is_balance_bonus:
keyboard.append(
[
InlineKeyboardButton(
text="💰 Бонус на баланс",
callback_data=f"admin_campaign_edit_balance_{campaign_id}",
)
]
)
else:
keyboard.extend(
[
[
InlineKeyboardButton(
text="📅 Длительность",
callback_data=f"admin_campaign_edit_sub_days_{campaign_id}",
),
InlineKeyboardButton(
text="🌐 Трафик",
callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}",
),
],
[
InlineKeyboardButton(
text="📱 Устройства",
callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}",
),
InlineKeyboardButton(
text="🌍 Серверы",
callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}",
),
],
]
)
keyboard.append(
[
InlineKeyboardButton(
text=texts.BACK, callback_data=f"admin_campaign_manage_{campaign_id}"
)
]
)
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"),
InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns")
]
])
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
@@ -225,7 +381,7 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
],
[
InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals")
InlineKeyboardButton(text="🤝 Партнерка", callback_data="admin_stats_referrals")
],
[
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
@@ -236,14 +392,21 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = [
[
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")
],
[
InlineKeyboardButton(
text=texts.ADMIN_USER_PROMO_GROUP_BUTTON,
callback_data=f"admin_user_promo_group_{user_id}"
)
],
[
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
],
[
@@ -267,9 +430,39 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_user_promo_group_keyboard(
promo_groups: List[Tuple[Any, int]],
user_id: int,
current_group_id: Optional[int],
language: str = "ru"
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard: List[List[InlineKeyboardButton]] = []
for group, members_count in promo_groups:
prefix = "" if current_group_id is not None and group.id == current_group_id else "👥"
count_text = f" ({members_count})" if members_count else ""
keyboard.append([
InlineKeyboardButton(
text=f"{prefix} {group.name}{count_text}",
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.ADMIN_USER_PROMO_GROUP_BACK,
callback_data=f"admin_user_manage_{user_id}"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -913,22 +1106,30 @@ def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) ->
return InlineKeyboardMarkup(inline_keyboard=keyboard)
DEFAULT_BROADCAST_BUTTONS = ("home",)
BROADCAST_BUTTONS = {
"balance": {"text": "💰 Пополнить баланс", "callback": "balance_topup"},
"referrals": {"text": "🤝 Партнерка", "callback": "menu_referrals"},
"promocode": {"text": "🎫 Промокод", "callback": "menu_promocode"},
"connect": {"text": "🔗 Подключиться", "callback": "subscription_connect"},
"subscription": {"text": "📱 Подписка", "callback": "menu_subscription"},
"support": {"text": "🛠️ Техподдержка", "callback": "menu_support"},
"home": {"text": "🏠 На главную", "callback": "back_to_menu"},
}
BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = (
("balance", "referrals"),
("promocode", "connect"),
("subscription", "support"),
("home",),
)
BROADCAST_BUTTON_LABELS = {key: value["text"] for key, value in BROADCAST_BUTTONS.items()}
def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
],
[
InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
],
[
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard_with_media(list(DEFAULT_BROADCAST_BUTTONS), False, language)
def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
@@ -958,25 +1159,33 @@ def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
keyboard = [
[
InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
],
[
InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
]
]
selected_buttons = selected_buttons or []
keyboard: list[list[InlineKeyboardButton]] = []
for row in BROADCAST_BUTTON_ROWS:
row_buttons: list[InlineKeyboardButton] = []
for button_key in row:
button_config = BROADCAST_BUTTONS[button_key]
base_text = button_config["text"]
if button_key in selected_buttons:
if " " in base_text:
toggle_text = f"{base_text.split(' ', 1)[1]}"
else:
toggle_text = f"{base_text}"
else:
toggle_text = base_text
row_buttons.append(
InlineKeyboardButton(text=toggle_text, callback_data=f"btn_{button_key}")
)
if row_buttons:
keyboard.append(row_buttons)
if has_media:
keyboard.append([
InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
])
keyboard.extend([
[
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
@@ -985,5 +1194,5 @@ def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: l
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+260 -146
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -33,19 +33,19 @@ def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="👥 Пользователи"),
KeyboardButton(text="📱 Подписки")
KeyboardButton(text=texts.ADMIN_USERS),
KeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS)
],
[
KeyboardButton(text="🎫 Промокоды"),
KeyboardButton(text="📨 Рассылки")
KeyboardButton(text=texts.ADMIN_PROMOCODES),
KeyboardButton(text=texts.ADMIN_MESSAGES)
],
[
KeyboardButton(text="📊 Статистика"),
KeyboardButton(text="🔧 Мониторинг")
KeyboardButton(text=texts.ADMIN_STATISTICS),
KeyboardButton(text=texts.ADMIN_MONITORING)
],
[
KeyboardButton(text="🏠 Главное меню")
KeyboardButton(text=texts.t("ADMIN_MAIN_MENU", "🏠 Главное меню"))
]
],
resize_keyboard=True,
@@ -81,9 +81,10 @@ def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="⏭️ Пропустить")]
[KeyboardButton(text=texts.REFERRAL_CODE_SKIP)]
],
resize_keyboard=True,
one_time_keyboard=True
@@ -95,10 +96,11 @@ def remove_keyboard() -> ReplyKeyboardRemove:
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
[KeyboardButton(text="❌ Отмена")]
[KeyboardButton(text=texts.t("SEND_CONTACT_BUTTON", "📱 Отправить контакт"), request_contact=True)],
[KeyboardButton(text=texts.CANCEL)]
],
resize_keyboard=True,
one_time_keyboard=True
@@ -106,11 +108,12 @@ def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
[KeyboardButton(text="❌ Отмена")]
[KeyboardButton(text=texts.t("SEND_LOCATION_BUTTON", "📍 Отправить геолокацию"), request_location=True)],
[KeyboardButton(text=texts.CANCEL)]
],
resize_keyboard=True,
one_time_keyboard=True
)
)
+16
View File
@@ -0,0 +1,16 @@
# Sample localization for English language.
# Keys correspond to constants defined in app/localization/texts.py.
WELCOME: |
Welcome to Remnawave Bedolaga Bot!
Update this text to greet users in your own style.
MENU:
BALANCE: "Balance"
SUBSCRIPTION: "Subscription"
RULES_TEXT: |
Remnawave service rules:
1. Follow the law of your jurisdiction.
2. Do not distribute spam or malicious content.
3. Respect other community members.
+16
View File
@@ -0,0 +1,16 @@
# Пример локализации на русском языке.
# Ключи соответствуют именам констант из app/localization/texts.py.
WELCOME: |
Добро пожаловать в Remnawave Bedolaga Bot!
Эти строки заменят стандартное приветствие бота.
MENU:
BALANCE: "Баланс"
SUBSCRIPTION: "Подписка"
RULES_TEXT: |
Правила сервиса Remnawave:
1. Следуйте законам своей страны.
2. Не распространяйте спам и вредоносный контент.
3. Уважайте других пользователей.
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import json
import logging
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict
from app.config import settings
_logger = logging.getLogger(__name__)
_FALLBACK_LANGUAGE = "ru"
_BASE_DIR = Path(__file__).resolve().parent
_DEFAULT_LOCALES_DIR = _BASE_DIR / "locales"
def _normalize_language_code(value: Any) -> str:
if isinstance(value, str):
return value.strip().lower()
if value is None:
return ""
return str(value).strip().lower()
def _resolve_user_locales_dir() -> Path:
path = Path(settings.LOCALES_PATH).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path
def _locale_file_exists(language: str) -> bool:
code = _normalize_language_code(language)
if not code:
return False
default_candidate = _DEFAULT_LOCALES_DIR / f"{code}.json"
if default_candidate.exists():
return True
user_dir = _resolve_user_locales_dir()
for extension in (".json", ".yml", ".yaml"):
if (user_dir / f"{code}{extension}").exists():
return True
return False
def _select_fallback_language(available_map: Dict[str, str]) -> str:
candidates = []
if _FALLBACK_LANGUAGE:
candidates.append(_FALLBACK_LANGUAGE)
candidates.extend(available_map.values())
seen = set()
for candidate in candidates:
normalized = _normalize_language_code(candidate)
if not normalized or normalized in seen:
continue
seen.add(normalized)
if normalized in available_map:
return available_map[normalized]
if _locale_file_exists(normalized):
return normalized
if _FALLBACK_LANGUAGE and _locale_file_exists(_FALLBACK_LANGUAGE):
return _FALLBACK_LANGUAGE
return _FALLBACK_LANGUAGE or "ru"
def _determine_default_language() -> str:
try:
raw_default = settings.DEFAULT_LANGUAGE
except AttributeError:
raw_default = None
configured = raw_default.strip() if isinstance(raw_default, str) else ""
try:
available_languages = settings.get_available_languages()
except Exception as error: # pragma: no cover - defensive logging
_logger.warning("Failed to load available languages from settings: %s", error)
available_languages = []
available_map = {
_normalize_language_code(lang): lang.strip()
for lang in available_languages
if isinstance(lang, str) and lang.strip()
}
if configured:
normalized_configured = _normalize_language_code(configured)
if normalized_configured in available_map:
return available_map[normalized_configured]
if not available_map and _locale_file_exists(normalized_configured):
return normalized_configured
if _locale_file_exists(normalized_configured):
_logger.warning(
"Configured default language '%s' is not listed in AVAILABLE_LANGUAGES. Falling back to '%s'.",
configured,
_FALLBACK_LANGUAGE,
)
else:
_logger.warning(
"Configured default language '%s' is not available. Falling back to '%s'.",
configured,
_FALLBACK_LANGUAGE,
)
else:
_logger.debug("DEFAULT_LANGUAGE is not set. Falling back to '%s'.", _FALLBACK_LANGUAGE)
fallback_language = _select_fallback_language(available_map)
if _normalize_language_code(fallback_language) != _normalize_language_code(_FALLBACK_LANGUAGE):
_logger.warning(
"Fallback language '%s' is not available. Using '%s' instead.",
_FALLBACK_LANGUAGE,
fallback_language,
)
return fallback_language or _FALLBACK_LANGUAGE
DEFAULT_LANGUAGE = _determine_default_language()
def _normalize_key(raw_key: Any) -> str:
key = str(raw_key).strip().replace(" ", "_")
return key.upper()
def _flatten_locale_dict(data: Dict[str, Any], parent_key: str = "") -> Dict[str, Any]:
flattened: Dict[str, Any] = {}
for key, value in (data or {}).items():
composite_key = _normalize_key(key)
if parent_key:
composite_key = f"{parent_key}_{composite_key}"
if isinstance(value, dict):
flattened.update(_flatten_locale_dict(value, composite_key))
else:
flattened[composite_key] = value
return flattened
def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
normalized: Dict[str, Any] = {}
for key, value in (data or {}).items():
if isinstance(value, dict):
normalized.update(_flatten_locale_dict(value, _normalize_key(key)))
else:
normalized[_normalize_key(key)] = value
return normalized
def ensure_locale_templates() -> None:
destination = _resolve_user_locales_dir()
try:
destination.mkdir(parents=True, exist_ok=True)
except Exception as error:
_logger.warning("Unable to create locales directory %s: %s", destination, error)
return
if any(destination.glob("*")):
return
if not _DEFAULT_LOCALES_DIR.exists():
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
return
for template in _DEFAULT_LOCALES_DIR.iterdir():
if not template.is_file():
continue
target_path = destination / template.name
try:
shutil.copyfile(template, target_path)
except Exception as error:
_logger.warning(
"Failed to copy default locale %s to %s: %s",
template,
target_path,
error,
)
def _load_default_locale(language: str) -> Dict[str, Any]:
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
if not default_path.exists():
return {}
return _normalize_locale_dict(_load_locale_file(default_path))
def _load_user_locale(language: str) -> Dict[str, Any]:
user_dir = _resolve_user_locales_dir()
for extension in (".json", ".yml", ".yaml"):
candidate = user_dir / f"{language}{extension}"
if candidate.exists():
return _normalize_locale_dict(_load_locale_file(candidate))
return {}
def _load_locale_file(path: Path) -> Dict[str, Any]:
suffix = path.suffix.lower()
try:
if suffix == ".json":
return json.loads(path.read_text(encoding="utf-8"))
if suffix in {".yml", ".yaml"}:
try:
import yaml # type: ignore
except ModuleNotFoundError as import_error:
raise RuntimeError(
"PyYAML is required to load YAML locale files. Install PyYAML or provide JSON files."
) from import_error
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as error:
_logger.warning("Failed to parse locale file %s: %s", path, error)
return {}
_logger.warning("Unsupported locale format for %s", path)
return {}
def _merge_dicts(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
result = dict(base)
for key, value in overrides.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = _merge_dicts(result[key], value)
else:
result[key] = value
return result
@lru_cache(maxsize=None)
def load_locale(language: str) -> Dict[str, Any]:
language = language or DEFAULT_LANGUAGE
defaults = _load_default_locale(language)
overrides = _load_user_locale(language)
merged = _merge_dicts(defaults, overrides)
if not merged and language != DEFAULT_LANGUAGE:
_logger.warning(
"Locale %s not found. Falling back to default language %s.",
language,
DEFAULT_LANGUAGE,
)
return load_locale(DEFAULT_LANGUAGE)
return merged
def clear_locale_cache() -> None:
load_locale.cache_clear()
+390
View File
@@ -0,0 +1,390 @@
{
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
"ADMIN_MAIN_MENU": "🏠 Main menu",
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
"AUTOPAY_BUTTON": "💳 Auto payment",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
"BACK": "⬅️ Back",
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
"CANCEL": "❌ Cancel",
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
"CONFIRM": "✅ Confirm",
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
"CONNECT_BUTTON": "🔗 Connect",
"CONTINUE": "➡️ Continue",
"CONTINUE_BUTTON": "➡️ Continue",
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
"CREATE_INVITE_BUTTON": "📝 Create invite",
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
"DEVICE_GUIDE_MAC": "🎯 macOS",
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
"DISABLE_BUTTON": "❌ Disable",
"ENABLE_BUTTON": "✅ Enable",
"ERROR": "❌ An error occurred",
"ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.",
"ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:",
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
"LOADING": "⏳ Loading...",
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
"MAIN_MENU_BUTTON": "🏠 Main menu",
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
"MENU_BALANCE": "💰 Balance",
"MENU_SUBSCRIPTION": "📱 Subscription",
"MENU_TRIAL": "🎁 Trial subscription",
"MY_BALANCE_BUTTON": "💰 My balance",
"MY_SUBSCRIPTION_BUTTON": "📱 My subscription",
"NO": "❌ No",
"NO_SERVERS_AVAILABLE": "❌ No servers available",
"NO_TRAFFIC_PACKAGES": "❌ No packages available",
"OTHER_APPS_BUTTON": "📋 Other apps",
"PAGINATION_NEXT": "➡️",
"PAGINATION_PREV": "⬅️",
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PAY_NOW_BUTTON": "💳 Pay",
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
"REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!",
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_CODE_SKIP": "⏭️ Skip",
"ALREADY_REGISTERED_REFERRAL": "️ You are already registered. A referral link cannot be applied.",
"REFERRAL_LIST_BUTTON": "👥 Referral list",
"RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices",
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device",
"RESET_TRAFFIC_BUTTON": "🔄 Reset traffic",
"RULES_HEADER": "📋 <b>Service Rules</b>",
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
"SEND_CONTACT_BUTTON": "📱 Share contact",
"SEND_LOCATION_BUTTON": "📍 Share location",
"SHOW_QR_BUTTON": "📱 Show QR code",
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
"SKIP_BUTTON": "Skip ➡️",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
"SUB_STATUS_NONE": "❌ Not available",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!",
"SUBSCRIPTION_ACTIVE": "✅ Active",
"SUCCESS": "✅ Success",
"REGISTRATION_COMPLETING": "✅ Completing registration...",
"SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic",
"TOPUP_BALANCE_BUTTON": "💳 Top up balance",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
"STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.",
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
"WELCOME_FALLBACK": "Welcome, {user_name}!",
"YES": "✅ Yes",
"ACCESS_DENIED": "❌ Access denied",
"ADMIN_MESSAGES": "📨 Broadcasts",
"ADMIN_MONITORING": "🔍 Monitoring",
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
"ADMIN_PROMOCODES": "🎫 Promo codes",
"ADMIN_REFERRALS": "🤝 Referral program",
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
"ADMIN_RULES": "📋 Rules",
"ADMIN_STATISTICS": "📊 Statistics",
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.",
"ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:",
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”",
"ADMIN_USER_PROMO_GROUP_ALREADY": "️ The user is already in this promo group.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
"ADMIN_USERS": "👥 Users",
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
"AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically",
"AUTOPAY_FAILED": "\n❌ <b>Autopay failed</b>\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n",
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
"BALANCE_BUTTON": "💰 Balance: {balance}",
"BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽",
"BALANCE_HISTORY": "📊 Transaction history",
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOP_UP": "💳 Top up",
"CAMPAIGN_EXISTING_USER": "️ This promo link is available only to new users.",
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Youve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Your promo group discounts</b>",
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%",
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%",
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\n️ Payments are not refunded\n",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CONTACT_SUPPORT": "💬 Contact support",
"CREATE_INVITE": "📝 Create invite",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
"DEVICES_NO_CHANGE": "️ Device limit was not changed",
"INVALID_AMOUNT": "❌ Invalid amount",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
"MENU_ADMIN": "⚙️ Admin panel",
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
"MENU_PROMOCODE": "🎫 Promo code",
"MENU_REFERRALS": "🤝 Referral program",
"MENU_RULES": "📋 Service rules",
"MENU_SUPPORT": "🛠️ Support",
"OPERATION_CANCELLED": "❌ Operation cancelled",
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
"PROMOCODE_ENTER": "🎫 Enter promo code",
"PROMOCODE_EXPIRED": "❌ Promo code has expired",
"PROMOCODE_INVALID": "❌ Invalid promo code",
"PROMOCODE_SUCCESS": "🎉 Promo code applied!",
"PROMOCODE_USED": "️ Promo code has already been used",
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
"RULES_ACCEPT": "✅ I accept the rules",
"RULES_DECLINE": "❌ I do not accept",
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
"SELECT_COUNTRIES": "Select countries:",
"SELECT_DEVICES": "Number of devices:",
"SELECT_PERIOD": "Choose period:",
"SELECT_TRAFFIC": "Choose traffic package:",
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew it to restore access.\n",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_INFO": "\n📱 <b>Subscription details</b>\n\n📊 <b>Status:</b> {status}\n🎭 <b>Type:</b> {type}\n📅 <b>Valid until:</b> {end_date}\n⏰ <b>Days left:</b> {days_left}\n\n📈 <b>Traffic:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Servers:</b> {countries_count} countries\n📱 <b>Devices:</b> {devices_used} / {devices_limit}\n\n💳 <b>Autopay:</b> {autopay_status}\n",
"SUBSCRIPTION_NONE": "❌ No active subscription",
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n️ Payments are not refunded\n",
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n",
"SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit",
"TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):",
"TOP_UP_METHODS": "\n💳 <b>Select a payment method</b>\n\nAmount: {amount}\n",
"TOP_UP_STARS": "⭐ Telegram Stars",
"TOP_UP_TRIBUTE": "💎 Bank card",
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"USER_NOT_FOUND": "❌ User not found",
"MENU_LANGUAGE": "🌐 Language",
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
"SUBSCRIPTION_STATUS_ACTIVE": "Active",
"SUBSCRIPTION_STATUS_UNKNOWN": "Unknown",
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired",
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days",
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr",
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min",
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!",
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!",
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!",
"SUBSCRIPTION_TYPE_TRIAL": "Trial",
"SUBSCRIPTION_TYPE_PAID": "Paid",
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB",
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB",
"SUBSCRIPTION_NO_SERVERS": "No servers",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Connected devices:</b>\n",
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Your import link for the VPN app:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured",
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.",
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
"SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:",
"SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>How to use:</b>",
"SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it",
"SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app",
"SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option",
"SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link",
"SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.",
"REFERRAL_PROGRAM_TITLE": "👥 <b>Referral program</b>",
"REFERRAL_STATS_HEADER": "📊 <b>Your statistics:</b>",
"REFERRAL_STATS_INVITED": "• Invited users: <b>{count}</b>",
"REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: <b>{count}</b>",
"REFERRAL_STATS_ACTIVE": "• Active referrals: <b>{count}</b>",
"REFERRAL_STATS_CONVERSION": "• Conversion: <b>{rate}%</b>",
"REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: <b>{amount}</b>",
"REFERRAL_STATS_MONTH_EARNED": "• Earned last month: <b>{amount}</b>",
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Latest rewards:</b>",
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission",
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission",
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: <b>{count}</b> ({amount})",
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}",
"REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}",
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Referral analytics</b>",
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Earnings by period:</b>",
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}",
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Top {count} referrals:</b>",
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)",
"REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!",
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
"REFERRAL_SHARE_BUTTON": "📤 Share",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
}
+390
View File
@@ -0,0 +1,390 @@
{
"ACCESS_DENIED": "❌ Доступ запрещен",
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
"ADMIN_MAIN_MENU": "🏠 Главное меню",
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MONITORING": "🔍 Мониторинг",
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
"ADMIN_PROMOCODES": "🎫 Промокоды",
"ADMIN_REFERRALS": "🤝 Партнерка",
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
"ADMIN_RULES": "📋 Правила",
"ADMIN_STATISTICS": "📊 Статистика",
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.",
"ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:",
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»",
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
"ADMIN_USERS": "👥 Пользователи",
"AUTOPAY_BUTTON": "💳 Автоплатёж",
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"BACK": "⬅️ Назад",
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
"BALANCE_BUTTON": "💰 Баланс: {balance}",
"BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}",
"BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽",
"BALANCE_HISTORY": "📊 История операций",
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOP_UP": "💳 Пополнить",
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Скидки вашей промогруппы</b>",
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%",
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%",
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
"CANCEL": "❌ Отмена",
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
"CONFIRM": "✅ Подтвердить",
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
"CONNECT_BUTTON": "🔗 Подключиться",
"CONTACT_SUPPORT": "💬 Написать в поддержку",
"CONTINUE": "➡️ Продолжить",
"CONTINUE_BUTTON": "✅ Продолжить",
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
"CREATE_INVITE": "📝 Создать приглашение",
"CREATE_INVITE_BUTTON": "📝 Создать приглашение",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
"DEVICE_GUIDE_MAC": "🎯 macOS",
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
"DISABLE_BUTTON": "❌ Выключить",
"ENABLE_BUTTON": "✅ Включить",
"ERROR": "❌ Произошла ошибка",
"ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.",
"ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:",
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
"INVALID_AMOUNT": "❌ Неверная сумма",
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
"LOADING": "⏳ Загрузка...",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
"MAIN_MENU_BUTTON": "🏠 Главное меню",
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
"MENU_ADMIN": "⚙️ Админ-панель",
"MENU_BALANCE": "💰 Баланс",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
"MENU_RULES": "📋 Правила сервиса",
"MENU_SUBSCRIPTION": "📱 Подписка",
"MENU_SUPPORT": "🛠️ Техподдержка",
"MENU_TRIAL": "🧪 Тестовая подписка",
"MY_BALANCE_BUTTON": "💰 Мой баланс",
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
"NO": "❌ Нет",
"NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов",
"NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов",
"OPERATION_CANCELLED": "❌ Операция отменена",
"OTHER_APPS_BUTTON": "📋 Другие приложения",
"PAGINATION_NEXT": "➡️",
"PAGINATION_PREV": "⬅️",
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PAY_NOW_BUTTON": "💳 Оплатить",
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀",
"PROMOCODE_ENTER": "🎫 Введите промокод:",
"PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод",
"PROMOCODE_EXPIRED": "❌ Промокод истек",
"PROMOCODE_INVALID": "❌ Неверный промокод",
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
"PROMOCODE_USED": "❌ Промокод уже использован",
"REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика",
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
"REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!",
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
"RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства",
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство",
"RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик",
"RULES_ACCEPT": "✅ Принимаю правила",
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
"RULES_DECLINE": "❌ Не принимаю",
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
"SELECT_COUNTRIES": "Выберите страны:",
"SELECT_DEVICES": "Количество устройств:",
"SELECT_PERIOD": "Выберите период:",
"SELECT_TRAFFIC": "Выберите пакет трафика:",
"SEND_CONTACT_BUTTON": "📱 Отправить контакт",
"SEND_LOCATION_BUTTON": "📍 Отправить геолокацию",
"SHOW_QR_BUTTON": "📱 Показать QR код",
"SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки",
"SKIP_BUTTON": "⏭️ Пропустить",
"SUBSCRIPTION_ACTIVE": "✅ Активна",
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_INFO": "\n📱 <b>Информация о подписке</b>\n\n📊 <b>Статус:</b> {status}\n🎭 <b>Тип:</b> {type}\n📅 <b>Действует до:</b> {end_date}\n⏰ <b>Осталось дней:</b> {days_left}\n\n📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Серверы:</b> {countries_count} стран\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n",
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.",
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
"SUB_STATUS_NONE": "❌ Отсутствует",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!",
"SUCCESS": "✅ Успешно",
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n",
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n",
"SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика",
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
"TOP_UP_STARS": "⭐ Telegram Stars",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
"STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.",
"TOP_UP_TRIBUTE": "💎 Банковская карта",
"TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
"USER_NOT_FOUND": "❌ Пользователь не найден",
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
"YES": "✅ Да",
"SUBSCRIPTION_STATUS_EXPIRED": "Истекла",
"SUBSCRIPTION_STATUS_TRIAL": "Тестовая",
"SUBSCRIPTION_STATUS_ACTIVE": "Активна",
"SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно",
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк",
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.",
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.",
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.",
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!",
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!",
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!",
"SUBSCRIPTION_TYPE_TRIAL": "Триал",
"SUBSCRIPTION_TYPE_PAID": "Платная",
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ",
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ",
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Подключенные устройства:</b>\n",
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
"SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:",
"SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>Как использовать:</b>",
"SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать",
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение",
"SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
"SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку",
"SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
"REFERRAL_PROGRAM_TITLE": "👥 <b>Реферальная программа</b>",
"REFERRAL_STATS_HEADER": "📊 <b>Ваша статистика:</b>",
"REFERRAL_STATS_INVITED": "• Приглашено пользователей: <b>{count}</b>",
"REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: <b>{count}</b>",
"REFERRAL_STATS_ACTIVE": "• Активных рефералов: <b>{count}</b>",
"REFERRAL_STATS_CONVERSION": "• Конверсия: <b>{rate}%</b>",
"REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: <b>{amount}</b>",
"REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: <b>{amount}</b>",
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Последние начисления:</b>",
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения",
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки",
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: <b>{count}</b> ({amount})",
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}",
"REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}",
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Аналитика рефералов</b>",
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Доходы по периодам:</b>",
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}",
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Топ-{count} рефералов:</b>",
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)",
"REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!",
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
}
+192 -569
View File
@@ -1,603 +1,226 @@
from __future__ import annotations
import asyncio
from typing import Dict, Any
import logging
from typing import Any, Dict
from app.config import settings
from app.localization.loader import (
DEFAULT_LANGUAGE,
clear_locale_cache,
load_locale,
)
_cached_rules = {}
_logger = logging.getLogger(__name__)
def _get_default_rules(language: str = "ru") -> str:
if language == "en":
return """
🔒 <b>Service Usage Rules</b>
_cached_rules: Dict[str, str] = {}
1. It is forbidden to use the service for illegal activities
2. Copyright infringement is prohibited
3. Spam and malware distribution are prohibited
4. Using the service for DDoS attacks is prohibited
5. One account - one user
6. Refunds are made only in exceptional cases
7. Administration reserves the right to block an account for violating the rules
<b>By accepting the rules, you agree to comply with them.</b>
"""
else:
return """
📋 <b>Правила использования сервиса</b>
def _get_cached_rules_value(language: str) -> str:
if language in _cached_rules:
return _cached_rules[language]
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
default = _get_default_rules(language)
_cached_rules[language] = default
return default
def _build_dynamic_values(language: str) -> Dict[str, Any]:
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
if language_code == "ru":
return {
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛠️ <b>Техническая поддержка</b>\n\n"
"По всем вопросам обращайтесь к нашей поддержке:\n\n"
f"👤 {settings.SUPPORT_USERNAME}\n\n"
"Мы поможем с:\n"
"• Настройкой подключения\n"
"• Решением технических проблем \n"
"• Вопросами по оплате\n"
"• Другими вопросами\n\n"
"⏰ Время ответа: обычно в течение 1-2 часов\n"
),
}
if language_code == "en":
return {
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛠️ <b>Technical support</b>\n\n"
"For any questions contact our support:\n\n"
f"👤 {settings.SUPPORT_USERNAME}\n\n"
"We can help with:\n"
"• Connection setup\n"
"• Troubleshooting issues\n"
"• Payment questions\n"
"• Other requests\n\n"
"⏰ Response time: usually within 1-2 hours\n"
),
}
return {}
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
class Texts:
def __init__(self, language: str = "ru"):
self.language = language
@property
def RULES_TEXT(self) -> str:
if self.language in _cached_rules:
return _cached_rules[self.language]
return _get_default_rules(self.language)
BACK = "⬅️ Назад"
CANCEL = "❌ Отмена"
CONFIRM = "✅ Подтвердить"
CONTINUE = "➡️ Продолжить"
YES = "✅ Да"
NO = "❌ Нет"
LOADING = "⏳ Загрузка..."
ERROR = "❌ Произошла ошибка"
SUCCESS = "✅ Успешно"
def __init__(self, language: str = DEFAULT_LANGUAGE):
self.language = language or DEFAULT_LANGUAGE
raw_data = load_locale(self.language)
self._values = {key: value for key, value in raw_data.items()}
if self.language != DEFAULT_LANGUAGE:
fallback_data = load_locale(DEFAULT_LANGUAGE)
else:
fallback_data = self._values
self._fallback_values = {
key: value for key, value in fallback_data.items() if key not in self._values
}
self._values.update(_build_dynamic_values(self.language))
def __getattr__(self, item: str) -> Any:
if item == "language":
return super().__getattribute__(item)
try:
return self._get_value(item)
except KeyError as error:
raise AttributeError(item) from error
def __getitem__(self, item: str) -> Any:
return self._get_value(item)
def get(self, item: str, default: Any = None) -> Any:
try:
return self._get_value(item)
except KeyError:
return default
def t(self, key: str, default: Any = None) -> Any:
try:
return self._get_value(key)
except KeyError:
if default is not None:
return default
raise
def _get_value(self, item: str) -> Any:
if item == "RULES_TEXT":
return _get_cached_rules_value(self.language)
if item in self._values:
return self._values[item]
if item in self._fallback_values:
return self._fallback_values[item]
_logger.warning(
"Missing localization key '%s' for language '%s'",
item,
self.language,
)
raise KeyError(item)
@staticmethod
def format_price(kopeks: int) -> str:
return f"{int(kopeks / 100)}"
return settings.format_price(kopeks)
@staticmethod
def format_traffic(gb: float) -> str:
if gb == 0:
return "∞ (безлимит)"
elif gb >= 1024:
return f"{gb/1024:.1f} ТБ"
else:
return f"{gb:.0f} ГБ"
if gb >= 1024:
return f"{gb / 1024:.1f} ТБ"
return f"{gb:.0f} ГБ"
class RussianTexts(Texts):
def __init__(self):
super().__init__("ru")
WELCOME = """
🎉 <b>Добро пожаловать в VPN сервис!</b>
def get_texts(language: str = DEFAULT_LANGUAGE) -> Texts:
return Texts(language)
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
🔐 <b>Преимущества:</b>
Высокая скорость подключения
Серверы в разных странах
Надежная защита данных
Круглосуточная поддержка
Для начала работы выберите язык интерфейса:
"""
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
RULES_ACCEPT = "✅ Принимаю правила"
RULES_DECLINE = "❌ Не принимаю"
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
REFERRAL_CODE_QUESTION = """
🤝 <b>У вас есть реферальный код от друга?</b>
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
Введите код или нажмите "Пропустить":
"""
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """👤 <b>{user_name}</b>
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
MENU_BALANCE = "💰 Баланс"
MENU_SUBSCRIPTION = "📱 Подписка"
MENU_TRIAL = "🧪 Тестовая подписка"
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
MENU_PROMOCODE = "🎫 Промокод"
MENU_REFERRALS = "🤝 Рефералы"
MENU_SUPPORT = "🛠️ Техподдержка"
MENU_RULES = "📋 Правила сервиса"
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
SUBSCRIPTION_ACTIVE = "✅ Активна"
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
SUBSCRIPTION_INFO = """
📱 <b>Информация о подписке</b>
📊 <b>Статус:</b> {status}
🎭 <b>Тип:</b> {type}
📅 <b>Действует до:</b> {end_date}
<b>Осталось дней:</b> {days_left}
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
🌍 <b>Серверы:</b> {countries_count} стран
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
💳 <b>Автоплатеж:</b> {autopay_status}
"""
TRIAL_AVAILABLE = """
🎁 <b>Тестовая подписка</b>
Вы можете получить бесплатную тестовую подписку:
<b>Период:</b> {days} дней
📈 <b>Трафик:</b> {traffic} ГБ
📱 <b>Устройства:</b> {devices} шт.
🌍 <b>Сервер:</b> {server_name}
Активировать тестовую подписку?
"""
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
CHANGE_DEVICES_TITLE = "📱 Изменение количества устройств"
CHANGE_DEVICES_INFO = """
📱 <b>Изменение количества устройств</b>
Текущий лимит: {current_devices} устройств
Выберите новое количество устройств:
💡 <b>Важно:</b>
При увеличении - доплата пропорционально оставшемуся времени
При уменьшении - возврат средств не производится
"""
CHANGE_DEVICES_CONFIRM = """
📱 <b>Подтверждение изменения</b>
Текущее количество: {current_devices} устройств
Новое количество: {new_devices} устройств
Действие: {action}
💰 {cost}
Подтвердить изменение?
"""
CHANGE_DEVICES_SUCCESS_INCREASE = """
Количество устройств увеличено!
📱 Было: {old_count} Стало: {new_count}
💰 Списано: {amount}
"""
CHANGE_DEVICES_SUCCESS_DECREASE = """
Количество устройств уменьшено!
📱 Было: {old_count} Стало: {new_count}
Возврат средств не производится
"""
DEVICES_NO_CHANGE = "ℹ️ Количество устройств не изменилось"
DEVICES_MINIMUM_LIMIT = "⚠️ Минимальное количество устройств: {limit}"
DEVICES_LIMIT_EXCEEDED = "⚠️ Превышен максимальный лимит устройств ({limit})"
DEVICES_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
BUY_SUBSCRIPTION_START = """
💎 <b>Настройка подписки</b>
Давайте настроим вашу подписку под ваши потребности.
Сначала выберите период подписки:
"""
SELECT_PERIOD = "Выберите период:"
SELECT_TRAFFIC = "Выберите пакет трафика:"
SELECT_COUNTRIES = "Выберите страны:"
SELECT_DEVICES = "Количество устройств:"
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
SUBSCRIPTION_SUMMARY = """
📋 <b>Итоговая конфигурация</b>
📅 <b>Период:</b> {period} дней
📈 <b>Трафик:</b> {traffic}
🌍 <b>Страны:</b> {countries}
📱 <b>Устройства:</b> {devices}
💰 <b>Итого к оплате:</b> {total_price}
Подтвердить покупку?
"""
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
<b>Пополните баланс на {amount} и попробуйте снова.</b>
"""
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
BALANCE_INFO = """
💰 <b>Баланс: {balance}</b>
Выберите действие:
"""
BALANCE_HISTORY = "📊 История операций"
BALANCE_TOP_UP = "💳 Пополнить"
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
TOP_UP_METHODS = """
💳 <b>Выберите способ оплаты</b>
Сумма: {amount}
"""
TOP_UP_STARS = "⭐ Telegram Stars"
TOP_UP_TRIBUTE = "💎 Банковская карта"
PROMOCODE_ENTER = "🎫 Введите промокод:"
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
PROMOCODE_INVALID = "❌ Неверный промокод"
PROMOCODE_EXPIRED = "❌ Промокод истек"
PROMOCODE_USED = "❌ Промокод уже использован"
REFERRAL_INFO = """
🤝 <b>Реферальная программа</b>
👥 <b>Приглашено:</b> {referrals_count} друзей
💰 <b>Заработано:</b> {earned_amount}
🔗 <b>Ваша реферальная ссылка:</b>
<code>{referral_link}</code>
🎫 <b>Ваш промокод:</b>
<code>{referral_code}</code>
💰 <b>Условия:</b>
За каждого друга: {registration_bonus}
Процент с пополнений: {commission_percent}%
"""
REFERRAL_INVITE_MESSAGE = """
🎯 <b>Приглашение в VPN сервис</b>
Привет! Приглашаю тебя в отличный VPN сервис!
🎁 По моей ссылке ты получишь бонус: {bonus}
🔗 Переходи: {link}
🎫 Или используй промокод: {code}
💪 Быстро, надежно, недорого!
"""
CREATE_INVITE = "📝 Создать приглашение"
TRIAL_ENDING_SOON = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через несколько часов.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {price}
Безлимитный трафик
Все серверы доступны
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
MAINTENANCE_MODE_ACTIVE = """
🔧 Технические работы!
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
Ориентировочное время завершения: неизвестно
🔄 Попробуйте позже
Приносим извинения за временные неудобства.
"""
MAINTENANCE_MODE_API_ERROR = """
🔧 Технические работы!
Сервис временно недоступен из-за проблем с подключением к серверам.
Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {last_check}
"""
SUBSCRIPTION_EXPIRING_PAID = """
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {end_date}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
SUBSCRIPTION_EXPIRED = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
AUTOPAY_SUCCESS = """
<b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
Новая дата окончания: {new_end_date}
"""
AUTOPAY_FAILED = """
<b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
💰 Ваш баланс: {balance}
💳 Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
SUPPORT_INFO = f"""
🛠 <b>Техническая поддержка</b>
По всем вопросам обращайтесь к нашей поддержке:
👤 {settings.SUPPORT_USERNAME}
Мы поможем с:
Настройкой подключения
Решением технических проблем
Вопросами по оплате
Другими вопросами
Время ответа: обычно в течение 1-2 часов
"""
CONTACT_SUPPORT = "💬 Написать в поддержку"
ADMIN_PANEL = """
<b>Административная панель</b>
Выберите раздел для управления:
"""
ADMIN_USERS = "👥 Пользователи"
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
ADMIN_PROMOCODES = "🎫 Промокоды"
ADMIN_MESSAGES = "📨 Рассылки"
ADMIN_MONITORING = "🔍 Мониторинг"
ADMIN_REFERRALS = "🤝 Рефералы"
ADMIN_RULES = "📋 Правила"
ADMIN_REMNAWAVE = "🖥️ Remnawave"
ADMIN_STATISTICS = "📊 Статистика"
ACCESS_DENIED = "❌ Доступ запрещен"
USER_NOT_FOUND = "❌ Пользователь не найден"
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
INVALID_AMOUNT = "❌ Неверная сумма"
OPERATION_CANCELLED = "❌ Операция отменена"
SUBSCRIPTION_EXPIRING = """
<b>Подписка истекает!</b>
Ваша подписка истекает через {days} дней.
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
"""
SUBSCRIPTION_EXPIRED = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
"""
AUTOPAY_SUCCESS = """
<b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
"""
SWITCH_TRAFFIC_TITLE = "🔄 Переключение лимита трафика"
SWITCH_TRAFFIC_INFO = """
🔄 <b>Переключение лимита трафика</b>
Текущий лимит: {current_traffic}
Выберите новый лимит трафика:
💡 <b>Важно:</b>
При увеличении - доплата за разницу пропорционально оставшемуся времени
При уменьшении - возврат средств не производится
Счетчик использованного трафика НЕ сбрасывается
"""
SWITCH_TRAFFIC_CONFIRM = """
🔄 <b>Подтверждение переключения трафика</b>
Текущий лимит: {current_traffic}
Новый лимит: {new_traffic}
Действие: {action}
💰 {cost}
Подтвердить переключение?
"""
SWITCH_TRAFFIC_SUCCESS_INCREASE = """
Лимит трафика увеличен!
📊 Было: {old_traffic} Стало: {new_traffic}
💰 Списано: {amount}
"""
SWITCH_TRAFFIC_SUCCESS_DECREASE = """
Лимит трафика уменьшен!
📊 Было: {old_traffic} Стало: {new_traffic}
Возврат средств не производится
"""
TRAFFIC_NO_CHANGE = "ℹ️ Лимит трафика не изменился"
TRAFFIC_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
AUTOPAY_FAILED = """
<b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
Недостаточно средств на балансе: {balance}
Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
class EnglishTexts(Texts):
def __init__(self):
super().__init__("en")
WELCOME = """
🎉 <b>Welcome to VPN Service!</b>
Our service provides fast and secure internet access without restrictions.
🔐 <b>Advantages:</b>
High connection speed
Servers in different countries
Reliable data protection
24/7 support
To get started, select interface language:
"""
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
BACK = "⬅️ Back"
CANCEL = "❌ Cancel"
CONFIRM = "✅ Confirm"
CONTINUE = "➡️ Continue"
YES = "✅ Yes"
NO = "❌ No"
MENU_BALANCE = "💰 Balance"
MENU_SUBSCRIPTION = "📱 Subscription"
MENU_TRIAL = "🎁 Trial subscription"
INSUFFICIENT_BALANCE = """❌ Insufficient balance. " \
Top up {amount} and try again."""
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
LANGUAGES = {
"ru": RussianTexts,
"en": EnglishTexts
}
def get_texts(language: str = "ru") -> Texts:
return LANGUAGES.get(language, RussianTexts)()
async def get_rules_from_db(language: str = "ru") -> str:
async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str:
try:
from app.database.database import get_db
from app.database.crud.rules import get_current_rules_content
async for db in get_db():
rules = await get_current_rules_content(db, language)
if rules:
_cached_rules[language] = rules
return rules
break
except Exception as e:
print(f"Ошибка получения правил из БД: {e}")
default_rules = _get_default_rules(language)
_cached_rules[language] = default_rules
return default_rules
def get_rules_sync(language: str = "ru") -> str:
except Exception as error: # pragma: no cover - defensive logging
_logger.warning("Failed to load rules from DB for %s: %s", language, error)
default = _get_default_rules(language)
_cached_rules[language] = default
return default
def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
default_key = "RULES_TEXT_DEFAULT"
locale = load_locale(language)
if default_key in locale:
return locale[default_key]
fallback = load_locale(DEFAULT_LANGUAGE)
return fallback.get(default_key, "")
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
if language in _cached_rules:
return _cached_rules[language]
try:
if language in _cached_rules:
return _cached_rules[language]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
rules = loop.run_until_complete(get_rules_from_db(language))
loop.close()
return rules
except Exception as e:
print(f"Ошибка получения правил: {e}")
return _get_default_rules(language)
loop = asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(get_rules(language))
async def refresh_rules_cache(language: str = "ru"):
try:
if language in _cached_rules:
del _cached_rules[language]
await get_rules_from_db(language)
print(f"✅ Кеш правил для языка {language} обновлен")
except Exception as e:
print(f"Ошибка обновления кеша правил: {e}")
loop.create_task(get_rules(language))
return _get_cached_rules_value(language)
def clear_rules_cache():
global _cached_rules
async def get_rules(language: str = DEFAULT_LANGUAGE) -> str:
if language in _cached_rules:
return _cached_rules[language]
return await get_rules_from_db(language)
async def refresh_rules_cache(language: str = DEFAULT_LANGUAGE) -> None:
if language in _cached_rules:
del _cached_rules[language]
await get_rules_from_db(language)
def clear_rules_cache() -> None:
_cached_rules.clear()
print("✅ Кеш правил очищен")
def reload_locales() -> None:
clear_locale_cache()
+23 -3
View File
@@ -8,6 +8,8 @@ from aiogram.enums import ChatMemberStatus
from app.config import settings
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.check_reg_process import is_registration_process
logger = logging.getLogger(__name__)
@@ -111,9 +113,27 @@ class ChannelCheckerMiddleware(BaseMiddleware):
@staticmethod
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
channel_sub_kb = get_channel_sub_keyboard(channel_link)
text = f"""🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!"""
user = None
if isinstance(event, (Message, CallbackQuery)):
user = getattr(event, "from_user", None)
elif isinstance(event, Update):
if event.message and event.message.from_user:
user = event.message.from_user
elif event.callback_query and event.callback_query.from_user:
user = event.callback_query.from_user
language = DEFAULT_LANGUAGE
if user and user.language_code:
language = user.language_code.split('-')[0]
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
text = texts.t(
"CHANNEL_REQUIRED_TEXT",
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
)
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)
+148 -64
View File
@@ -330,78 +330,25 @@ class BackupService:
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
restore_order = []
await self._restore_users_without_referrals(db, backup_data, models_by_table)
for model in self.backup_models_ordered:
table_name = model.__tablename__
if table_name in backup_data and backup_data[table_name]:
restore_order.append(table_name)
for table_name in restore_order:
records = backup_data[table_name]
if not records:
continue
model = models_by_table.get(table_name)
if not model:
logger.warning(f"⚠️ Модель для таблицы {table_name} не найдена, пропускаем")
if table_name == "users":
continue
records = backup_data.get(table_name, [])
if not records:
continue
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
for record_data in records:
try:
processed_data = {}
for key, value in record_data.items():
if value is None:
processed_data[key] = None
continue
column = getattr(model.__table__.columns, key, None)
if column is None:
logger.warning(f"Колонка {key} не найдена в модели {table_name}")
continue
column_type_str = str(column.type).upper()
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
try:
if 'T' in value:
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError) as e:
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
processed_data[key] = datetime.utcnow()
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
elif ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = int(value)
except ValueError:
processed_data[key] = 0
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = float(value)
except ValueError:
processed_data[key] = 0.0
elif 'JSON' in column_type_str:
if isinstance(value, str) and value.strip():
try:
processed_data[key] = json_lib.loads(value)
except (ValueError, TypeError):
processed_data[key] = value
elif isinstance(value, (list, dict)):
processed_data[key] = value
else:
processed_data[key] = None
else:
processed_data[key] = value
processed_data = self._process_record_data(record_data, model, table_name)
primary_key_col = None
for col in model.__table__.columns:
if col.primary_key:
primary_key_col = col.name
break
primary_key_col = self._get_primary_key_column(model)
if primary_key_col and primary_key_col in processed_data:
existing_record = await db.execute(
@@ -413,7 +360,7 @@ class BackupService:
if existing and not clear_existing:
for key, value in processed_data.items():
if key != primary_key_col:
if key != primary_key_col:
setattr(existing, key, value)
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
else:
@@ -428,11 +375,14 @@ class BackupService:
except Exception as e:
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
logger.error(f"Проблемные данные: {record_data}")
continue
await db.rollback()
raise e
restored_tables += 1
logger.info(f"✅ Таблица {table_name} восстановлена")
await self._update_user_referrals(db, backup_data)
await db.commit()
break
@@ -465,6 +415,140 @@ class BackupService:
return False, error_msg
async def _restore_users_without_referrals(self, db: AsyncSession, backup_data: dict, models_by_table: dict):
users_data = backup_data.get("users", [])
if not users_data:
return
logger.info(f"👥 Восстанавливаем {len(users_data)} пользователей без реферальных связей")
User = models_by_table["users"]
for user_data in users_data:
try:
processed_data = self._process_record_data(user_data, User, "users")
processed_data['referred_by_id'] = None
if 'id' in processed_data:
existing_user = await db.execute(
select(User).where(User.id == processed_data['id'])
)
existing = existing_user.scalar_one_or_none()
if existing:
for key, value in processed_data.items():
if key != 'id':
setattr(existing, key, value)
else:
instance = User(**processed_data)
db.add(instance)
else:
instance = User(**processed_data)
db.add(instance)
except Exception as e:
logger.error(f"Ошибка при восстановлении пользователя: {e}")
await db.rollback()
raise e
await db.commit()
logger.info("✅ Пользователи без реферальных связей восстановлены")
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
users_data = backup_data.get("users", [])
if not users_data:
return
logger.info("🔗 Обновляем реферальные связи пользователей")
for user_data in users_data:
try:
referred_by_id = user_data.get('referred_by_id')
user_id = user_data.get('id')
if referred_by_id and user_id:
referrer_result = await db.execute(
select(User).where(User.id == referred_by_id)
)
referrer = referrer_result.scalar_one_or_none()
if referrer:
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if user:
user.referred_by_id = referred_by_id
else:
logger.warning(f"Пользователь {user_id} не найден для обновления реферальной связи")
else:
logger.warning(f"Реферер {referred_by_id} не найден для пользователя {user_id}")
except Exception as e:
logger.error(f"Ошибка при обновлении реферальной связи: {e}")
continue
await db.commit()
logger.info("✅ Реферальные связи обновлены")
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
processed_data = {}
for key, value in record_data.items():
if value is None:
processed_data[key] = None
continue
column = getattr(model.__table__.columns, key, None)
if column is None:
logger.warning(f"Колонка {key} не найдена в модели {table_name}")
continue
column_type_str = str(column.type).upper()
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
try:
if 'T' in value:
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError) as e:
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
processed_data[key] = datetime.utcnow()
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
elif ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = int(value)
except ValueError:
processed_data[key] = 0
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = float(value)
except ValueError:
processed_data[key] = 0.0
elif 'JSON' in column_type_str:
if isinstance(value, str) and value.strip():
try:
processed_data[key] = json_lib.loads(value)
except (ValueError, TypeError):
processed_data[key] = value
elif isinstance(value, (list, dict)):
processed_data[key] = value
else:
processed_data[key] = None
else:
processed_data[key] = value
return processed_data
def _get_primary_key_column(self, model) -> Optional[str]:
for col in model.__table__.columns:
if col.primary_key:
return col.name
return None
async def _clear_database_tables(self, db: AsyncSession):
tables_order = [
"subscription_servers", "sent_notifications",
+171
View File
@@ -0,0 +1,171 @@
import logging
from dataclasses import dataclass
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import record_campaign_registration
from app.database.crud.subscription import (
create_paid_subscription,
get_subscription_by_user_id,
)
from app.database.crud.user import add_user_balance
from app.database.models import AdvertisingCampaign, User
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
@dataclass
class CampaignBonusResult:
success: bool
bonus_type: Optional[str] = None
balance_kopeks: int = 0
subscription_days: Optional[int] = None
subscription_traffic_gb: Optional[int] = None
subscription_device_limit: Optional[int] = None
subscription_squads: Optional[List[str]] = None
class AdvertisingCampaignService:
def __init__(self) -> None:
self.subscription_service = SubscriptionService()
async def apply_campaign_bonus(
self,
db: AsyncSession,
user: User,
campaign: AdvertisingCampaign,
) -> CampaignBonusResult:
if not campaign.is_active:
logger.warning(
"⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id
)
return CampaignBonusResult(success=False)
if campaign.is_balance_bonus:
return await self._apply_balance_bonus(db, user, campaign)
if campaign.is_subscription_bonus:
return await self._apply_subscription_bonus(db, user, campaign)
logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type)
return CampaignBonusResult(success=False)
async def _apply_balance_bonus(
self,
db: AsyncSession,
user: User,
campaign: AdvertisingCampaign,
) -> CampaignBonusResult:
amount = campaign.balance_bonus_kopeks or 0
if amount <= 0:
logger.info("️ Кампания %s не имеет бонуса на баланс", campaign.id)
return CampaignBonusResult(success=False)
description = f"Бонус за регистрацию по кампании '{campaign.name}'"
success = await add_user_balance(
db,
user,
amount,
description=description,
)
if not success:
return CampaignBonusResult(success=False)
await record_campaign_registration(
db,
campaign_id=campaign.id,
user_id=user.id,
bonus_type="balance",
balance_bonus_kopeks=amount,
)
logger.info(
"💰 Пользователю %s начислен бонус %s₽ по кампании %s",
user.telegram_id,
amount / 100,
campaign.id,
)
return CampaignBonusResult(
success=True,
bonus_type="balance",
balance_kopeks=amount,
)
async def _apply_subscription_bonus(
self,
db: AsyncSession,
user: User,
campaign: AdvertisingCampaign,
) -> CampaignBonusResult:
existing_subscription = await get_subscription_by_user_id(db, user.id)
if existing_subscription:
logger.warning(
"⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен",
user.telegram_id,
campaign.id,
)
return CampaignBonusResult(success=False)
duration_days = campaign.subscription_duration_days or 0
if duration_days <= 0:
logger.info(
"️ Кампания %s не содержит корректной длительности подписки",
campaign.id,
)
return CampaignBonusResult(success=False)
traffic_limit = campaign.subscription_traffic_gb
device_limit = (
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
)
squads = list(campaign.subscription_squads or [])
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
)
try:
await self.subscription_service.create_remnawave_user(db, new_subscription)
except Exception as error:
logger.error(
"❌ Ошибка синхронизации RemnaWave для кампании %s: %s",
campaign.id,
error,
)
await record_campaign_registration(
db,
campaign_id=campaign.id,
user_id=user.id,
bonus_type="subscription",
subscription_duration_days=duration_days,
)
logger.info(
"🎁 Пользователю %s выдана подписка по кампании %s на %s дней",
user.telegram_id,
campaign.id,
duration_days,
)
return CampaignBonusResult(
success=True,
bonus_type="subscription",
subscription_days=duration_days,
subscription_traffic_gb=traffic_limit or 0,
subscription_device_limit=device_limit,
subscription_squads=squads,
)
+139 -112
View File
@@ -22,6 +22,10 @@ from app.external.cryptobot import CryptoBotService
from app.utils.currency_converter import currency_converter
from app.database.database import get_db
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
logger = logging.getLogger(__name__)
@@ -33,7 +37,49 @@ class PaymentService:
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
self.stars_service = TelegramStarsService(bot) if bot else None
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
texts = get_texts(user.language if user else "ru")
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
if user:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
keyboard_rows.append([
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
])
keyboard_rows.append([
InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")
])
keyboard_rows.append([
InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
async def create_stars_invoice(
self,
amount_kopeks: int,
@@ -124,33 +170,7 @@ class PaymentService:
if self.bot:
try:
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
]
)
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
@@ -261,6 +281,94 @@ class PaymentService:
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa: {e}")
return None
async def create_yookassa_sbp_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
if not self.yookassa_service:
logger.error("YooKassa сервис не инициализирован")
return None
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata or {}
payment_metadata.update({
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup_sbp"
})
yookassa_response = await self.yookassa_service.create_sbp_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(f"Ошибка создания платежа YooKassa СБП: {yookassa_response}")
return None
yookassa_created_at = None
if yookassa_response.get("created_at"):
try:
dt_with_tz = datetime.fromisoformat(
yookassa_response["created_at"].replace('Z', '+00:00')
)
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
except Exception as e:
logger.warning(f"Не удалось парсить created_at: {e}")
yookassa_created_at = None
confirmation_token = None
if yookassa_response.get("confirmation"):
confirmation_token = yookassa_response["confirmation"].get("confirmation_token")
if confirmation_token:
payment_metadata["confirmation_token"] = confirmation_token
local_payment = await create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type="bank_card",
yookassa_created_at=yookassa_created_at,
test_mode=yookassa_response.get("test_mode", False)
)
logger.info(f"Создан платеж YooKassa СБП {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"confirmation_token": confirmation_token,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}")
return None
async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
try:
@@ -344,33 +452,7 @@ class PaymentService:
if self.bot:
try:
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
]
)
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
@@ -457,33 +539,7 @@ class PaymentService:
user = await get_user_by_telegram_id(db, telegram_id)
break
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
]
)
keyboard = await self.build_topup_success_keyboard(user)
message = (
f"✅ <b>Платеж успешно завершен!</b>\n\n"
@@ -679,10 +735,8 @@ class PaymentService:
)
if not updated_payment.transaction_id:
# Получаем сумму в USD из платежа
amount_usd = updated_payment.amount_float
# Конвертируем в рубли по текущему курсу с улучшенной обработкой ошибок
try:
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
amount_kopeks = int(amount_rubles * 100)
@@ -694,7 +748,6 @@ class PaymentService:
amount_kopeks = int(amount_usd * 100)
conversion_rate = 1.0
# Проверяем корректность конвертированной суммы
if amount_kopeks <= 0:
logger.error(f"Некорректная сумма после конвертации: {amount_kopeks} копеек для платежа {invoice_id}")
return False
@@ -742,33 +795,7 @@ class PaymentService:
if self.bot:
try:
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
]
)
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
@@ -0,0 +1,52 @@
from typing import Optional
from app.database.models import User
from app.utils.cache import UserCache
_CHECKOUT_SESSION_KEY = "subscription_checkout"
_CHECKOUT_TTL_SECONDS = 3600
async def save_subscription_checkout_draft(
user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS
) -> bool:
"""Persist subscription checkout draft data in cache."""
return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl)
async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]:
"""Retrieve subscription checkout draft from cache."""
return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY)
async def clear_subscription_checkout_draft(user_id: int) -> bool:
"""Remove stored subscription checkout draft for the user."""
return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY)
async def has_subscription_checkout_draft(user_id: int) -> bool:
draft = await get_subscription_checkout_draft(user_id)
return draft is not None
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
"""
Determine whether checkout resume button should be available for the user.
Only users without an active paid subscription or users currently on trial
are eligible to continue assembling the subscription from the saved draft.
"""
if not has_draft:
return False
subscription = getattr(user, "subscription", None)
if subscription is None:
return True
return bool(getattr(subscription, "is_trial", False))
+393 -76
View File
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User, SubscriptionStatus
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, UserStatus,
TrafficLimitStrategy, RemnaWaveAPIError
@@ -20,6 +20,40 @@ from app.utils.pricing_utils import (
logger = logging.getLogger(__name__)
def _resolve_discount_percent(
user: Optional[User],
promo_group: Optional[PromoGroup],
category: str,
*,
period_days: Optional[int] = None,
) -> int:
if user is not None:
try:
return user.get_promo_discount(category, period_days)
except AttributeError:
pass
if promo_group is not None:
return promo_group.get_discount_percent(category, period_days)
return 0
def get_traffic_reset_strategy():
from app.config import settings
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
strategy_mapping = {
'NO_RESET': 'NO_RESET',
'DAY': 'DAY',
'WEEK': 'WEEK',
'MONTH': 'MONTH'
}
mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET')
logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}")
return getattr(TrafficLimitStrategy, mapped_strategy)
class SubscriptionService:
def __init__(self):
@@ -66,7 +100,7 @@ class SubscriptionService:
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
@@ -84,7 +118,7 @@ class SubscriptionService:
expire_at=subscription.end_date,
status=UserStatus.ACTIVE,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
@@ -103,7 +137,8 @@ class SubscriptionService:
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
@@ -144,7 +179,7 @@ class SubscriptionService:
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
@@ -159,7 +194,8 @@ class SubscriptionService:
status_text = "активным" if is_actually_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
@@ -249,9 +285,12 @@ class SubscriptionService:
self,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
server_squad_ids: List[int],
devices: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES
@@ -262,62 +301,167 @@ class SubscriptionService:
base_price = PERIOD_PRICES.get(period_days, 0)
promo_group = promo_group or (user.promo_group if user else None)
traffic_price = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
server_prices = []
total_servers_price = 0
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_prices.append(server.price_kopeks)
total_servers_price += server.price_kopeks
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}")
server_price = server.price_kopeks
server_discount = server_price * servers_discount_percent // 100
discounted_server_price = server_price - server_discount
server_prices.append(discounted_server_price)
total_servers_price += discounted_server_price
log_message = f"Сервер {server.display_name}: {server_price/100}"
if server_discount > 0:
log_message += (
f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)"
)
logger.debug(log_message)
else:
server_prices.append(0)
logger.warning(f"Сервер ID {server_id} недоступен")
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_price = base_price + traffic_price + total_servers_price + devices_price
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount = devices_price * devices_discount_percent // 100
discounted_devices_price = devices_price - devices_discount
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
logger.info(f"Расчет стоимости новой подписки:")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({devices}): {devices_price/100}")
if discounted_traffic_price > 0:
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
if discounted_devices_price > 0:
message = f" Устройства ({devices}): {devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
async def calculate_renewal_price(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
try:
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
servers_price, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
)
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount = servers_price * servers_discount_percent // 100
discounted_servers_price = servers_price - servers_discount
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount = devices_price * devices_discount_percent // 100
discounted_devices_price = devices_price - devices_discount
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
total_price = base_price + servers_price + devices_price + traffic_price
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
total_price = (
base_price
+ discounted_servers_price
+ discounted_devices_price
+ discounted_traffic_price
)
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}")
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}")
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}")
if servers_price > 0:
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}"
if servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
)
logger.info(message)
if devices_price > 0:
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
)
logger.info(message)
if traffic_price > 0:
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
except Exception as e:
@@ -417,9 +561,12 @@ class SubscriptionService:
self,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
server_squad_ids: List[int],
devices: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES
@@ -432,73 +579,183 @@ class SubscriptionService:
base_price = PERIOD_PRICES.get(period_days, 0)
promo_group = promo_group or (user.promo_group if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
server_prices = []
total_servers_price = 0
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_price_per_month = server.price_kopeks
server_price_total = server_price_per_month * months_in_period
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_price_total = discounted_server_per_month * months_in_period
server_prices.append(server_price_total)
total_servers_price += server_price_total
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}")
log_message = (
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}"
)
if server_discount_per_month > 0:
log_message += (
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)"
)
logger.debug(log_message)
else:
server_prices.append(0)
logger.warning(f"Сервер ID {server_id} недоступен")
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
if total_traffic_price > 0:
message = (
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
if total_devices_price > 0:
message = (
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
async def calculate_renewal_price_with_months(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
)
total_servers_price = servers_price_per_month * months_in_period
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_price = discounted_servers_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_price = traffic_price_per_month * months_in_period
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
if total_servers_price > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}"
)
if servers_discount_per_month > 0:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
if total_devices_price > 0:
message = (
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
if total_traffic_price > 0:
message = (
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
except Exception as e:
@@ -517,32 +774,92 @@ class SubscriptionService:
if additional_server_ids is None:
additional_server_ids = []
current_time = datetime.utcnow()
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
user = getattr(subscription, "user", None)
promo_group = user.promo_group if user else None
total_price = 0
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
total_price += traffic_price_per_month * months_to_pay
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}")
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_hint_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
traffic_total_price = discounted_traffic_per_month * months_to_pay
total_price += traffic_total_price
message = (
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay}"
f" = {traffic_total_price/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%:"
f" -{traffic_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_price += devices_price_per_month * months_to_pay
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}")
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_hint_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
devices_total_price = discounted_devices_per_month * months_to_pay
total_price += devices_total_price
message = (
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay}"
f" = {devices_total_price/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%:"
f" -{devices_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_server_ids and db:
for server_id in additional_server_ids:
from app.database.crud.server_squad import get_server_squad_by_id
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available:
server_price_per_month = server.price_kopeks
server_total_price = server_price_per_month * months_to_pay
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_hint_days,
)
server_discount_per_month = (
server_price_per_month * servers_discount_percent // 100
)
discounted_server_per_month = (
server_price_per_month - server_discount_per_month
)
server_total_price = discounted_server_per_month * months_to_pay
total_price += server_total_price
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}")
message = (
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay}"
f" = {server_total_price/100}"
)
if server_discount_per_month > 0:
message += (
f" (скидка {servers_discount_percent}%:"
f" -{server_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}")
return total_price
+7 -34
View File
@@ -5,8 +5,6 @@ from datetime import datetime
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import get_db
from app.database.models import Transaction, TransactionType, PaymentMethod
@@ -15,7 +13,7 @@ from app.database.crud.transaction import (
)
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
from app.external.tribute import TributeService as TributeAPI
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
@@ -216,7 +214,7 @@ class TributeService:
logger.error(f"Ошибка обработки возврата Tribute: {e}")
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
try:
amount_rubles = amount_kopeks / 100
@@ -224,34 +222,8 @@ class TributeService:
user = await get_user_by_telegram_id(session, user_id)
break
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
)
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
]
)
payment_service = PaymentService(self.bot)
keyboard = await payment_service.build_topup_success_keyboard(user)
text = (
f"✅ **Платеж успешно получен!**\n\n"
@@ -267,10 +239,11 @@ class TributeService:
reply_markup=keyboard,
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
try:
+155 -18
View File
@@ -1,20 +1,23 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
get_users_count, get_users_statistics, get_inactive_users,
add_user_balance, subtract_user_balance, update_user, delete_user
)
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup
)
from app.config import settings
@@ -23,6 +26,59 @@ logger = logging.getLogger(__name__)
class UserService:
async def _send_balance_notification(
self,
bot: Bot,
user: User,
amount_kopeks: int,
admin_name: str
) -> bool:
"""Отправляет уведомление пользователю о пополнении/списании баланса"""
try:
if amount_kopeks > 0:
# Пополнение
emoji = "💰"
action = "пополнен"
amount_text = f"+{settings.format_price(amount_kopeks)}"
message = (
f"{emoji} <b>Баланс пополнен!</b>\n\n"
f"💵 <b>Сумма:</b> {amount_text}\n"
f"👤 <b>Администратор:</b> {admin_name}\n"
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
f"Спасибо за использование нашего сервиса! 🎉"
)
else:
# Списание
emoji = "💸"
action = "списан"
amount_text = f"-{settings.format_price(abs(amount_kopeks))}"
message = (
f"{emoji} <b>Средства списаны с баланса</b>\n\n"
f"💵 <b>Сумма:</b> {amount_text}\n"
f"👤 <b>Администратор:</b> {admin_name}\n"
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
f"Если у вас есть вопросы, обратитесь в поддержку."
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML"
)
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
return True
except TelegramForbiddenError:
logger.warning(f"⚠️ Пользователь {user.telegram_id} заблокировал бота")
return False
except TelegramBadRequest as e:
logger.error(f"❌ Ошибка Telegram API при отправке уведомления пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при отправке уведомления пользователю {user.telegram_id}: {e}")
return False
async def get_user_profile(
self,
db: AsyncSession,
@@ -90,13 +146,14 @@ class UserService:
db: AsyncSession,
page: int = 1,
limit: int = 20,
status: Optional[UserStatus] = None
status: Optional[UserStatus] = None,
order_by_balance: bool = False
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
users = await get_users_list(
db, offset=offset, limit=limit, status=status
db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
)
total_count = await get_users_count(db, status=status)
@@ -128,27 +185,81 @@ class UserService:
user_id: int,
amount_kopeks: int,
description: str,
admin_id: int
admin_id: int,
bot: Optional[Bot] = None,
admin_name: Optional[str] = None
) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False
# Сохраняем старый баланс для уведомления
old_balance = user.balance_kopeks
if amount_kopeks > 0:
await add_user_balance(db, user, amount_kopeks, description=description)
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
return True
success = True
else:
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
if success:
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}")
return success
# Отправляем уведомление пользователю, если операция прошла успешно
if success and bot:
# Обновляем пользователя для получения нового баланса
await db.refresh(user)
# Получаем имя администратора
if not admin_name:
admin_user = await get_user_by_id(db, admin_id)
admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}"
# Отправляем уведомление (не блокируем операцию если не удалось отправить)
await self._send_balance_notification(bot, user, amount_kopeks, admin_name)
return success
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя: {e}")
return False
async def update_user_promo_group(
self,
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> Tuple[bool, Optional[User], Optional[PromoGroup]]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False, None, None
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
return False, None, None
user.promo_group_id = promo_group.id
user.promo_group = promo_group
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(
"👥 Промогруппа пользователя %s обновлена на '%s'",
user.telegram_id,
promo_group.name,
)
return True, user, promo_group
except Exception as e:
await db.rollback()
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
return False, None, None
async def block_user(
self,
db: AsyncSession,
@@ -239,13 +350,39 @@ class UserService:
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
if user.remnawave_uuid:
from app.config import settings
delete_mode = settings.get_remnawave_user_delete_mode()
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
if delete_mode == "delete":
# Удаляем пользователя из панели Remnawave
async with remnawave_service.api as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели")
else:
logger.warning(f"⚠️ Не удалось удалить пользователя {user.remnawave_uuid} из панели Remnawave")
else:
# Деактивируем пользователя в панели Remnawave
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован (режим: {delete_mode})")
except Exception as e:
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
logger.warning(f"⚠️ Ошибка обработки пользователя в Remnawave (режим: {delete_mode}): {e}")
# Если основное действие не удалось, попытаемся хотя бы деактивировать
if delete_mode == "delete":
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован как fallback")
except Exception as fallback_e:
logger.error(f"❌ Ошибка деактивации RemnaWave как fallback: {fallback_e}")
try:
sent_notifications_result = await db.execute(
+104 -1
View File
@@ -11,7 +11,6 @@ from app.config import settings
logger = logging.getLogger(__name__)
class YooKassaService:
def __init__(self,
@@ -149,6 +148,110 @@ class YooKassaService:
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
return None
async def create_sbp_payment(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж через СБП.")
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
return {
"error": True,
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({
"type": "sbp"
})
receipt_items_list: List[Dict[str, Any]] = [{
"description": description[:128],
"quantity": "1.00",
"amount": {
"value": str(round(amount, 2)),
"currency": currency.upper()
},
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
}]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
f"Создание платежа YooKassa СБП (Idempotence-Key: {idempotence_key}). "
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
logger.info(
f"Ответ YooKassa Payment.create (СБП): ID={response.id}, Status={response.status}, Paid={response.paid}")
return {
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, 'test') else None
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True)
return None
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
+32 -1
View File
@@ -16,6 +16,7 @@ class SubscriptionStates(StatesGroup):
adding_devices = State()
extending_subscription = State()
confirming_traffic_reset = State()
cart_saved_for_topup = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
@@ -41,10 +42,37 @@ class AdminStates(StatesGroup):
setting_promocode_value = State()
setting_promocode_uses = State()
setting_promocode_expiry = State()
creating_campaign_name = State()
creating_campaign_start = State()
creating_campaign_bonus = State()
creating_campaign_balance = State()
creating_campaign_subscription_days = State()
creating_campaign_subscription_traffic = State()
creating_campaign_subscription_devices = State()
creating_campaign_subscription_servers = State()
editing_campaign_name = State()
editing_campaign_start = State()
editing_campaign_balance = State()
editing_campaign_subscription_days = State()
editing_campaign_subscription_traffic = State()
editing_campaign_subscription_devices = State()
editing_campaign_subscription_servers = State()
waiting_for_broadcast_message = State()
waiting_for_broadcast_media = State()
confirming_broadcast = State()
creating_promo_group_name = State()
creating_promo_group_traffic_discount = State()
creating_promo_group_server_discount = State()
creating_promo_group_device_discount = State()
editing_promo_group_name = State()
editing_promo_group_traffic_discount = State()
editing_promo_group_server_discount = State()
editing_promo_group_device_discount = State()
editing_squad_price = State()
editing_traffic_price = State()
@@ -69,6 +97,9 @@ class AdminStates(StatesGroup):
editing_welcome_text = State()
waiting_for_message_buttons = "waiting_for_message_buttons"
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
class SupportStates(StatesGroup):
waiting_for_message = State()
+8 -3
View File
@@ -203,14 +203,19 @@ class UserCache:
@staticmethod
async def set_user_session(
user_id: int,
session_key: str,
data: Any,
user_id: int,
session_key: str,
data: Any,
expire: int = 1800
) -> bool:
key = cache_key("session", user_id, session_key)
return await cache.set(key, data, expire)
@staticmethod
async def delete_user_session(user_id: int, session_key: str) -> bool:
key = cache_key("session", user_id, session_key)
return await cache.delete(key)
class SystemCache:
+36 -12
View File
@@ -1,5 +1,7 @@
from typing import List, Dict, Tuple
from app.config import settings
from app.localization.texts import get_texts
def get_available_payment_methods() -> List[Dict[str, str]]:
"""
@@ -54,28 +56,50 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
return methods
def get_payment_methods_text() -> str:
def get_payment_methods_text(language: str) -> str:
"""
Генерирует текст с описанием доступных способов оплаты
"""
texts = get_texts(language)
methods = get_available_payment_methods()
if len(methods) <= 1: # Только поддержка
return """💳 <b>Способы пополнения баланса</b>
return texts.t(
"PAYMENT_METHODS_ONLY_SUPPORT",
"""💳 <b>Способы пополнения баланса</b>
В данный момент автоматические способы оплаты временно недоступны.
Обратитесь в техподдержку для пополнения баланса.
Выберите способ пополнения:"""
text = "💳 <b>Способы пополнения баланса</b>\n\n"
text += "Выберите удобный для вас способ оплаты:\n\n"
Выберите способ пополнения:""",
)
text = texts.t(
"PAYMENT_METHODS_TITLE",
"💳 <b>Способы пополнения баланса</b>",
) + "\n\n"
text += texts.t(
"PAYMENT_METHODS_PROMPT",
"Выберите удобный для вас способ оплаты:",
) + "\n\n"
for method in methods:
text += f"{method['icon']} <b>{method['name']}</b> - {method['description']}\n"
text += "\nВыберите способ пополнения:"
method_id = method['id'].upper()
name = texts.t(
f"PAYMENT_METHOD_{method_id}_NAME",
f"{method['icon']} <b>{method['name']}</b>",
)
description = texts.t(
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
method['description'],
)
text += f"{name} - {description}\n"
text += "\n" + texts.t(
"PAYMENT_METHODS_FOOTER",
"Выберите способ пополнения:",
)
return text
def is_payment_method_available(method_id: str) -> bool:
+22 -2
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from datetime import datetime
from typing import Tuple
import logging
@@ -28,8 +29,8 @@ def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
def calculate_prorated_price(
monthly_price: int,
end_date: datetime,
monthly_price: int,
end_date: datetime,
min_charge_months: int = 1
) -> Tuple[int, int]:
months_remaining = get_remaining_months(end_date)
@@ -42,6 +43,25 @@ def calculate_prorated_price(
return total_price, months_to_charge
def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
if amount <= 0 or percent <= 0:
return amount, 0
clamped_percent = max(0, min(100, percent))
discount_value = amount * clamped_percent // 100
discounted_amount = amount - discount_value
logger.debug(
"Применена скидка %s%%: %s%s (скидка %s)",
clamped_percent,
amount,
discounted_amount,
discount_value,
)
return discounted_amount, discount_value
def format_period_description(days: int, language: str = "ru") -> str:
months = calculate_months_from_days(days)
+20 -14
View File
@@ -3,7 +3,7 @@ import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -28,25 +28,31 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s
return f"ref{timestamp}"
async def mark_user_as_had_paid_subscription(db: AsyncSession, user_id: int) -> bool:
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
try:
from app.database.crud.user import get_user_by_id
if user.has_had_paid_subscription:
logger.debug(f"Пользователь {user.id} уже отмечен как имевший платную подписку")
return True
user = await get_user_by_id(db, user_id)
if not user:
logger.error(f"Пользователь {user_id} не найден")
return False
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
await db.execute(
update(User)
.where(User.id == user.id)
.values(
has_had_paid_subscription=True,
updated_at=datetime.utcnow()
)
)
await db.commit()
logger.info(f"✅ Пользователь {user.id} отмечен как имевший платную подписку")
return True
except Exception as e:
logger.error(f"Ошибка отметки пользователя {user_id} как имевшего платную подписку: {e}")
logger.error(f"Ошибка отметки пользователя {user.id} как имевшего платную подписку: {e}")
try:
await db.rollback()
except Exception as rollback_error:
logger.error(f"Ошибка отката транзакции: {rollback_error}")
return False
+2
View File
@@ -57,11 +57,13 @@ services:
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
volumes:
# Логи
- ./logs:/app/logs:rw
# Данные приложения (для SQLite в случае переключения)
- ./data:/app/data:rw
- ./locales:/app/locales:rw
# Конфигурация приложения
# - ./app-config.json:/app/app-config.json:ro
# Timezone
+6
View File
@@ -18,6 +18,7 @@ from app.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.localization.loader import ensure_locale_templates
class GracefulExit:
@@ -43,6 +44,11 @@ async def main():
logger = logging.getLogger(__name__)
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
try:
ensure_locale_templates()
except Exception as error:
logger.warning("Failed to prepare locale templates: %s", error)
killer = GracefulExit()
signal.signal(signal.SIGINT, killer.exit_gracefully)
signal.signal(signal.SIGTERM, killer.exit_gracefully)
@@ -0,0 +1,224 @@
"""add promo groups table and link users"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
PROMO_GROUPS_TABLE = "promo_groups"
USERS_TABLE = "users"
PROMO_GROUP_COLUMN = "promo_group_id"
PROMO_GROUP_INDEX = "ix_users_promo_group_id"
PROMO_GROUP_FK = "fk_users_promo_group_id_promo_groups"
DEFAULT_PROMO_GROUP_NAME = "Базовый юзер"
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool:
return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name))
revision: str = "1f5f3a3f5a4d"
down_revision: Union[str, None] = "cbd1be472f3d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, PROMO_GROUPS_TABLE):
op.create_table(
PROMO_GROUPS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column(
"server_discount_percent",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"traffic_discount_percent",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"device_discount_percent",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"is_default",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("name", name="uq_promo_groups_name"),
)
inspector = sa.inspect(bind)
if not _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
op.add_column(
USERS_TABLE,
sa.Column(PROMO_GROUP_COLUMN, sa.Integer(), nullable=True),
)
inspector = sa.inspect(bind)
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
if not _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
op.create_index(PROMO_GROUP_INDEX, USERS_TABLE, [PROMO_GROUP_COLUMN])
inspector = sa.inspect(bind)
if not _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
op.create_foreign_key(
PROMO_GROUP_FK,
USERS_TABLE,
PROMO_GROUPS_TABLE,
[PROMO_GROUP_COLUMN],
["id"],
ondelete="RESTRICT",
)
inspector = sa.inspect(bind)
if not _table_exists(inspector, PROMO_GROUPS_TABLE) or not _column_exists(
inspector, USERS_TABLE, PROMO_GROUP_COLUMN
):
return
promo_groups_table = sa.table(
PROMO_GROUPS_TABLE,
sa.column("id", sa.Integer()),
sa.column("name", sa.String()),
sa.column("server_discount_percent", sa.Integer()),
sa.column("traffic_discount_percent", sa.Integer()),
sa.column("device_discount_percent", sa.Integer()),
sa.column("is_default", sa.Boolean()),
)
connection = bind
existing_named_group = (
connection.execute(
sa.select(
promo_groups_table.c.id,
promo_groups_table.c.is_default,
)
.where(promo_groups_table.c.name == DEFAULT_PROMO_GROUP_NAME)
.limit(1)
)
.mappings()
.first()
)
if existing_named_group:
default_group_id = existing_named_group["id"]
if not existing_named_group["is_default"]:
connection.execute(
sa.update(promo_groups_table)
.where(promo_groups_table.c.id == default_group_id)
.values(is_default=True)
)
else:
default_group_id = connection.execute(
sa.select(promo_groups_table.c.id)
.where(promo_groups_table.c.is_default.is_(True))
.limit(1)
).scalar_one_or_none()
if default_group_id is None:
default_group_id = connection.execute(
sa.insert(promo_groups_table)
.values(
name=DEFAULT_PROMO_GROUP_NAME,
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
)
.returning(promo_groups_table.c.id)
).scalar_one()
users_table = sa.table(
USERS_TABLE,
sa.column("promo_group_id", sa.Integer()),
)
connection.execute(
sa.update(users_table)
.where(users_table.c.promo_group_id.is_(None))
.values(promo_group_id=default_group_id)
)
inspector = sa.inspect(bind)
column_info = next(
(col for col in inspector.get_columns(USERS_TABLE) if col["name"] == PROMO_GROUP_COLUMN),
None,
)
if column_info and column_info.get("nullable", True):
op.alter_column(
USERS_TABLE,
PROMO_GROUP_COLUMN,
existing_type=sa.Integer(),
nullable=False,
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
column_info = next(
(
col
for col in inspector.get_columns(USERS_TABLE)
if col["name"] == PROMO_GROUP_COLUMN
),
None,
)
if column_info and not column_info.get("nullable", False):
op.alter_column(
USERS_TABLE,
PROMO_GROUP_COLUMN,
existing_type=sa.Integer(),
nullable=True,
)
inspector = sa.inspect(bind)
if _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
op.drop_constraint(PROMO_GROUP_FK, USERS_TABLE, type_="foreignkey")
inspector = sa.inspect(bind)
if _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
op.drop_index(PROMO_GROUP_INDEX, table_name=USERS_TABLE)
op.drop_column(USERS_TABLE, PROMO_GROUP_COLUMN)
inspector = sa.inspect(bind)
if _table_exists(inspector, PROMO_GROUPS_TABLE):
op.drop_table(PROMO_GROUPS_TABLE)
@@ -1,29 +0,0 @@
"""add_paid_price_to_subscription
Revision ID: 3d9b35c6bd8f
Revises:
Create Date: 2025-08-23 08:17:00.563340
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d9b35c6bd8f'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('subscriptions', 'paid_price_kopeks')
# ### end Alembic commands ###
@@ -0,0 +1,146 @@
"""add advertising campaigns tables"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
CAMPAIGNS_TABLE = "advertising_campaigns"
CAMPAIGNS_START_INDEX = "ix_advertising_campaigns_start_parameter"
CAMPAIGNS_ID_INDEX = "ix_advertising_campaigns_id"
REGISTRATIONS_TABLE = "advertising_campaign_registrations"
REGISTRATIONS_ID_INDEX = "ix_advertising_campaign_registrations_id"
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
revision: str = "5d1f1f8b2e9a"
down_revision: Union[str, None] = "cbd1be472f3d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, CAMPAIGNS_TABLE):
op.create_table(
CAMPAIGNS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("start_parameter", sa.String(length=64), nullable=False),
sa.Column("bonus_type", sa.String(length=20), nullable=False),
sa.Column(
"balance_bonus_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
sa.Column("subscription_squads", sa.JSON(), nullable=True),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
)
inspector = sa.inspect(bind)
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
op.create_index(
CAMPAIGNS_START_INDEX,
CAMPAIGNS_TABLE,
["start_parameter"],
unique=True,
)
inspector = sa.inspect(bind)
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
op.create_index(CAMPAIGNS_ID_INDEX, CAMPAIGNS_TABLE, ["id"])
inspector = sa.inspect(bind)
if not _table_exists(inspector, REGISTRATIONS_TABLE):
op.create_table(
REGISTRATIONS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("campaign_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("bonus_type", sa.String(length=20), nullable=False),
sa.Column(
"balance_bonus_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["campaign_id"],
[f"{CAMPAIGNS_TABLE}.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
)
inspector = sa.inspect(bind)
if not _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
op.create_index(
REGISTRATIONS_ID_INDEX,
REGISTRATIONS_TABLE,
["id"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
op.drop_index(REGISTRATIONS_ID_INDEX, table_name=REGISTRATIONS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, REGISTRATIONS_TABLE):
op.drop_table(REGISTRATIONS_TABLE)
inspector = sa.inspect(bind)
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
op.drop_index(CAMPAIGNS_ID_INDEX, table_name=CAMPAIGNS_TABLE)
inspector = sa.inspect(bind)
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
op.drop_index(CAMPAIGNS_START_INDEX, table_name=CAMPAIGNS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, CAMPAIGNS_TABLE):
op.drop_table(CAMPAIGNS_TABLE)
@@ -1,8 +1,11 @@
"""add sent notifications table"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = '8fd1e338eb45'
down_revision: Union[str, None] = '3d9b35c6bd8f'
@@ -10,18 +13,46 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = 'sent_notifications'
UNIQUE_CONSTRAINT_NAME = 'uq_sent_notifications'
UNIQUE_CONSTRAINT_COLUMNS = ['user_id', 'subscription_id', 'notification_type', 'days_before']
def _table_exists(inspector: Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def _unique_constraint_exists(inspector: Inspector) -> bool:
existing_constraints = {
constraint['name'] for constraint in inspector.get_unique_constraints(TABLE_NAME)
}
return UNIQUE_CONSTRAINT_NAME in existing_constraints
def upgrade() -> None:
op.create_table(
'sent_notifications',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
sa.Column('notification_type', sa.String(length=50), nullable=False),
sa.Column('days_before', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'),
)
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
op.create_table(
TABLE_NAME,
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
sa.Column('notification_type', sa.String(length=50), nullable=False),
sa.Column('days_before', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.UniqueConstraint(*UNIQUE_CONSTRAINT_COLUMNS, name=UNIQUE_CONSTRAINT_NAME),
)
elif not _unique_constraint_exists(inspector):
op.create_unique_constraint(
UNIQUE_CONSTRAINT_NAME, TABLE_NAME, UNIQUE_CONSTRAINT_COLUMNS
)
def downgrade() -> None:
op.drop_table('sent_notifications')
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector):
op.drop_table(TABLE_NAME)
@@ -1,24 +0,0 @@
"""add cascade delete to sent notifications"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'cbd1be472f3d'
down_revision: Union[str, None] = '8fd1e338eb45'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey')
op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey')
op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE')
def downgrade() -> None:
op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey')
op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey')
op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id'])
op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])
+13 -12
View File
@@ -1,28 +1,29 @@
# Основные зависимости
aiogram==3.7.0
aiohttp==3.9.1
asyncpg==0.29.0
SQLAlchemy==2.0.25
alembic==1.13.1
aiosqlite==0.19.0
aiogram==3.22.0
aiohttp==3.12.15
asyncpg==0.30.0
SQLAlchemy==2.0.43
alembic==1.16.5
aiosqlite==0.21.0
# Дополнительные зависимости
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
pydantic==2.11.9
pydantic-settings==2.10.1
python-dotenv==1.1.1
redis==5.0.1
PyYAML==6.0.2
# YooKassa SDK
yookassa==3.0.0
yookassa==3.7.0
# Логирование и мониторинг
structlog==23.2.0
# Планировщик задач для техработ
APScheduler==3.10.4
APScheduler==3.11.0
# Утилиты
python-dateutil==2.8.2
python-dateutil==2.9.0.post0
pytz==2023.4
cryptography>=41.0.0
qrcode[pil]==7.4.2