Compare commits

...
143 Commits
Author SHA1 Message Date
3252a8andGitHub 5892a72575 Merge pull request #7 from 3252a8/dev
Backups, docs demo, and Mini App improvements
2026-05-28 22:57:40 +03:00
3252a8 d299a3c524 feature: Add local subscription notification worker 2026-05-28 22:48:48 +03:00
3252a8 1c1ef06e29 fix: Fix trial settings persistence 2026-05-28 22:48:48 +03:00
3252a8 bea62d75a9 fix: persist account language changes 2026-05-28 22:07:43 +03:00
3252a8 e998b9ddd1 chore: compact traffic cards 2026-05-28 21:52:38 +03:00
3252a8 2f192447ce fix: hide premium traffic when regular limit is depleted 2026-05-28 21:00:06 +03:00
3252a8 e89ed870dc fix: compact mobile subscription status card 2026-05-28 19:44:50 +03:00
3252a8 6a722bfa17 refactor: share email code confirmation screen 2026-05-28 17:11:15 +03:00
3252a8 a96aaa763f feat: use admin identity in demo auth flow 2026-05-28 17:02:22 +03:00
3252a8 bbe05d7f57 feat: support telegram auth in demo flow 2026-05-28 16:49:23 +03:00
3252a8 da28b69461 feat: add demo auth flow 2026-05-28 16:40:16 +03:00
3252a8 cd469ae2bb feat: add demo device top-up flow 2026-05-28 15:54:35 +03:00
3252a8 9af49453f8 fix: refresh devices after billing updates 2026-05-28 15:54:30 +03:00
3252a8 e543665704 fix: add favicon to docs demo shell 2026-05-28 15:32:45 +03:00
3252a8 39c0696e53 fix: show activation dialog for forced actions 2026-05-28 15:29:58 +03:00
3252a8 c6cc4ab963 fix: use standard purchase copy in demo 2026-05-28 15:25:18 +03:00
3252a8 4dfe71f392 fix: align demo state flows 2026-05-28 15:21:55 +03:00
3252a8 cf05c6580d fix: keep demo mock switch on app shell 2026-05-28 15:05:48 +03:00
3252a8 d544ebd879 feat: add fullscreen docs demo routes 2026-05-28 14:59:54 +03:00
3252a8 7e0e8ff319 feat: enrich docs demo mock data 2026-05-28 14:59:43 +03:00
3252a8 613a9860a0 refactor: support prefixed webapp routes 2026-05-28 14:59:34 +03:00
3252a8 92f0277dad docs: refresh docs demo data 2026-05-28 13:10:07 +03:00
3252a8 749693078b fix: use app favicon fallback in admin appearance 2026-05-28 13:10:01 +03:00
3252a8 b566a34725 fix: mark depleted traffic on webapp home 2026-05-28 13:09:55 +03:00
3252a8 3ab6c18a76 fix: preserve account language on telegram auth 2026-05-28 11:31:45 +03:00
3252a8 52458eb223 fix: align tariff action in status card 2026-05-28 11:31:41 +03:00
3252a8 790946d89e fix: label docs demo admin version 2026-05-28 10:09:26 +03:00
3252a8 30c5d9ae11 fix: refine home tariff actions 2026-05-28 10:06:10 +03:00
3252a8 12e60629fe fix: preserve admin view on language change 2026-05-28 09:54:27 +03:00
3252a8 6c0d2932c3 docs: make demo bar collapsible on mobile 2026-05-28 09:12:58 +03:00
3252a8 ce19c7e2d1 docs: add mobile docs navigation menu 2026-05-28 08:21:58 +03:00
3252a8 93c9dde572 chore: harden docs demo config fetch 2026-05-28 08:21:54 +03:00
3252a8 d46324cb6a docs: fix mobile homepage navigation 2026-05-28 08:07:09 +03:00
3252a8 f153ca5bf4 fix: complete english localization refresh 2026-05-28 00:55:22 +03:00
3252a8 62e950f5c4 docs: refine static demo experience 2026-05-28 00:55:11 +03:00
3252a8 2b8bcd10b8 docs: make demo open fullscreen 2026-05-28 00:19:22 +03:00
3252a8 4c2ee19957 docs: add interactive demo page 2026-05-27 23:56:52 +03:00
3252a8 8217d13cd6 feat: add docs demo runtime build 2026-05-27 23:56:48 +03:00
3252a8 a2a887b898 fix: recognize backups admin route 2026-05-27 23:56:41 +03:00
3252a8 1620de9a01 refactor: isolate mock runtime from production webapp 2026-05-27 23:56:37 +03:00
3252a8 b6c6887842 chore: improve backup admin controls 2026-05-27 23:22:20 +03:00
3252a8 ef4b493e65 chore: change backup archive name style 2026-05-27 23:01:42 +03:00
3252a8 50dd1951c4 fix: prevent support message row stretching 2026-05-27 22:37:50 +03:00
3252a8 b3894e53e4 fix: tighten support chat message spacing 2026-05-27 22:21:44 +03:00
3252a8 ded044b4c0 fix: make support replies finish promptly 2026-05-27 22:21:24 +03:00
3252a8 bce78c4f28 chore: backup warning details 2026-05-27 19:08:49 +03:00
3252a8 75586d8883 fix: harden hwid provider payment edge cases 2026-05-27 18:31:14 +03:00
3252a8 f2fc335221 fix: anchor hwid pricing to paid period 2026-05-27 18:13:27 +03:00
3252a8 1c9e55d797 fix: wire subscription service for yookassa hwid payments 2026-05-27 18:13:22 +03:00
3252a8 25056602d8 fix: serialize webapp datetime payloads 2026-05-27 14:55:05 +03:00
3252a8 bd2e67059f chore: optimize docker layer caching 2026-05-27 14:44:58 +03:00
3252a8 fe34edfe73 chore: verify formatting checks 2026-05-27 14:29:45 +03:00
3252a8 4706be53ab feat: manual backup button 2026-05-27 14:25:37 +03:00
3252a8 4bd547f06a chore: tune backup function and related docs 2026-05-27 14:11:22 +03:00
3252a8 0250264fa0 fix: serialize HWID top-up validity dates 2026-05-27 13:59:03 +03:00
3252a8 3aede8fe95 feat: add backups feature 2026-05-27 13:53:30 +03:00
3252a8 e90988ea5c feat: persist support ticket drafts 2026-05-27 08:10:08 +03:00
3252a8 69d3400310 fix: stack admin badges on mobile 2026-05-27 07:55:05 +03:00
3252a8 6a44da1f31 fix: refresh trial settings in admin tariffs 2026-05-27 07:52:20 +03:00
3252a8 77c8785ec0 chore: set default webapp title to minishop 2026-05-27 07:22:34 +03:00
3252a8 f2c0a9f6d5 fix: preserve home logo scale after admin navigation 2026-05-27 07:13:53 +03:00
3252a8 f77a6ea46d feat: add default webapp brand assets 2026-05-27 07:07:31 +03:00
3252a8 a92ad32b23 docs: update docs 2026-05-27 00:11:24 +03:00
3252a8 a6af8f8415 docs: tune docs visual 2026-05-26 23:55:07 +03:00
3252a8andGitHub da9db7b7f8 Merge pull request #5 from 3252a8/dev
Update and structurize docs
2026-05-26 23:42:59 +03:00
3252a8 11048a6ed8 docs: refactor docs structure 2026-05-26 23:31:04 +03:00
3252a8 c3381bdd31 chore: add image publish and mirror sync scripts 2026-05-26 22:30:10 +03:00
3252a8 53f1cec401 docs: point repository links to GitHub 2026-05-26 22:21:00 +03:00
3252a8 bf46a15446 docs: update GitLab repository links 2026-05-26 21:57:49 +03:00
3252a8 0df52d0235 docs: refactor docs structure 2026-05-26 21:45:44 +03:00
3252a8 0c167c8f09 docs: refactor docs structure 2026-05-26 17:26:46 +03:00
3252a8 804ccdabec docs: use nova starlight theme 2026-05-26 16:18:15 +03:00
3252a8 5833b18052 docs: docs-site initial 2026-05-26 16:04:04 +03:00
3252a8 f30e729f1f Merge branch 'dev' into 'main'
Install guides, payment fixes, and runtime translations

See merge request 3252a8/remnawave-minshop!1
2026-05-26 10:39:29 +00:00
3252a8 1e63431ae3 chore: merge main into dev 2026-05-26 12:51:00 +03:00
3252a8 072e7273c4 docs: standardize MIT license file 2026-05-26 12:48:58 +03:00
3252a8 df079138ee docs: standardize MIT license file 2026-05-26 12:44:27 +03:00
3252a8 87f32114d7 docs: update license file attribution 2026-05-26 12:29:58 +03:00
3252a8 b330e604f6 chore: use Docker Hub images in compose files 2026-05-26 11:15:10 +03:00
3252a8 827d69231c feat: add Docker Hub image script 2026-05-26 08:58:09 +03:00
3252a8 0623850c3f fix: open Telegram Stars invoices inside Mini App 2026-05-25 22:13:11 +03:00
3252a8 4c77d129e7 fix: update support page content without page refresh 2026-05-25 22:03:30 +03:00
3252a8 c68cb97964 fix: bind HWID top-ups to subscription periods 2026-05-25 19:33:50 +03:00
3252a8 59fa301344 feat: add runtime locale overrides 2026-05-25 17:47:45 +03:00
3252a8 23784d00fe chore: update custom themes styles 2026-05-25 13:52:25 +03:00
3252a8 17cff74b7c chore: tune sub activate modal view 2026-05-25 13:11:48 +03:00
3252a8 2a3a3c21ba chore: adjust webapp payment layout 2026-05-25 12:42:17 +03:00
3252a8 ba7811621e fix: preserve premium topup squad access 2026-05-25 12:18:58 +03:00
3252a8 340afe1b80 fix: stop premium squad sync churn 2026-05-25 11:54:03 +03:00
3252a8 11b7823188 fix: expire Wata payment links sooner 2026-05-25 11:34:10 +03:00
3252a8 de1763cfe1 feat: enable payment provider for admin only 2026-05-25 11:19:54 +03:00
3252a8 d30b069876 fix: reuse pending Wata payment links on retry 2026-05-25 10:49:57 +03:00
3252a8 0d68da9624 fix: handle Wata payment links 2026-05-25 09:17:39 +03:00
3252a8 4d857b386b fix: remove inline startup panel sync 2026-05-25 00:40:24 +03:00
3252a8 be7a3bc153 fix: queue bot panel sync requests 2026-05-25 00:36:15 +03:00
3252a8 5c71fc0de2 fix: hide links from payment success message 2026-05-25 00:23:48 +03:00
3252a8 82cc33587c fix: stop panel description churn 2026-05-25 00:06:29 +03:00
3252a8 121f3c6ddf feat: add compact panel sync update diagnostics 2026-05-24 23:44:29 +03:00
3252a8 3410eeaec1 fix(admin): delete Remnawave user with bot account 2026-05-24 23:35:19 +03:00
3252a8 85ecd644b8 fix: refresh webapp profile after activation 2026-05-24 23:25:21 +03:00
3252a8 7cffb4667f fix: expose iOS home screen icons for web app 2026-05-24 23:13:15 +03:00
3252a8 0449ded505 feat: render webapp preview metadata 2026-05-24 22:54:01 +03:00
3252a8 e15cbffdb1 feat: expose webapp title in admin settings 2026-05-24 22:53:50 +03:00
3252a8 9674c674a2 feat: resume activation handoff after payments 2026-05-24 22:45:54 +03:00
3252a8 bafc5f4709 feat: show linked emails in telegram logs 2026-05-24 22:32:35 +03:00
3252a8 a60de46173 feat: guide users after subscription activation 2026-05-24 22:24:02 +03:00
3252a8 1165dc3fa6 chore: verify formatting checks 2026-05-24 22:04:12 +03:00
3252a8 f5ab18a67b feat: redesign webapp trial offer 2026-05-24 22:02:22 +03:00
3252a8 65db4aaff6 fix: clean legacy emails from panel descriptions 2026-05-24 21:45:38 +03:00
3252a8 d9a23235e5 fix: stop syncing email in panel descriptions 2026-05-24 21:36:00 +03:00
3252a8 00b54e1f15 feat: improve admin trial settings controls 2026-05-24 19:19:39 +03:00
3252a8 3c4ff66150 feat: streamline admin tariff settings 2026-05-24 19:10:36 +03:00
3252a8 2b3078fcce chore: fix formatting checks 2026-05-24 18:51:37 +03:00
3252a8 6f4074e8d6 feat: surface trial activation in mini app 2026-05-24 18:48:01 +03:00
3252a8 235ee25d9f fix: refresh YooKassa webapp payments 2026-05-24 18:47:37 +03:00
3252a8 d1b9990bac feat: manage trial settings on tariffs page 2026-05-24 18:46:23 +03:00
3252a8 60d1ba4efc fix: handle Wata prepayment webhooks 2026-05-24 14:04:21 +03:00
3252a8 29b6e6eb24 fix: keep payment user cards in payments 2026-05-24 09:32:47 +03:00
3252a8 aa65972483 feat: add admin payment detail view 2026-05-24 09:30:58 +03:00
3252a8 ad275a7c83 feat: polish admin settings layout 2026-05-24 09:11:43 +03:00
3252a8 02b57ead00 feat: separate legacy tariff settings 2026-05-24 09:11:34 +03:00
3252a8 7f484fa7b3 feat: warn about legacy tariff settings 2026-05-24 00:05:16 +03:00
3252a8 3ea906a74d feat: group Platega admin settings 2026-05-24 00:01:58 +03:00
3252a8 939c40ccbf feat: collapse admin settings by default 2026-05-24 00:01:18 +03:00
3252a8 f0eb291f56 feat: tune visual of install instructions page 2026-05-23 23:55:06 +03:00
3252a8 46391b10e2 feat: tune deeplink fallback page 2026-05-23 23:26:10 +03:00
3252a8 5918a6cc71 fix: open install guide deeplinks via external app gateway 2026-05-23 23:07:42 +03:00
3252a8 0eeabc7b3a fix: process yookassa hwid device topups 2026-05-23 22:28:05 +03:00
3252a8 31eb5c06ad fix: repair linked panel email during sync 2026-05-23 22:27:51 +03:00
3252a8 60d8c297f9 docs: update deploy examples 2026-05-23 22:01:40 +03:00
3252a8 dab70d5a97 docs: update install guides documentation 2026-05-23 16:22:57 +03:00
3252a8 520a6289a8 Merge branch 'feature/install-page' into dev 2026-05-23 16:16:46 +03:00
3252a8 7adca53116 feat: enable bot install guides by default 2026-05-23 16:05:41 +03:00
3252a8 fbc3e6c084 chore: fix frontend formatting checks 2026-05-23 16:01:07 +03:00
3252a8 19ba5c8f11 feat: support install guide app deeplinks 2026-05-23 09:52:26 +03:00
3252a8 ce0d4dccf7 feat: open bot install guides in mini app 2026-05-23 09:48:30 +03:00
3252a8 7c874cd4aa feat: support install guides in custom themes 2026-05-22 23:39:17 +03:00
3252a8 17224b4f74 feat: harden public install guide loading 2026-05-22 23:29:58 +03:00
3252a8 40414264be feat: polish install guide loading state 2026-05-22 23:24:59 +03:00
3252a8 d9a4a007e2 feat: add install guide share tokens and animations 2026-05-22 23:20:25 +03:00
3252a8 d3925cad22 Merge branch 'dev' into feature/install-page
# Conflicts:
#	backend/bot/app/web/admin_api_impl/settings.py
#	backend/bot/app/web/webapp/cache_helpers.py
#	frontend/src/admin/sections/SettingsSection.svelte
#	tests/test_admin_settings_manifest_i18n.py
2026-05-22 22:57:24 +03:00
3252a8 443e2e62db fix: auto-merge duplicate panel identities 2026-05-22 22:39:45 +03:00
3252a8 9e61a3d8a8 feat: install instruction inside web app 2026-05-22 13:58:34 +03:00
306 changed files with 184706 additions and 2453 deletions
+33
View File
@@ -13,9 +13,14 @@ scratch/
*.local.*
node_modules/
frontend/node_modules/
docs-site/node_modules/
docs-site/.astro/
docs-site/dist/
docs-site/src/content/docs/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
# CI
@@ -30,10 +35,38 @@ deploy/compose/*.yml
tmp/
# WebApp build artifacts (regenerated inside Docker)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
bot/app/web/templates/subscription_webapp.*.css
bot/app/web/templates/subscription_webapp.min.*.js.br
bot/app/web/templates/subscription_webapp.min.*.js.gz
bot/app/web/templates/subscription_webapp.*.css.br
bot/app/web/templates/subscription_webapp.*.css.gz
bot/app/web/templates/subscription_webapp_admin.css
bot/app/web/templates/subscription_webapp_admin.js
bot/app/web/templates/subscription_webapp_admin.min.*.js
bot/app/web/templates/subscription_webapp_admin.*.css
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.*.css
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp.*.css.br
backend/bot/app/web/templates/subscription_webapp.*.css.gz
backend/bot/app/web/templates/subscription_webapp_admin.css
backend/bot/app/web/templates/subscription_webapp_admin.js
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
backend/bot/app/web/templates/subscription_webapp_admin.*.css
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
# Byte-compiled / optimized / DLL files
**/__pycache__/
+17
View File
@@ -12,6 +12,12 @@ scratch/
node_modules/
.git/
# Documentation site build artifacts
docs-site/.astro/
docs-site/dist/
docs-site/public/demo/runtime/
docs-site/src/content/docs/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
@@ -29,6 +35,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
bot/app/web/templates/subscription_webapp_docs_demo.css
bot/app/web/templates/subscription_webapp_docs_demo.js
bot/app/web/templates/subscription_webapp_docs_demo.*.css
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
@@ -45,6 +56,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
tmp
.claude
@@ -63,3 +79,4 @@ locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
+18 -4
View File
@@ -1,7 +1,21 @@
Copyright 2025 machka-pasla
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+41 -18
View File
@@ -4,7 +4,7 @@
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
## Возможности
@@ -14,6 +14,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
@@ -25,20 +26,25 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью.
## Документация
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
## Совместимость
@@ -86,14 +92,15 @@ docker compose logs -f backend worker frontend
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- остальные настройки удобнее задать в Web App админке.
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку и уведомления через UI. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji
touch data/locales-overrides.json
chown -R 10001:10001 data
chmod -R u+rwX data
```
@@ -107,18 +114,34 @@ docker compose up -d --build
# Логи приложения
docker compose logs -f backend worker frontend
# Запуск с Caddy
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
# Рекомендуемый продакшен-вариант с Caddy
cd deploy/examples/caddy # или nginx, newt, no-proxy
cp .env.example .env
nano .env
docker compose up -d
# Запуск из готового образа
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
# Запуск из готового образа с конкретным тегом
IMAGE_TAG=3.1.0 docker compose up -d
```
GHCR image names for releases:
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
Имена образов для релизов:
- `ghcr.io/3252a8/remnawave-minishop-backend`
- `ghcr.io/3252a8/remnawave-minishop-worker`
- `ghcr.io/3252a8/remnawave-minishop-frontend`
- `docker.io/3252a8/remnawave-minishop-backend`
- `docker.io/3252a8/remnawave-minishop-worker`
- `docker.io/3252a8/remnawave-minishop-frontend`
Сборка и публикация сразу в GHCR и Docker Hub:
```bash
docker login ghcr.io
docker login docker.io
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
```
## Поддержать проект
+1 -1
View File
@@ -30,7 +30,7 @@ def build_core_services(
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
email_auth_service = EmailAuthService(settings)
email_auth_service = EmailAuthService(settings, i18n)
notification_service = NotificationService(
bot,
settings,
+4
View File
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
_runtime as _runtime,
ads as _ads,
auth as _auth,
backups as _backups,
broadcast as _broadcast,
common as _common,
logs as _logs,
@@ -19,6 +20,7 @@ from bot.app.web.admin_api_impl import (
sync as _sync,
tariffs as _tariffs,
themes as _themes,
translations as _translations,
users as _users,
)
@@ -35,9 +37,11 @@ _MODULES = (
_broadcast,
_sync,
_ads,
_backups,
_settings,
_tariffs,
_themes,
_translations,
_panel,
_routes,
)
@@ -41,6 +41,7 @@ from config.tariffs_config import TariffsConfig
from db.dal import (
ad_dal,
app_settings_dal,
locale_overrides_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
@@ -0,0 +1,182 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
import secrets
import subprocess
from bot.infra.redis import redis_lock
from bot.services.backup_restore_service import (
BACKUP_UPLOAD_MAX_BYTES,
BackupArchiveError,
BackupArchiveInfo,
BackupRestoreError,
BackupRestoreService,
)
from bot.services.backup_worker import BackupWorker
def _backup_archive_payload(archive) -> Dict[str, Any]:
return archive.to_payload()
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
settings: Settings = request.app["settings"]
service = BackupRestoreService(settings)
backup_dir = service.backup_dir()
temp_path: Optional[Path] = None
reader = await request.multipart()
try:
async for part in reader:
if part.name != "file":
continue
original_filename = part.filename or "backup.zip"
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
size = 0
with temp_path.open("wb") as handle:
while True:
chunk = await part.read_chunk(size=1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > BACKUP_UPLOAD_MAX_BYTES:
raise BackupArchiveError("Backup archive is too large")
handle.write(chunk)
if size <= 0:
raise BackupArchiveError("Uploaded archive is empty")
archive = service.import_uploaded_archive(temp_path, original_filename)
temp_path = None
return archive
finally:
if temp_path is not None and temp_path.exists():
try:
temp_path.unlink()
except OSError:
logger.warning("Failed to remove temporary backup upload %s", temp_path)
raise BackupArchiveError("file field is required")
async def admin_backups_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
try:
service = BackupRestoreService(settings)
archives = service.list_archives()
except OSError as exc:
logger.exception("Failed to list backup archives")
return _error(500, "backup_list_failed", str(exc))
return _ok(
{
"backup_dir": str(service.backup_dir()),
"archives": [_backup_archive_payload(archive) for archive in archives],
}
)
async def admin_backups_upload_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
if not content_type.startswith("multipart/form-data"):
return _error(400, "invalid_backup_archive", "multipart file upload is required")
try:
archive = await _read_uploaded_backup_file(request)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded backup archive")
return _error(500, "backup_upload_failed", str(exc))
return _ok({"archive": _backup_archive_payload(archive)})
async def admin_backups_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
bot = request.app["bot"]
session_factory = request.app.get("async_session_factory")
worker = BackupWorker(settings, bot, session_factory=session_factory)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_create_busy", "Backup or restore is already running")
await worker.refresh_settings()
result = await worker.create_and_send_backup(backup_type="manual")
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
except Exception as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
return _ok(
{
"result": result.to_payload(),
"archive": _backup_archive_payload(archive),
}
)
async def admin_backups_restore_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
archive_name = str(payload.get("archive_name") or "").strip()
restore_database = bool(payload.get("restore_database"))
restore_compose = bool(payload.get("restore_compose"))
confirm = bool(payload.get("confirm"))
if not confirm:
return _error(400, "restore_confirmation_required")
service = BackupRestoreService(settings)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_restore_busy", "Backup or restore is already running")
result = await service.restore_archive(
archive_name,
restore_database=restore_database,
restore_compose=restore_compose,
)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except BackupRestoreError as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
if result.database_restored:
try:
from db import database_setup
if database_setup.async_engine is not None:
await database_setup.async_engine.dispose()
except Exception:
logger.exception("Failed to dispose DB engine after backup restore")
return _ok({"result": result.to_payload()})
@@ -234,6 +234,7 @@ def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
"user_id": int(entry.user_id) if entry.user_id else None,
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"email": getattr(getattr(entry, "author_user", None), "email", None),
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
@@ -32,6 +32,35 @@ async def admin_payments_list_route(request: web.Request) -> web.Response:
)
async def admin_payment_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
try:
payment_id = int(request.match_info["payment_id"])
except (TypeError, ValueError):
return _error(400, "invalid_payment", "Invalid payment id")
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment:
return _error(404, "not_found", "Payment not found")
payload = _serialize_payment(payment)
payload.update(
{
"yookassa_payment_id": payment.yookassa_payment_id,
"idempotence_key": payment.idempotence_key,
"promo_code": (
payment.promo_code_used.code if payment.promo_code_used is not None else None
),
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
}
)
return _ok({"payment": payload})
async def admin_payments_export_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
@@ -36,6 +36,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
router.add_get("/api/admin/payments", admin_payments_list_route)
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
router.add_get("/api/admin/promos", admin_promos_list_route)
@@ -65,6 +66,8 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/settings", admin_settings_get_route)
router.add_patch("/api/admin/settings", admin_settings_patch_route)
router.add_get("/api/admin/translations", admin_translations_get_route)
router.add_patch("/api/admin/translations", admin_translations_patch_route)
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
@@ -72,4 +75,8 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_put("/api/admin/themes", admin_themes_save_route)
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
router.add_get("/api/admin/backups", admin_backups_list_route)
router.add_post("/api/admin/backups/create", admin_backups_create_route)
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+28 -28
View File
@@ -1,5 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_admin_config_json,
)
async def admin_settings_get_route(request: web.Request) -> web.Response:
@@ -27,12 +33,25 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
override = overrides_by_key.get(key)
value = current_value(settings, key)
is_secret = bool(field.get("secret"))
overridden = bool(override)
source = None
read_error = None
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
try:
value, source = subscription_guides_admin_config_json(settings)
overridden = source == "admin_json"
except SubscriptionGuidesConfigError as exc:
read_error = str(exc)
response_field = {
**field,
"value": "" if is_secret else value,
"overridden": bool(override),
"overridden": overridden,
"updated_at": override.get("updated_at") if override else None,
}
if source:
response_field["source"] = source
if read_error:
response_field["read_error"] = read_error
if is_secret:
response_field["has_value"] = bool(value)
webhook_path = str(response_field.get("webhook_path") or "").strip()
@@ -60,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
if (
"SUBSCRIPTION_PAGE_CONFIG_JSON" in updates
and not str(updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or "").strip()
):
updates = dict(updates)
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
result = await update_overrides(
settings,
@@ -74,32 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
status=400,
)
# Bust the public webapp settings cache so users see new values immediately.
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
try:
from bot.app.web.webapp.cache_helpers import invalidate_all_webapp_user_caches
await invalidate_all_webapp_user_caches(settings, include_devices=True)
except Exception:
logger.exception("Failed to invalidate WebApp user payload caches after settings update")
if (
"WEBAPP_LOGO_URL" in updates
or "WEBAPP_LOGO_URL" in deletes
or "WEBAPP_LOGO_USE_EMOJI" in updates
or "WEBAPP_LOGO_USE_EMOJI" in deletes
or "WEBAPP_FAVICON_URL" in updates
or "WEBAPP_FAVICON_URL" in deletes
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
or "WEBAPP_LOGO_FAVICON_URL" in updates
or "WEBAPP_LOGO_FAVICON_URL" in deletes
):
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
@@ -55,9 +56,6 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
+6 -15
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
import asyncio
import hashlib
@@ -65,11 +66,9 @@ def _bump_theme_asset_versions(
previous_theme = previous_by_key.get(key)
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
current_version = int(theme.get("assets_version") or 1)
theme_changed = (
previous_theme is None
or _theme_payload_for_version_compare(theme)
!= _theme_payload_for_version_compare(previous_theme)
)
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
theme
) != _theme_payload_for_version_compare(previous_theme)
if theme_changed or (default_changed and key == config.default_theme):
theme["assets_version"] = max(previous_version + 1, current_version, 1)
elif previous_version > current_version:
@@ -213,12 +212,7 @@ async def _persist_appearance_upload(
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
request.app["webapp_logo_cache"] = None
prune_unused_appearance_assets(settings)
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
return True
@@ -484,10 +478,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok(
{
@@ -0,0 +1,146 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
from bot.services.locale_override_service import (
LOCALE_OVERRIDES_PATH,
audience_for_locale_key,
group_id_for_locale_key,
locale_group_catalog,
load_locale_overrides,
update_locale_overrides,
)
def _locale_languages(
i18n: JsonI18n,
overrides: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
base_languages = set((i18n.base_locales_data or {}).keys())
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
override_languages.update((i18n.locale_overrides or {}).keys())
return locale_language_options(
base_languages | override_languages,
base_languages=base_languages,
)
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
result: Dict[Tuple[str, str], Dict] = {}
for entry in overrides:
lang = str(entry.get("lang") or "")
raw_key = str(entry.get("key") or "")
key = resolve_locale_key(raw_key)
if lang and key:
if raw_key != key and (lang, key) in result:
continue
result[(lang, key)] = entry
return result
def _admin_translations_payload(
i18n: JsonI18n,
overrides: List[Dict[str, Any]],
) -> Dict[str, Any]:
base_data = i18n.base_locales_data or i18n.locales_data or {}
effective_data = i18n.locales_data or {}
override_meta = _locale_override_meta_map(overrides)
language_items = _locale_languages(i18n, overrides)
languages = [item["code"] for item in language_items]
all_keys = sorted(
{key for messages in base_data.values() for key in messages.keys()}
| {key for _, key in override_meta.keys()}
)
groups_by_id = {
group["id"]: {
**group,
"items": [],
}
for group in locale_group_catalog()
}
for key in all_keys:
values: Dict[str, Dict[str, Any]] = {}
for lang in languages:
meta = override_meta.get((lang, key))
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
values[lang] = {
"base": base_data.get(lang, {}).get(key, ""),
"fallback": fallback_base,
"effective": effective_data.get(lang, {}).get(key, ""),
"override": meta.get("value") if meta else "",
"overridden": bool(meta),
"updated_at": meta.get("updated_at") if meta else None,
"updated_by": meta.get("updated_by") if meta else None,
}
group_id = group_id_for_locale_key(key)
groups_by_id.setdefault(
group_id,
{"id": group_id, "title": group_id, "description": "", "items": []},
)
groups_by_id[group_id]["items"].append(
{
"key": key,
"audience": audience_for_locale_key(key),
"values": values,
}
)
groups = [group for group in groups_by_id.values() if group["items"]]
return {
"languages": language_items,
"groups": groups,
"path": str(LOCALE_OVERRIDES_PATH),
"override_count": len(overrides),
}
async def admin_translations_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
await load_locale_overrides(i18n, async_session_factory)
async with async_session_factory() as session:
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
return _ok(_admin_translations_payload(i18n, overrides))
async def admin_translations_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = await _read_json(request)
updates = payload.get("updates") or {}
deletes = payload.get("deletes") or []
if not isinstance(updates, dict):
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
result = await update_locale_overrides(
i18n,
async_session_factory,
updates=updates,
deletes=deletes,
actor_id=actor_id,
)
if not result.get("ok"):
return web.json_response(
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
status=400,
)
return _ok(
{
"applied": result.get("applied", 0),
"reverted": result.get("reverted", 0),
"file_written": result.get("file_written", False),
}
)
+58 -3
View File
@@ -1,5 +1,16 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .auth import _require_admin_user_id
from .common import (
_build_admin_webapp_referral_link,
_error,
_ok,
_premium_traffic_list_payload,
_read_json,
_serialize_payment,
_serialize_subscription,
_serialize_user,
)
import hashlib
from html import escape as html_escape
@@ -861,18 +872,62 @@ async def admin_user_delete_route(request: web.Request) -> web.Response:
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
panel_service = request.app.get("panel_service")
if panel_service is None:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
session,
target_id,
user=user,
)
if panel_user_uuids and panel_service is None:
await session.rollback()
return _error(503, "panel_service_unavailable")
for panel_uuid in panel_user_uuids:
try:
panel_deleted = await panel_service.delete_user_from_panel(
panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Admin webapp failed to delete panel user %s for user %s: %s",
panel_uuid,
target_id,
exc,
)
await session.rollback()
return _error(502, "panel_delete_failed", str(exc))
if not panel_deleted:
await session.rollback()
return _error(
502,
"panel_delete_failed",
f"Failed to delete panel user {panel_uuid}",
)
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id,
"user_id": actor_id if actor_id != target_id else None,
"event_type": "admin_delete_user_webapp",
"content": f"Deleted user_id={target_id}",
"content": (
f"Deleted user_id={target_id}; "
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
),
"is_admin_event": True,
},
)
@@ -0,0 +1,67 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from bot.app.web.webapp.cache_helpers import (
invalidate_all_webapp_user_payloads,
reset_subscription_guides_cache,
reset_webapp_settings_cache,
)
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
{
"WEBAPP_TITLE",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
}
)
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
{
"MY_DEVICES_SECTION_ENABLED",
"USER_HWID_DEVICE_LIMIT",
"USER_TRAFFIC_LIMIT_GB",
"USER_TRAFFIC_STRATEGY",
}
)
def changed_setting_keys(
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
) -> set[str]:
keys = {str(key) for key in (updates or {}).keys()}
keys.update(str(key) for key in (deletes or []) if key is not None)
return keys
async def refresh_webapp_runtime_after_settings_change(
request: Any,
*,
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
include_user_payloads: bool = True,
) -> None:
settings = request.app["settings"]
keys = changed_setting_keys(updates, deletes)
reset_webapp_settings_cache(request.app)
reset_subscription_guides_cache(request.app)
if include_user_payloads:
await invalidate_all_webapp_user_payloads(
settings,
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
)
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
+209 -20
View File
@@ -16,7 +16,7 @@ from typing import Any, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon"
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
section: str
label: str
description: str = ""
@@ -34,6 +34,13 @@ class SettingField:
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
"WEBAPP_TITLE",
"string",
"general",
"Web App title",
placeholder="My subscription",
),
SettingField(
"DEFAULT_LANGUAGE",
"string",
@@ -117,9 +124,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
subsection="Remnawave",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
),
SettingField(
"SUBSCRIPTION_MINI_APP_URL",
"url",
@@ -160,6 +164,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
SettingField(
"SUBSCRIPTION_GUIDES_ENABLED",
"bool",
"subscription_guides",
"Embedded install guides",
"Open install instructions inside the Web App instead of an external connect page.",
),
SettingField(
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
"bool",
"subscription_guides",
"Open install guides from bot",
(
"Use the Telegram Mini App install screen for bot connect buttons and show "
"public install guide links."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
"bool",
"subscription_guides",
"Use Remnawave Panel config",
(
"Fetch Subscription Page config from Remnawave Panel by the user's "
"subscription short UUID."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
"bool",
"subscription_guides",
"Enable admin JSON override",
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PATH",
"string",
"subscription_guides",
"Subscription Page config path",
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
placeholder="data/subpage-config/multiapp.json",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON",
"json",
"subscription_guides",
"Subscription Page config JSON",
(
"Optional admin JSON override. It is applied only when the JSON override "
"switch is enabled."
),
placeholder='{\n "version": "1"\n}',
),
# ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
@@ -177,37 +234,46 @@ SETTINGS_MANIFEST: List[SettingField] = [
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"pricing",
"Порядок методов оплаты",
"Через запятую, например: severpay,freekassa,yookassa,heleket",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"bool",
"pricing",
"payments",
"Показывать описание подписки",
"Текст появится перед выбором срока покупки или продления.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"text",
"pricing",
"payments",
"Описание подписки (RU)",
"Русская версия текста на этапе оплаты.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"text",
"pricing",
"payments",
"Описание подписки (EN)",
"Английская версия текста на этапе оплаты.",
subsection="checkout",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
SettingField(
"STARS_ADMIN_ONLY_ENABLED",
"bool",
"payments",
"Telegram Stars admin-only",
(
"Shows Telegram Stars only to users from ADMIN_IDS. "
"Payment callbacks remain active for admin test payments."
),
subsection="common",
i18n_label_key="admin_settings_provider_admin_only_label",
i18n_description_key="admin_settings_provider_admin_only_description",
),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
@@ -217,10 +283,48 @@ SETTINGS_MANIFEST: List[SettingField] = [
subsection="common",
),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
SettingField(
"TRIAL_ENABLED",
"bool",
"pricing",
"Триал включён",
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_DURATION_DAYS",
"int",
"pricing",
"Длительность триала (дней)",
optional=False,
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_LIMIT_GB",
"float",
"pricing",
"Лимит трафика триала (ГБ)",
optional=False,
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_STRATEGY",
"string",
"pricing",
"Стратегия сброса трафика триала",
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
"pricing",
"Internal Squads для триала",
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
subsection="trial",
),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
@@ -305,6 +409,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
"За сколько дней предупреждать",
min=0,
),
SettingField(
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
"int",
"notifications",
"За сколько часов предупреждать",
min=0,
max=23,
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
@@ -340,6 +452,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
"ID треда поддержки",
"Тред лог-чата для уведомлений о тикетах поддержки.",
),
SettingField(
"BACKUP_ENABLED",
"bool",
"backups",
"Бэкапы включены",
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
),
SettingField(
"BACKUP_CHAT_ID",
"int",
"backups",
"ID чата для бэкапов",
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
),
SettingField(
"BACKUP_THREAD_ID",
"int",
"backups",
"ID треда для бэкапов",
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
),
SettingField(
"BACKUP_INTERVAL_SECONDS",
"int",
"backups",
"Период бэкапов (сек.)",
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
optional=False,
min=60,
),
SettingField(
"BACKUP_LOCAL_RETENTION",
"int",
"backups",
"Сколько архивов хранить",
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
optional=False,
min=1,
),
SettingField(
"BACKUP_COMPOSE_ENABLED",
"bool",
"backups",
"Добавлять compose-папку",
(
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
"бэкап БД все равно будет создан."
),
),
SettingField(
"SUPPORT_TICKETS_ENABLED",
"bool",
@@ -454,7 +615,21 @@ def manifest_keys() -> List[str]:
def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field."""
if field.type == "json":
if raw is None:
return ""
text = raw if isinstance(raw, str) else str(raw)
text = text.strip()
if not text:
return ""
from config.subscription_guides_config import validate_subscription_guides_config_text
validate_subscription_guides_config_text(text)
return text
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
if not field.optional:
raise ValueError(f"{field.key}: value required")
return None
if field.type == "bool":
@@ -510,19 +685,27 @@ def manifest_payload() -> List[dict]:
from bot.payment_providers import (
find_manifest_owner,
manifest_field_default,
provider_admin_only_pairs,
provider_webhook_metadata,
)
sections_order = {
"general": 1,
"appearance": 2,
"pricing": 3,
"pricing": 11,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"support": 8,
"devices": 9,
"backups": 9,
"devices": 10,
"subscription_guides": 10,
}
exclusive_map = {
key: opposite
for public_key, admin_key in provider_admin_only_pairs()
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
}
items: List[dict] = []
for field in aggregated_manifest():
@@ -565,6 +748,12 @@ def manifest_payload() -> List[dict]:
"optional": field.optional,
"secret": field.secret,
}
if field.min is not None:
item["min"] = field.min
if field.max is not None:
item["max"] = field.max
if field.key in exclusive_map:
item["mutually_exclusive_key"] = exclusive_map[field.key]
if default_value is not None:
item["default"] = default_value
if webhook_metadata:
@@ -11,6 +11,7 @@ from bot.app.web.webapp import (
billing as _billing,
common as _common,
devices as _devices,
guides as _guides,
payloads as _payloads,
routes as _routes,
serializers as _serializers,
@@ -27,6 +28,7 @@ _MODULES = (
_serializers,
_billing,
_devices,
_guides,
_support,
_routes,
_application,
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,219 @@
<!doctype html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>__PAGE_TITLE__</title>
<style nonce="__NONCE__">
:root {
color-scheme: dark light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #0b1017;
color: #f7fafc;
}
body {
min-height: 100dvh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
main {
width: min(100%, 420px);
display: grid;
gap: 14px;
text-align: center;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #aeb8c5;
font-size: 15px;
line-height: 1.55;
}
.actions {
display: grid;
gap: 10px;
margin-top: 4px;
}
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: #14b86f;
color: #03120b;
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.secondary {
border-color: #2d3847;
background: transparent;
color: #f7fafc;
}
.button[aria-disabled="true"] {
pointer-events: none;
background: #344052;
color: #aeb8c5;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<main>
<h1 id="title"></h1>
<p id="status"></p>
<div class="actions">
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
<button id="close-button" class="button secondary" type="button" hidden></button>
</div>
</main>
<script nonce="__NONCE__">
(() => {
const messages = __MESSAGES_JSON__;
const titleEl = document.getElementById("title");
const statusEl = document.getElementById("status");
const openLink = document.getElementById("open-link");
const closeButton = document.getElementById("close-button");
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const target = String(params.get("url") || "").trim();
const isUnsafe =
!target ||
hasControlChars(target) ||
/^(?:javascript|data|vbscript|https?):/i.test(target);
let attempted = false;
let pageLeft = false;
let state = "opening";
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function text(key, fallback) {
const value = messages && messages[key];
return typeof value === "string" && value ? value : fallback;
}
function tryCloseWindow() {
try {
window.close();
} catch (_error) {
void _error;
}
}
function render(nextState) {
state = nextState;
if (nextState === "unavailable") {
titleEl.textContent = text("unavailableTitle", "App link unavailable");
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
openLink.textContent = text("button", "Open app");
openLink.setAttribute("aria-disabled", "true");
openLink.removeAttribute("href");
closeButton.hidden = true;
return;
}
if (nextState === "done") {
titleEl.textContent = text("doneTitle", "Settings added");
statusEl.textContent = text("doneHint", "You can close this window.");
openLink.textContent = text("retryButton", "Open again");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.textContent = text("closeButton", "Close window");
closeButton.hidden = false;
return;
}
titleEl.textContent = text("title", "Opening app");
statusEl.textContent =
nextState === "manual"
? text("manualHint", "If the app did not open automatically, tap the button below.")
: text("hint", "Opening the app on this device...");
openLink.textContent = text("button", "Open app");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.hidden = true;
}
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
window.setTimeout(tryCloseWindow, 120);
}
function notePageLeft() {
if (!attempted) return;
pageLeft = true;
window.setTimeout(markDone, 900);
}
function openTarget() {
if (isUnsafe) return;
attempted = true;
pageLeft = false;
render("opening");
window.location.href = target;
window.setTimeout(() => {
if (state === "opening" && !pageLeft) render("manual");
}, 1600);
}
if (isUnsafe) {
render("unavailable");
return;
}
openLink.addEventListener("click", (event) => {
event.preventDefault();
openTarget();
});
closeButton.addEventListener("click", () => {
tryCloseWindow();
render("done");
});
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
} else if (pageLeft) {
markDone();
}
});
render("opening");
window.setTimeout(openTarget, 80);
})();
</script>
</body>
</html>
@@ -8,7 +8,20 @@
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" />
<link id="app-favicon" rel="icon" href="data:," sizes="any" />
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<link
id="app-apple-touch-icon"
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="180x180"
href="/apple-touch-icon-precomposed.png"
/>
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
+108 -4
View File
@@ -45,6 +45,7 @@
--surface-sheen-soft: transparent;
--surface-hover: rgba(255, 255, 255, 0.08);
--surface-muted: #0a0a0a;
--surface-subtle: #000000;
--surface-subtle-border: #ffffff;
--overlay-scrim: rgba(0, 0, 0, 0.85);
--nav-bg: #000000;
@@ -119,6 +120,8 @@
/* ---------- Panels / cards ---------- */
.theme-key-ascii .card,
.theme-key-ascii .trial-card-facts span,
.theme-key-ascii .trial-activation-facts div,
.theme-key-ascii .period-card,
.theme-key-ascii .method-card,
.theme-key-ascii .settings-row,
@@ -149,6 +152,12 @@
.theme-key-ascii .ticket-message-avatar,
.theme-key-ascii .ticket-message-bubble,
.theme-key-ascii .ticket-composer,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
.theme-key-ascii .admin-sidebar,
.theme-key-ascii .admin-header,
.theme-key-ascii .admin-card,
@@ -183,6 +192,8 @@
.theme-key-ascii .support-new-ticket-button,
.theme-key-ascii .support-select-trigger,
.theme-key-ascii .support-status-tabs-trigger,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .admin-btn,
.theme-key-ascii .admin-chip,
.theme-key-ascii .admin-tabs-trigger,
@@ -230,6 +241,7 @@
.theme-key-ascii .support-status-tabs-trigger[data-state="active"],
.theme-key-ascii .support-select-item[data-highlighted],
.theme-key-ascii .support-select-item[data-selected],
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .admin-nav-item.active,
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
.theme-key-ascii .admin-revenue-period-btn.is-active {
@@ -259,6 +271,9 @@
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
.theme-key-ascii .admin-mobile-toggle:focus-visible,
.theme-key-ascii .language-select-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger[data-state="open"],
.theme-key-ascii .install-app-button:focus-visible,
.theme-key-ascii .bottom-nav button:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 1px;
@@ -305,12 +320,22 @@
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-ascii .trial-offer-card,
.theme-key-ascii .trial-card-facts span,
.theme-key-ascii .trial-activation-card,
.theme-key-ascii .trial-activation-facts div,
.theme-key-ascii .activation-success-dialog,
.theme-key-ascii .subscription-purchase-description,
.theme-key-ascii .support-create-panel,
.theme-key-ascii .ticket-composer {
background: #000000;
}
.theme-key-ascii .trial-card-head > svg,
.theme-key-ascii .dialog-title-icon {
color: #ffffff;
}
.theme-key-ascii .support-heading-icon,
.theme-key-ascii .support-new-ticket-icon,
.theme-key-ascii .support-empty-state svg,
@@ -412,6 +437,7 @@
}
body:has(.theme-key-ascii) .support-select-content,
body:has(.theme-key-ascii) .install-platform-content,
body:has(.theme-key-ascii) .field-error-tooltip {
border: 1px solid #ffffff;
border-radius: 0;
@@ -420,19 +446,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
box-shadow: 0 0 0 1px #ffffff;
}
body:has(.theme-key-ascii) .support-select-item {
body:has(.theme-key-ascii) .support-select-item,
body:has(.theme-key-ascii) .install-platform-item {
border-radius: 0;
color: #ffffff;
}
body:has(.theme-key-ascii) .support-select-item[data-highlighted],
body:has(.theme-key-ascii) .support-select-item[data-selected] {
body:has(.theme-key-ascii) .support-select-item[data-selected],
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff;
color: #000000 !important;
}
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg,
body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
body:has(.theme-key-ascii) .support-select-item[data-selected] svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important;
stroke: #000000 !important;
}
@@ -695,7 +726,8 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.theme-key-ascii .admin-btn-primary svg.lucide,
.theme-key-ascii .admin-nav-item.active svg.lucide,
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-ascii .install-app-button.active svg.lucide {
color: #000000 !important;
stroke: #000000 !important;
}
@@ -1044,12 +1076,16 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.theme-key-ascii :is(
.card, .dialog-card, .toast,
.btn, .input,
.trial-card-facts span, .trial-activation-facts div,
.period-card, .method-card, .settings-row, .option-row,
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
.topup-carryover-note, .subscription-purchase-description,
.language-select-content, .language-select-item,
.language-select-trigger, .bottom-nav, .bottom-nav button,
.link-button,
.install-platform-trigger, .install-platform-content, .install-platform-item,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-subscription-header-icon, .install-loading,
.support-overview-card, .support-list-card, .support-ticket-card,
.support-conversation-card, .support-new-ticket-button,
.support-create-panel, .support-select-trigger, .support-select-content,
@@ -1083,6 +1119,9 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card,
.admin-select-trigger, .admin-select-content,
.install-platform-trigger, .install-platform-content,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-loading,
.admin-cn-card,
.admin-input, .admin-textarea, .admin-btn,
.admin-nav-item, .admin-tabs-trigger
@@ -1096,6 +1135,71 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
border-radius: 0 !important;
}
/* ---------- Install guide theme surfaces ---------- */
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
body:has(.theme-key-ascii) .install-platform-content {
border: 1px solid #ffffff !important;
border-radius: 0 !important;
background: #000000 !important;
box-shadow: none !important;
}
.theme-key-ascii .install-platform-trigger:hover,
.theme-key-ascii .install-app-button:hover:not(:disabled) {
background: #ffffff !important;
color: #000000 !important;
transform: none !important;
}
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .install-app-button.active:hover:not(:disabled),
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff !important;
color: #000000 !important;
border-color: #ffffff !important;
}
.theme-key-ascii .install-app-button.active svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important;
stroke: #000000 !important;
}
.theme-key-ascii .install-step:hover,
.theme-key-ascii .install-subscription-card:hover {
transform: none !important;
box-shadow: none !important;
}
.theme-key-ascii .install-step-icon,
.theme-key-ascii .install-subscription-header-icon {
border: 1px solid currentColor !important;
background: #000000 !important;
color: #ffffff !important;
}
.theme-key-ascii .install-qr-divider {
color: #ffffff !important;
opacity: 0.72;
}
.theme-key-ascii .install-feature-star.attention-dot {
background: #ffffff !important;
animation: ascii-caret 1s steps(1) infinite !important;
}
.theme-key-ascii .install-loading .ui-spinner {
color: #ffffff;
}
/* ============================================================
* Console-style tables: cell borders, header underline,
* row separator using dashed line.
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 3,
"assets_version": 4,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
@@ -33,6 +33,7 @@
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
--surface-hover: rgba(15, 23, 42, 0.045);
--surface-muted: rgba(15, 23, 42, 0.035);
--surface-subtle: rgba(15, 23, 42, 0.025);
--surface-subtle-border: rgba(15, 23, 42, 0.1);
--overlay-scrim: rgba(15, 23, 42, 0.34);
--nav-bg: rgba(255, 255, 255, 0.88);
@@ -109,6 +110,26 @@
z-index: 1;
}
/* New user-facing activation surfaces */
.theme-key-light .trial-offer-card,
.theme-key-light .trial-activation-card,
.theme-key-light .activation-success-dialog {
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.theme-key-light .trial-card-head > svg,
.theme-key-light .dialog-title-icon {
color: color-mix(in srgb, var(--accent) 54%, #000000);
}
.theme-key-light .trial-card-facts span,
.theme-key-light .trial-activation-facts div {
border-color: rgba(15, 23, 42, 0.12);
background: rgba(15, 23, 42, 0.025);
}
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
.theme-key-light .admin-revenue-svg-frame {
background: #ffffff;
@@ -129,3 +150,57 @@
.theme-key-light .bonus-card-head > svg {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
/* Install guide theme surfaces */
.theme-key-light .install-platform-trigger,
.theme-key-light .install-app-button,
.theme-key-light .install-step,
.theme-key-light .install-subscription-card,
.theme-key-light .install-qr-wrap {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
}
.theme-key-light .install-app-button.active {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.theme-key-light .install-platform-trigger:focus-visible,
.theme-key-light .install-platform-trigger[data-state="open"],
.theme-key-light .install-app-button:focus-visible {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
body:has(.theme-key-light) .install-platform-content {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.14);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
}
body:has(.theme-key-light) .install-platform-item[data-highlighted],
body:has(.theme-key-light) .install-platform-item[data-selected] {
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
}
.theme-key-light .install-step-icon,
.theme-key-light .install-subscription-header-icon {
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
.theme-key-light .install-qr-divider {
color: rgba(15, 23, 42, 0.24);
}
.theme-key-light .install-feature-star.attention-dot {
background: #f59e0b;
}
.theme-key-light .install-loading .ui-spinner {
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 2,
"assets_version": 3,
"tokens": {
"color_scheme": "light"
}
+145 -6
View File
@@ -39,6 +39,7 @@
--surface-sheen-soft: transparent;
--surface-hover: rgba(0, 0, 128, 0.14);
--surface-muted: #c0c0c0;
--surface-subtle: #dfdfdf;
--surface-subtle-border: #808080;
--overlay-scrim: rgba(0, 0, 0, 0.35);
--nav-bg: #c0c0c0;
@@ -138,12 +139,15 @@
.theme-key-windows95 svg.lucide-megaphone,
.theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-message-square-plus,
.theme-key-windows95 svg.lucide-monitor,
.theme-key-windows95 svg.lucide-paintbrush,
.theme-key-windows95 svg.lucide-plus,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-refresh-cw,
.theme-key-windows95 svg.lucide-save,
.theme-key-windows95 svg.lucide-search,
.theme-key-windows95 svg.lucide-send,
.theme-key-windows95 svg.lucide-share-2,
.theme-key-windows95 svg.lucide-settings,
.theme-key-windows95 svg.lucide-shield,
.theme-key-windows95 svg.lucide-sliders,
@@ -273,6 +277,10 @@
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-monitor {
--win95-button-icon: var(--win95-icon-dashboard);
}
.theme-key-windows95 svg.lucide-paintbrush {
--win95-button-icon: var(--win95-icon-paintbrush);
}
@@ -281,6 +289,10 @@
--win95-button-icon: var(--win95-icon-folder);
}
.theme-key-windows95 svg.lucide-qr-code {
--win95-button-icon: var(--win95-icon-key);
}
.theme-key-windows95 svg.lucide-refresh-cw {
--win95-button-icon: var(--win95-icon-refresh);
}
@@ -293,6 +305,10 @@
--win95-button-icon: var(--win95-icon-search);
}
.theme-key-windows95 svg.lucide-share-2 {
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-settings {
--win95-button-icon: var(--win95-icon-settings);
}
@@ -371,12 +387,15 @@
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
@@ -425,12 +444,15 @@
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
@@ -488,7 +510,13 @@
.theme-key-windows95 .support-message-scroll,
.theme-key-windows95 .ticket-message-avatar,
.theme-key-windows95 .ticket-message-bubble,
.theme-key-windows95 .ticket-composer {
.theme-key-windows95 .ticket-composer,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button,
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
@@ -510,6 +538,7 @@
body:has(.theme-key-windows95) .language-select-content,
body:has(.theme-key-windows95) .support-select-content,
body:has(.theme-key-windows95) .install-platform-content,
body:has(.theme-key-windows95) .field-error-tooltip,
body:has(.theme-key-windows95) .admin-select-content {
border-width: 2px;
@@ -525,18 +554,23 @@ body:has(.theme-key-windows95) .admin-select-content {
body:has(.theme-key-windows95) .language-select-item,
body:has(.theme-key-windows95) .support-select-item,
body:has(.theme-key-windows95) .install-platform-item,
body:has(.theme-key-windows95) .admin-select-item {
border-radius: 0 !important;
}
body:has(.theme-key-windows95) .support-select-item[data-highlighted],
body:has(.theme-key-windows95) .support-select-item[data-selected] {
body:has(.theme-key-windows95) .support-select-item[data-selected],
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: #000080;
color: #ffffff !important;
}
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg,
body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
body:has(.theme-key-windows95) .support-select-item[data-selected] svg,
body:has(.theme-key-windows95) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-windows95) .install-platform-item[data-selected] svg {
filter: brightness(0) invert(1);
}
@@ -560,7 +594,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .link-button,
.theme-key-windows95 .support-new-ticket-button,
.theme-key-windows95 .support-select-trigger,
.theme-key-windows95 .support-status-tabs-trigger {
.theme-key-windows95 .support-status-tabs-trigger,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
min-height: 34px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
@@ -625,13 +661,17 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .support-new-ticket-button.active,
.theme-key-windows95 .support-status-tabs-trigger[data-state="active"],
.theme-key-windows95 .support-select-item[data-highlighted],
.theme-key-windows95 .support-select-item[data-selected] {
.theme-key-windows95 .support-select-item[data-selected],
.theme-key-windows95 .install-app-button.active {
background: var(--accent);
color: #ffffff;
}
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-windows95 .trial-offer-card,
.theme-key-windows95 .trial-activation-card,
.theme-key-windows95 .activation-success-dialog,
.theme-key-windows95 .subscription-purchase-description,
.theme-key-windows95 .support-create-panel,
.theme-key-windows95 .ticket-composer,
@@ -639,6 +679,20 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
background: #c0c0c0;
}
.theme-key-windows95 .trial-card-facts span,
.theme-key-windows95 .trial-activation-facts div {
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
background: #dfdfdf;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #ffffff;
}
.theme-key-windows95 .dialog-title-icon {
color: var(--accent);
}
.theme-key-windows95 .support-heading-icon,
.theme-key-windows95 .support-new-ticket-icon,
.theme-key-windows95 .support-empty-state svg,
@@ -718,6 +772,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .ticket-composer:focus-within,
.theme-key-windows95 .support-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .ticket-card:focus-visible,
.theme-key-windows95 .support-status-tabs-trigger:focus-visible {
outline: 1px dotted #000000;
@@ -731,6 +788,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
transition: none;
}
/* ---------- Install guide theme surfaces ---------- */
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
background: #c0c0c0;
color: #000000;
transition: none;
transform: none;
}
.theme-key-windows95 .install-platform-trigger:hover,
.theme-key-windows95 .install-app-button:hover:not(:disabled):not(.active) {
background: #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-app-button.active,
.theme-key-windows95 .install-app-button.active:hover:not(:disabled) {
background: var(--accent);
color: #ffffff;
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
background: #c0c0c0;
transition: none;
}
.theme-key-windows95 .install-step:hover,
.theme-key-windows95 .install-subscription-card:hover {
transform: none;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .install-step-icon,
.theme-key-windows95 .install-subscription-header-icon {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #dfdfdf;
color: var(--accent);
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #808080;
}
body:has(.theme-key-windows95) .install-platform-content {
background: #c0c0c0;
}
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: var(--accent);
color: #ffffff !important;
}
.theme-key-windows95 .install-qr-divider {
color: #404040;
opacity: 1;
}
.theme-key-windows95 .install-feature-star.attention-dot {
background: #ffff00 !important;
border: 1px solid #000000;
box-shadow: 1px 1px 0 #000000;
}
.theme-key-windows95 .install-loading .ui-spinner {
color: var(--accent);
}
.theme-key-windows95 .card-heading-accent,
.theme-key-windows95 .brand-row strong,
.theme-key-windows95 .login-brand h1,
@@ -1059,6 +1195,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .admin-revenue-period-btn:focus-visible,
.theme-key-windows95 .admin-mobile-toggle:focus-visible,
.theme-key-windows95 .language-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .bottom-nav button:focus-visible {
outline: 1px dotted #000000;
outline-offset: -4px;
@@ -1116,6 +1255,7 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .admin-nav-item.active svg.lucide,
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-windows95 .install-app-button.active svg.lucide,
.theme-key-windows95 .admin-header svg.lucide {
filter: brightness(0) invert(1);
}
@@ -1133,7 +1273,6 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 svg.lucide-map,
.theme-key-windows95 svg.lucide-menu,
.theme-key-windows95 svg.lucide-mouse-pointer-click,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-radio,
.theme-key-windows95 svg.lucide-repeat-2,
.theme-key-windows95 svg.lucide-server,
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 9,
"assets_version": 11,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
+12 -1
View File
@@ -49,7 +49,11 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from bot.utils.text_sanitizer import (
panel_description_from_profile,
sanitize_display_name,
sanitize_username,
)
from config.settings import Settings
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
@@ -59,6 +63,7 @@ logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
APP_ROOT = Path(__file__).resolve().parents[5]
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
@@ -66,6 +71,12 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_DEFAULT_BRAND_DIR = ASSET_DIR / "default-brand"
WEBAPP_DEFAULT_LOGO_FILE = WEBAPP_DEFAULT_BRAND_DIR / "default-logo.webp"
WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
+17
View File
@@ -430,6 +430,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
fresh = str(request.query.get("fresh") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
if fresh:
await _invalidate_webapp_user_caches(settings, user_id)
data = await _build_user_payload(request, user_id)
return web.json_response({"ok": True, **data})
data = await webapp_cached_user_payload(
settings,
"me",
@@ -471,12 +482,18 @@ async def account_avatar_route(request: web.Request) -> web.Response:
async def account_language_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
if validation_error:
return validation_error
language = _normalize_language(str(language_payload.language or ""))
i18n = request.app.get("i18n")
if i18n and hasattr(i18n, "reload_overrides_from_file"):
i18n.reload_overrides_from_file()
if i18n and language not in getattr(i18n, "locales_data", {}):
return _json_error(400, "unsupported_language", "Unsupported language")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
+5 -1
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .guides import warm_subscription_guides_config
def create_subscription_webapp_application(
@@ -20,10 +21,12 @@ def create_subscription_webapp_application(
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
app["webapp_logo_cache"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock()
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
app["subscription_guides_config_lock"] = asyncio.Lock()
app["webapp_rate_limit_buckets"] = {}
app["webapp_rate_limit_lock"] = asyncio.Lock()
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj)
await warm_subscription_guides_config(app_obj)
async def _shutdown(app_obj: web.Application) -> None:
await _close_shared_http_session()
+260 -13
View File
@@ -9,6 +9,7 @@ from config.webapp_themes_config import (
public_theme_payload,
public_themes_catalog_payload,
)
from bot.middlewares.i18n import locale_language_options
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
@@ -95,9 +96,7 @@ async def theme_css_asset_route(request: web.Request) -> web.Response:
raise web.HTTPNotFound(text="theme_css_not_found") from None
query = getattr(request, "query", {}) or {}
cache_control = (
"public, max-age=31536000, immutable" if query.get("v") else "no-cache"
)
cache_control = "public, max-age=31536000, immutable" if query.get("v") else "no-cache"
try:
stat = path.stat()
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
@@ -204,9 +203,9 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return ""
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
if not raw_logo_url:
return ""
return WEBAPP_DEFAULT_LOGO_PATH
parsed_logo_url = urlsplit(raw_logo_url)
if parsed_logo_url.scheme == "https":
@@ -216,7 +215,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return raw_logo_url
if raw_logo_url.startswith("/"):
return raw_logo_url
return ""
return WEBAPP_DEFAULT_LOGO_PATH
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
@@ -228,7 +227,9 @@ def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
if resolved:
return resolved
return logo_url or ""
if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH:
return logo_url
return WEBAPP_DEFAULT_FAVICON_URL
def _resolve_webapp_asset_url(raw_url: str) -> str:
@@ -365,6 +366,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
return _uploaded_webapp_logo_response(filename)
async def webapp_default_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp")
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_favicon_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
@@ -372,6 +383,78 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
digest = str(request.match_info.get("digest") or "").strip().lower()
filename = str(request.match_info.get("filename") or "").strip()
return _webapp_favicon_file_response(digest, filename)
async def webapp_current_favicon_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
requested_filename = str(request.path.rsplit("/", 1)[-1] or "").strip()
target_filename = _webapp_root_favicon_target_filename(requested_filename)
if not target_filename:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
digest = _webapp_generated_favicon_digest(favicon_url)
if digest:
return _webapp_favicon_file_response(digest, target_filename)
redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename)
if redirect_url:
raise web.HTTPFound(location=redirect_url)
raise web.HTTPNotFound(text="webapp_favicon_not_found")
def _webapp_root_favicon_target_filename(filename: str) -> str:
if filename == "apple-touch-icon-precomposed.png":
return "apple-touch-icon.png"
if filename in {
"apple-touch-icon.png",
"favicon.ico",
"icon-192.png",
"icon-512.png",
}:
return filename
return ""
def _webapp_generated_favicon_digest(favicon_url: str) -> str:
parsed = urlsplit(str(favicon_url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(favicon_url or "")
match = re.fullmatch(
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/"
r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))",
path,
)
return match.group(1) if match else ""
def _webapp_redirectable_favicon_url(favicon_url: str, target_filename: str) -> str:
href = str(favicon_url or "").strip()
if not href:
return ""
parsed = urlsplit(href)
path = parsed.path if parsed.scheme or parsed.netloc else href
suffix = Path(path).suffix.lower()
if target_filename in {"apple-touch-icon.png", "icon-192.png", "icon-512.png"}:
if suffix != ".png":
return ""
elif target_filename == "favicon.ico":
if suffix != ".ico":
return ""
else:
return ""
if parsed.scheme in {"http", "https"} or href.startswith("/"):
return href
return ""
def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
if not re.fullmatch(r"[0-9a-f]{16}", digest):
raise web.HTTPNotFound(text="webapp_favicon_not_found")
if not re.fullmatch(
@@ -380,6 +463,9 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
):
raise web.HTTPNotFound(text="webapp_favicon_not_found")
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
return _webapp_default_favicon_file_response(filename)
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
path = (root / digest / filename).resolve()
try:
@@ -406,6 +492,29 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
return response
def _webapp_default_favicon_file_response(filename: str) -> web.Response:
path = WEBAPP_DEFAULT_FAVICON_DIR / filename
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
if not content_type:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
response = _webapp_default_brand_file_response(path, content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response:
try:
body = _read_template_binary_cached(path)
except OSError:
raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise web.HTTPNotFound(text="webapp_default_brand_not_found")
return web.Response(body=body, content_type=content_type)
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower()
@@ -1000,6 +1109,30 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
WEBAPP_I18N_SCOPES = {"webapp", "admin"}
APP_DEEPLINK_I18N_KEYS = {
"title": "wa_app_launch_title",
"hint": "wa_app_launch_opening_hint",
"manualHint": "wa_app_launch_hint",
"button": "wa_app_launch_button",
"retryButton": "wa_app_launch_retry_button",
"doneTitle": "wa_app_launch_done_title",
"doneHint": "wa_app_launch_done_hint",
"closeButton": "wa_app_launch_close_button",
"unavailableTitle": "wa_app_launch_unavailable_title",
"unavailableHint": "wa_app_launch_unavailable_hint",
}
APP_DEEPLINK_I18N_FALLBACKS = {
"wa_app_launch_title": "Opening app",
"wa_app_launch_opening_hint": "Opening the app on this device...",
"wa_app_launch_hint": "If the app did not open automatically, tap the button below.",
"wa_app_launch_button": "Open app",
"wa_app_launch_retry_button": "Open again",
"wa_app_launch_done_title": "Settings added",
"wa_app_launch_done_hint": "If the app opened, you can close this window.",
"wa_app_launch_close_button": "Close window",
"wa_app_launch_unavailable_title": "App link unavailable",
"wa_app_launch_unavailable_hint": "Return to Telegram and try again.",
}
def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
@@ -1063,7 +1196,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
preview_key = ""
i18n_instance: Optional[object] = request.app.get("i18n")
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
i18n_instance.reload_overrides_from_file()
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
base_locales_data = getattr(i18n_instance, "base_locales_data", {}) if i18n_instance else {}
return {
"config": {
"title": settings.WEBAPP_TITLE,
@@ -1094,6 +1230,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"userAgreementUrl": cached["user_agreement_url"],
"currency": cached["currency"],
"language": cached["language"],
"languages": locale_language_options(
locales_data.keys(),
base_languages=base_locales_data.keys(),
),
"emailAuthEnabled": cached["email_auth_enabled"],
"appVersion": _resolve_app_version(),
"appRepositoryUrl": APP_REPOSITORY_URL,
@@ -1110,6 +1250,8 @@ async def bootstrap_route(request: web.Request) -> web.Response:
async def i18n_route(request: web.Request) -> web.Response:
i18n_instance: Optional[object] = request.app.get("i18n")
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
i18n_instance.reload_overrides_from_file()
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
response = web.json_response(
@@ -1123,6 +1265,72 @@ async def i18n_route(request: web.Request) -> web.Response:
return response
def _webapp_page_title(settings: Settings, suffix: str = "") -> str:
base = str(getattr(settings, "WEBAPP_TITLE", "") or "").strip() or "Subscription"
suffix = str(suffix or "").strip()
return f"{base} - {suffix}" if suffix else base
def _webapp_preview_meta_markup(page_title: str) -> str:
escaped_title = html.escape(str(page_title or ""), quote=True)
return "\n".join(
[
f'<meta name="application-name" content="{escaped_title}">',
f'<meta name="apple-mobile-web-app-title" content="{escaped_title}">',
f'<meta property="og:title" content="{escaped_title}">',
'<meta property="og:type" content="website">',
f'<meta property="og:site_name" content="{escaped_title}">',
'<meta name="twitter:card" content="summary">',
f'<meta name="twitter:title" content="{escaped_title}">',
]
)
def _replace_webapp_title(html_text: str, page_title: str) -> str:
escaped_title = html.escape(str(page_title or ""), quote=False)
next_title = f"<title>{escaped_title}</title>"
replaced = re.sub(
r"<title\b[^>]*>.*?</title>",
next_title,
html_text,
count=1,
flags=re.IGNORECASE | re.DOTALL,
)
if replaced != html_text:
return replaced
return html_text.replace("</head>", f"{next_title}\n</head>", 1)
def _replace_webapp_favicon(html_text: str, favicon_markup: str) -> str:
markup = str(favicon_markup or "").strip()
if not markup:
return html_text
replaced = re.sub(
r"<link\b(?=[^>]*\bid=[\"']app-favicon[\"'])[^>]*>",
markup,
html_text,
count=1,
flags=re.IGNORECASE,
)
if replaced != html_text:
return replaced
return html_text.replace("</head>", f"{markup}\n</head>", 1)
def _apply_webapp_head_metadata(html_text: str, page_title: str, favicon_url: str = "") -> str:
html_text = _replace_webapp_title(html_text, page_title)
if 'property="og:title"' not in html_text and "property='og:title'" not in html_text:
meta_markup = _webapp_preview_meta_markup(page_title)
html_text = re.sub(
r"(<title\b[^>]*>.*?</title>)",
lambda match: f"{match.group(1)}\n{meta_markup}",
html_text,
count=1,
flags=re.IGNORECASE | re.DOTALL,
)
return _replace_webapp_favicon(html_text, _favicon_head_markup(favicon_url))
async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
@@ -1144,6 +1352,7 @@ async def index_route(request: web.Request) -> web.Response:
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
if initial_theme_markup:
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
html = _apply_webapp_head_metadata(html, _webapp_page_title(settings), cached["favicon_url"])
i18n_payload = bootstrap["i18n"]
nonce = request.get("csp_nonce", "")
html = html.replace(
@@ -1166,12 +1375,6 @@ async def index_route(request: web.Request) -> web.Response:
WEBAPP_JS_PLACEHOLDER,
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
)
favicon_markup = _favicon_head_markup(cached["favicon_url"])
if favicon_markup:
html = html.replace(
'<link id="app-favicon" rel="icon" href="data:," sizes="any">',
favicon_markup,
)
brand_asset_url = cached["logo_url"]
if (
not brand_asset_url
@@ -1193,6 +1396,50 @@ async def index_route(request: web.Request) -> web.Response:
return response
async def app_deeplink_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not getattr(settings, "WEBAPP_ENABLED", True):
raise web.HTTPNotFound(text="webapp_disabled")
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
query = getattr(request, "query", {}) or {}
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
messages = _app_deeplink_i18n_payload(request, lang)
page_title = _webapp_page_title(settings, messages["title"])
messages_json = json.dumps(
messages,
ensure_ascii=False,
separators=(",", ":"),
).replace("</", "<\\/")
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
html_text = (
_read_template_text_cached(APP_DEEPLINK_TEMPLATE_PATH)
.replace("__LANG__", html.escape(lang, quote=True))
.replace("__PAGE_TITLE__", html.escape(page_title, quote=False))
.replace("__NONCE__", nonce)
.replace("__MESSAGES_JSON__", messages_json)
)
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-store"
return response
def _app_deeplink_i18n_payload(request: web.Request, lang: str) -> Dict[str, str]:
i18n_instance: Optional[object] = request.app.get("i18n")
payload: Dict[str, str] = {}
for payload_key, i18n_key in APP_DEEPLINK_I18N_KEYS.items():
fallback = APP_DEEPLINK_I18N_FALLBACKS[i18n_key]
value = ""
if i18n_instance is not None:
try:
value = str(i18n_instance.gettext(lang, i18n_key) or "")
except Exception as exc:
logger.debug("Failed to resolve open-app i18n key %s: %s", i18n_key, exc)
payload[payload_key] = value if value and value != i18n_key else fallback
return payload
async def _serve_template_asset(
request: web.Request,
filename: str,
+31 -27
View File
@@ -1013,13 +1013,11 @@ def _telegram_id_for_user(user: User) -> Optional[int]:
def _panel_description_for_user(user: User) -> str:
lines = [
user.email or "",
user.username or "",
user.first_name or "",
user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
return panel_description_from_profile(
user.username,
user.first_name,
user.last_name,
)
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
@@ -1042,9 +1040,7 @@ async def _sync_panel_identity_for_user(
if not subscription_service or not subscription_service.panel_service:
return False
payload: Dict[str, Any] = {
"description": _panel_description_for_user(user),
}
payload: Dict[str, Any] = {}
telegram_id = _telegram_id_for_user(user)
if telegram_id:
payload["telegramId"] = telegram_id
@@ -1058,11 +1054,19 @@ async def _sync_panel_identity_for_user(
payload["status"] = "ACTIVE"
try:
await subscription_service.panel_service.update_user_details_on_panel(
updated_panel_user = await subscription_service.panel_service.update_user_details_on_panel(
user.panel_user_uuid,
payload,
log_response=False,
)
if not updated_panel_user or (
isinstance(updated_panel_user, dict) and updated_panel_user.get("error")
):
logger.warning(
"Panel identity update returned no success payload for user %s",
user.user_id,
)
return False
return True
except Exception as exc:
logger.warning(
@@ -1198,11 +1202,9 @@ def _apply_telegram_profile_to_user(
telegram_user: Dict[str, Any],
settings: Settings,
) -> None:
language_code = (
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
language_code = _normalize_language(
user.language_code or telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
)
if language_code not in {"ru", "en"}:
language_code = user.language_code or settings.DEFAULT_LANGUAGE
user.telegram_id = int(telegram_user["id"])
user.username = sanitize_username(telegram_user.get("username"))
@@ -1248,13 +1250,11 @@ async def _link_telegram_to_user(
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = (
telegram_user.get("language_code")
or current_user.language_code
language_code = _normalize_language(
current_user.language_code
or telegram_user.get("language_code")
or settings.DEFAULT_LANGUAGE
)
if language_code not in {"ru", "en"}:
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
target_user, _ = await user_dal.create_user(
session,
{
@@ -1400,20 +1400,19 @@ async def _ensure_user_from_telegram(
referral_param: Optional[str] = None,
) -> User:
user_id = int(telegram_user["id"])
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = settings.DEFAULT_LANGUAGE
telegram_language_code = _normalize_language(
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
)
update_data = {
profile_data = {
"telegram_id": user_id,
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
}
telegram_photo_url = _telegram_photo_url_value(telegram_user)
if telegram_photo_url:
update_data["telegram_photo_url"] = telegram_photo_url
profile_data["telegram_photo_url"] = telegram_photo_url
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
if not db_user:
@@ -1428,7 +1427,8 @@ async def _ensure_user_from_telegram(
session,
{
"user_id": user_id,
**update_data,
**profile_data,
"language_code": telegram_language_code,
"referred_by_id": referred_by_id,
"registration_date": datetime.now(timezone.utc),
},
@@ -1436,6 +1436,10 @@ async def _ensure_user_from_telegram(
setattr(db_user, "_webapp_created", bool(created))
return db_user
update_data = {
**profile_data,
"language_code": _normalize_language(db_user.language_code or telegram_language_code),
}
changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value}
if changed:
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
+341 -39
View File
@@ -1,6 +1,44 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.isoformat()
return str(value)
def _billing_datetime_text(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
text = str(value)
try:
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
return normalized.strftime("%d.%m.%Y %H:%M")
except Exception:
return text
def _parse_positive_int_units(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if not number.is_integer():
return None
integer = int(number)
return integer if integer > 0 else None
async def apply_promo_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
@@ -62,14 +100,20 @@ async def create_payment_route(request: web.Request) -> web.Response:
return validation_error
method = str(payment_payload.method or "").strip().lower()
settings: Settings = request.app["settings"]
subscription_service: SubscriptionService = request.app["subscription_service"]
cached = _get_cached_webapp_settings(request)
tariffs_config = settings.tariffs_config
traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None
hwid_quote: Optional[Dict[str, Any]] = None
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
if tariffs_config and requested_sale_mode in {"hwid_device", "hwid_devices"}:
if tariffs_config and requested_sale_mode in {
"hwid_device",
"hwid_devices",
"hwid_devices_renewal",
}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected")
@@ -77,33 +121,19 @@ async def create_payment_route(request: web.Request) -> web.Response:
tariff = tariffs_config.require(tariff_key)
except Exception:
return _json_error(400, "invalid_plan", "Tariff is not available")
try:
device_count = int(
float(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
)
except (TypeError, ValueError):
if tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
device_count = _parse_positive_int_units(
payment_payload.device_count
if payment_payload.device_count is not None
else payment_payload.months
)
if device_count is None:
return _json_error(400, "invalid_plan", "Invalid device package")
packages = tariff.hwid_device_packages
rub_packages = {
int(package.count): float(package.price)
for package in (packages.rub if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
price = rub_packages.get(device_count)
stars_price = stars_packages.get(device_count)
if price is None and method != "stars":
if not tariff.hwid_device_packages:
return _json_error(400, "invalid_plan", "Device package is not available")
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
return _json_error(400, "invalid_plan", "Stars price is not configured")
payment_units = device_count
sale_mode = f"hwid_devices@{tariff.key}"
sale_mode = f"{requested_sale_mode}@{tariff.key}"
elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
@@ -251,6 +281,42 @@ async def create_payment_route(request: web.Request) -> web.Response:
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
if _sale_mode_is_hwid_devices(sale_mode):
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
if not sub or not sub.tariff_key or sub.tariff_key != sale_tariff_key:
return _json_error(
400, "subscription_required", "Active tariff subscription is required"
)
try:
active_tariff = tariffs_config.require(sub.tariff_key) if tariffs_config else None
except Exception:
active_tariff = None
if not active_tariff or active_tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
currency = "stars" if method == "stars" else "rub"
hwid_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(payment_units),
tariff_key=sale_tariff_key,
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
currency=currency,
)
if not hwid_quote:
return _json_error(400, "invalid_plan", "Device package is not available")
if method == "stars":
stars_price = int(hwid_quote["price"])
price = 0.0
if stars_price <= 0:
return _json_error(400, "invalid_plan", "Stars price is not configured")
else:
price = float(hwid_quote["price"])
stars_price = None
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
return await _create_subscription_payment(
request=request,
session=session,
@@ -262,6 +328,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
lang=lang,
sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment,
is_admin=is_admin,
hwid_quote=hwid_quote,
)
@@ -311,7 +379,12 @@ async def activate_trial_route(request: web.Request) -> web.Response:
notification_service = NotificationService(
request.app["bot"], settings, i18n_instance
)
await notification_service.notify_trial_activation(user_id, end_date)
await notification_service.notify_trial_activation(
user_id,
end_date,
username=db_user.username,
email=getattr(db_user, "email", None),
)
except Exception:
logger.exception("Failed to send WebApp trial activation notification")
@@ -324,6 +397,8 @@ async def activate_trial_route(request: web.Request) -> web.Response:
await session.rollback()
logger.exception("Failed to mark WebApp trial activation for ad attribution")
await invalidate_webapp_user_caches(settings, user_id)
return web.json_response(
{
"ok": True,
@@ -448,7 +523,9 @@ async def tariff_change_options_route(request: web.Request) -> web.Response:
for tariff in config.enabled_tariffs:
if tariff.key == current.key:
continue
options = subscription_service.calculate_tariff_switch_options(sub, tariff)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, sub, tariff
)
targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang))
return web.json_response(
{
@@ -525,7 +602,9 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required"
)
target = config.require(tariff_key)
options = subscription_service.calculate_tariff_switch_options(sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, sub, target
)
price = float(options.get("paid_diff_rub") or 0)
if price <= 0:
return _json_error(
@@ -567,25 +646,231 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required"
)
tariff = config.require(sub.tariff_key)
if tariff.billing_model != "period":
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
active = await subscription_service.get_active_subscription_details(session, user_id)
plans = _serialize_hwid_device_packages(
settings,
tariff,
tariff.hwid_device_packages,
db_user.language_code or settings.DEFAULT_LANGUAGE,
)
renewal_available = bool(active and active.get("device_topup_renewal_available"))
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
extra_hwid_valid_until_text = (
active.get("extra_hwid_devices_valid_until_text") if active else None
) or _billing_datetime_text(extra_hwid_valid_until)
packages = tariff.hwid_device_packages
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(rub_counts | stars_counts):
rub_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="rub",
)
if count in rub_counts
else None
)
stars_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="stars",
)
if count in stars_counts
else None
)
if not rub_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan,
"months": count,
"device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"])
plans.append(plan)
return web.json_response(
{
"ok": True,
"tariff_key": tariff.key,
"tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE),
"tariff_name": tariff.name(lang),
"current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None,
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
if active
else int(sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
else 0,
"plans": plans,
}
)
def _yookassa_payment_payload_for_processing(payload: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(payload or {})
if not isinstance(normalized.get("amount"), dict):
amount_value = normalized.get("amount_value")
amount_currency = normalized.get("amount_currency")
if amount_value is not None or amount_currency:
normalized["amount"] = {
"value": str(amount_value if amount_value is not None else 0),
"currency": amount_currency or "RUB",
}
return normalized
def _payment_status_can_be_refreshed(payment: Payment) -> bool:
normalized = str(getattr(payment, "status", "") or "").lower()
if normalized == "succeeded":
return False
if normalized in {"failed", "canceled", "cancelled", "failed_creation"}:
return False
return normalized.startswith("pending") or normalized in {"waiting_for_capture", "created"}
async def _refresh_yookassa_payment_status(
request: web.Request,
session: AsyncSession,
payment: Payment,
) -> Payment:
if str(getattr(payment, "provider", "") or "").lower() != "yookassa":
return payment
if not _payment_status_can_be_refreshed(payment):
return payment
yookassa_payment_id = payment.yookassa_payment_id or payment.provider_payment_id
yookassa_service = request.app.get("yookassa_service")
if (
not yookassa_payment_id
or not yookassa_service
or not getattr(yookassa_service, "configured", False)
or not hasattr(yookassa_service, "get_payment_info")
):
return payment
try:
provider_payload = await yookassa_service.get_payment_info(yookassa_payment_id)
except Exception:
logger.exception("Failed to refresh YooKassa payment %s status", payment.payment_id)
return payment
if not provider_payload:
return payment
provider_payload = _yookassa_payment_payload_for_processing(provider_payload)
provider_status = str(provider_payload.get("status") or "").lower()
if provider_status == "succeeded" and provider_payload.get("paid") is True:
from bot.payment_providers.yookassa import (
payment_processing_lock,
process_successful_payment,
)
async with payment_processing_lock:
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
if not current:
return payment
if current.status == "succeeded":
return current
try:
await process_successful_payment(
session,
request.app["bot"],
provider_payload,
request.app["i18n"],
request.app["settings"],
request.app["panel_service"],
request.app["subscription_service"],
request.app["referral_service"],
request.app.get("lknpd_service"),
)
await session.commit()
except Exception:
await session.rollback()
logger.exception(
"Failed to process refreshed YooKassa payment %s",
payment.payment_id,
)
return current
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current
if provider_status in {"canceled", "cancelled"}:
from bot.payment_providers.yookassa import (
payment_processing_lock,
process_cancelled_payment,
)
async with payment_processing_lock:
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
if not current:
return payment
if not _payment_status_can_be_refreshed(current):
return current
try:
await process_cancelled_payment(
session,
request.app["bot"],
provider_payload,
request.app["i18n"],
request.app["settings"],
)
await session.commit()
except Exception:
await session.rollback()
logger.exception(
"Failed to process refreshed cancelled YooKassa payment %s",
payment.payment_id,
)
return current
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current
return payment
async def _refresh_wata_payment_status(
request: web.Request,
session: AsyncSession,
payment: Payment,
) -> Payment:
if str(getattr(payment, "provider", "") or "").lower() != "wata":
return payment
if not _payment_status_can_be_refreshed(payment):
return payment
wata_service = request.app.get("wata_service")
if (
not wata_service
or not getattr(wata_service, "configured", False)
or not hasattr(wata_service, "refresh_payment_status")
):
return payment
try:
return await wata_service.refresh_payment_status(session, payment)
except Exception:
logger.exception("Failed to refresh Wata payment %s status", payment.payment_id)
return payment
async def payment_status_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
try:
@@ -598,6 +883,10 @@ async def payment_status_route(request: web.Request) -> web.Response:
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment or payment.user_id != user_id:
return _json_error(404, "not_found", "Payment not found")
payment = await _refresh_yookassa_payment_status(request, session, payment)
payment = await _refresh_wata_payment_status(request, session, payment)
if payment.status == "succeeded":
await invalidate_webapp_user_caches(request.app["settings"], user_id)
return web.json_response(
{
"ok": True,
@@ -623,7 +912,11 @@ def _sale_mode_is_traffic(sale_mode: str) -> bool:
def _sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
return _sale_mode_base(sale_mode) in {
"hwid_device",
"hwid_devices",
"hwid_devices_renewal",
}
async def _create_subscription_payment(
@@ -638,6 +931,8 @@ async def _create_subscription_payment(
lang: str,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
sale_mode = str(sale_mode or "subscription")
@@ -655,11 +950,11 @@ async def _create_subscription_payment(
provider_spec = get_provider_spec(method)
if provider_spec and provider_spec.create_webapp_payment:
if not provider_spec.is_visible(settings, request.app):
if not provider_spec.is_visible_for_user(settings, request.app, is_admin=is_admin):
logger.warning(
"WebApp payment method unavailable: method=%s enabled=%s configured=%s",
method,
provider_spec.is_enabled(settings),
provider_spec.is_effectively_enabled(settings),
provider_spec.is_service_configured(request.app),
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
@@ -675,6 +970,13 @@ async def _create_subscription_payment(
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
)
)
+45 -20
View File
@@ -9,6 +9,24 @@ from config.settings import Settings
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
def reset_webapp_settings_cache(app: Any) -> None:
cache = app.get("webapp_settings_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
def reset_subscription_guides_cache(app: Any) -> None:
cache = app.get("subscription_guides_config_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["fingerprint"] = None
cache["status"] = None
def _payload_namespaces(include_devices: bool = False) -> tuple[str, ...]:
return ("me", "devices") if include_devices else ("me",)
def _webapp_user_payload_cache(
settings: Settings,
namespace: str,
@@ -48,9 +66,7 @@ def invalidate_local_webapp_user_payload(
user_id: int,
) -> None:
key = str(int(user_id))
for (settings_id, cache_namespace, _ttl), cache in tuple(
_WEBAPP_USER_PAYLOAD_CACHES.items()
):
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
if settings_id == id(settings) and cache_namespace == namespace:
cache.invalidate(key)
@@ -58,13 +74,20 @@ def invalidate_local_webapp_user_payload(
def invalidate_all_local_webapp_user_payloads(
settings: Settings,
namespace: Optional[str] = None,
*,
include_devices: Optional[bool] = None,
) -> None:
for (settings_id, cache_namespace, _ttl), cache in tuple(
_WEBAPP_USER_PAYLOAD_CACHES.items()
):
if include_devices is not None:
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
elif namespace is not None:
namespaces = {namespace}
else:
namespaces = None
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
if settings_id != id(settings):
continue
if namespace is not None and cache_namespace != namespace:
if namespaces is not None and cache_namespace not in namespaces:
continue
cache.invalidate()
@@ -95,21 +118,23 @@ async def invalidate_webapp_user_caches(
await cache_delete(settings, *keys)
async def invalidate_all_webapp_user_payloads(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
for namespace in _payload_namespaces(include_devices):
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern)
except Exception:
continue
async def invalidate_all_webapp_user_caches(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
namespaces = ["me"]
if include_devices:
namespaces.append("devices")
for namespace in namespaces:
invalidate_all_local_webapp_user_payloads(settings, namespace)
try:
await cache_delete_pattern(
settings,
redis_key(settings, "cache", "webapp", namespace, "*"),
)
except Exception:
continue
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
+8 -22
View File
@@ -2,7 +2,11 @@
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import (
invalidate_local_webapp_user_payload,
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
)
from bot.middlewares.i18n import (
is_valid_locale_language_code,
normalize_locale_language_code,
)
@@ -26,25 +30,7 @@ async def _invalidate_webapp_user_caches(
*user_ids: Optional[int],
include_devices: bool = False,
) -> None:
keys: List[str] = []
seen: set[int] = set()
for raw_user_id in user_ids:
if raw_user_id is None:
continue
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
continue
if user_id in seen:
continue
seen.add(user_id)
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
invalidate_local_webapp_user_payload(settings, "me", user_id)
if include_devices:
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
invalidate_local_webapp_user_payload(settings, "devices", user_id)
if keys:
await cache_delete(settings, *keys)
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
def _validation_error_response(exc: ValidationError) -> web.Response:
@@ -91,8 +77,8 @@ def _validate_model_payload(
def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower()
return value if value in {"ru", "en"} else "ru"
value = normalize_locale_language_code(lang, prefer_known_base=False)
return value if is_valid_locale_language_code(value) else "ru"
def _format_remaining(seconds: int, lang: str) -> str:
+11 -3
View File
@@ -86,8 +86,7 @@ async def _load_devices_payload(
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
"devices": [
_serialize_device(device, index)
for index, device in enumerate(devices, start=1)
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
],
},
}
@@ -194,6 +193,15 @@ def _format_device_datetime(value: Any) -> str:
return text
def _serialize_device_datetime(value: Any) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.isoformat()
return str(value)
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
hwid = str(device.get("hwid") or "").strip()
model = str(device.get("deviceModel") or "").strip()
@@ -209,7 +217,7 @@ def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
"os_version": os_version,
"platform_label": platform_label,
"user_agent": user_agent,
"created_at": device.get("createdAt"),
"created_at": _serialize_device_datetime(device.get("createdAt")),
"created_at_text": _format_device_datetime(device.get("createdAt")),
"hwid_short": _shorten_hwid_for_display(hwid),
"token": _device_hwid_token(hwid) if hwid else "",
+287
View File
@@ -0,0 +1,287 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_status,
validate_panel_subscription_guides_config,
)
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS = 30
async def warm_subscription_guides_config(app: web.Application) -> None:
try:
await _subscription_guides_status_shared(app)
except Exception as exc:
logger.warning("Failed to warm subscription guides config: %s", exc)
async def subscription_guides_route(request: web.Request) -> web.Response:
_require_user_id(request)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def public_subscription_guides_route(request: web.Request) -> web.Response:
share_token = subscription_dal.normalize_install_share_token(
request.match_info.get("share_token")
)
if not share_token:
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
subscription = await _public_subscription_payload(request, share_token)
if not subscription.get("active"):
return web.json_response(
{
"ok": False,
"enabled": False,
"config": None,
"source": None,
"subscription": subscription,
"error": "subscription_unavailable",
},
status=404,
)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
"subscription": subscription,
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def _subscription_guides_status_shared(app: web.Application) -> Dict[str, Any]:
settings: Settings = app["settings"]
cache = app.setdefault("subscription_guides_config_cache", {})
lock: asyncio.Lock = app.setdefault("subscription_guides_config_lock", asyncio.Lock())
fingerprint = _subscription_guides_settings_fingerprint(settings)
now = time.monotonic()
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
async with lock:
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
status = await _load_subscription_guides_status(app, settings)
cache["fingerprint"] = fingerprint
cache["status"] = status
cache["ts"] = time.monotonic()
return status
async def _load_subscription_guides_status(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return {"enabled": False, "config": None, "source": None, "error": None}
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
json_override_enabled = bool(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
)
if admin_json and json_override_enabled:
return subscription_guides_status(settings)
if bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)):
panel_status = await _subscription_guides_status_from_panel_config(app, settings)
if panel_status.get("enabled"):
return panel_status
return subscription_guides_status(settings)
async def _subscription_guides_status_from_panel_config(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
panel_service = _panel_service_from_app(app)
if panel_service is None:
return {
"enabled": False,
"config": None,
"source": "panel",
"error": "Panel service is unavailable",
}
try:
config_uuid = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or "").strip()
if not config_uuid:
config_uuid = await _default_panel_subscription_page_config_uuid(panel_service)
config_uuid = config_uuid or PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
detail = await panel_service.get_subscription_page_config_by_uuid(config_uuid)
if detail is None and config_uuid != PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
detail = await panel_service.get_subscription_page_config_by_uuid(
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
)
if detail is None:
raise SubscriptionGuidesConfigError(
f"Panel subscription page config {config_uuid} is unavailable"
)
config = validate_panel_subscription_guides_config(detail)
except (SubscriptionGuidesConfigError, Exception) as exc:
logger.warning("Failed to load subscription guides config from Remnawave Panel: %s", exc)
return {"enabled": False, "config": None, "source": "panel", "error": str(exc)}
return {"enabled": True, "config": config, "source": "panel", "error": None}
async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> str:
get_list = getattr(panel_service, "get_subscription_page_config_list", None)
if not callable(get_list):
return ""
payload = await get_list()
configs = (payload or {}).get("configs")
if not isinstance(configs, list):
return ""
candidates: list[Dict[str, Any]] = [item for item in configs if isinstance(item, dict)]
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid == PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
return uuid
candidates.sort(key=lambda item: int(item.get("viewPosition") or 0))
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid:
return uuid
return ""
async def _public_subscription_payload(
request: web.Request,
share_token: str,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
panel_service = _panel_service_from_app(request.app)
raw_link = ""
username = ""
resolved_short_uuid = ""
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
local_sub = await subscription_dal.get_subscription_by_install_share_token(
session,
share_token,
)
if (
local_sub
and getattr(local_sub, "panel_user_uuid", None)
and _local_subscription_is_publicly_active(local_sub)
and panel_service
):
panel_user = await panel_service.get_user_by_uuid(local_sub.panel_user_uuid)
if panel_user:
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
username = str(panel_user.get("username") or "").strip()
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
display_link, connect_url = await prepare_config_links(settings, raw_link)
return {
"active": bool(display_link),
"config_link": display_link,
"connect_url": connect_url or display_link,
"panel_short_uuid": resolved_short_uuid or None,
"install_share_token": share_token,
"username": username,
"share_url": _public_install_url(request, share_token),
}
def _panel_service_from_app(app: web.Application) -> Any:
subscription_service: Optional[SubscriptionService] = app.get("subscription_service")
panel_service = (
getattr(subscription_service, "panel_service", None) if subscription_service else None
)
return panel_service or app.get("panel_service")
def _subscription_guides_settings_fingerprint(settings: Settings) -> Tuple[Any, ...]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "")
return (
bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", "") or ""),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or ""),
hashlib.sha256(admin_json.encode("utf-8")).hexdigest(),
str(getattr(settings, "PANEL_API_URL", "") or ""),
bool(getattr(settings, "PANEL_API_KEY", "") or ""),
)
def _local_subscription_is_publicly_active(subscription: Any) -> bool:
end_date = getattr(subscription, "end_date", None)
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
return bool(
getattr(subscription, "is_active", False)
and end_date
and end_date > datetime.now(timezone.utc)
)
def _public_install_url(request: web.Request, share_token: str) -> str:
settings: Settings = request.app["settings"]
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
headers = request.headers
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
user_agent = headers.get(
"User-Agent",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
)
return {
"host": host,
"x-forwarded-host": host,
"x-forwarded-proto": proto,
"user-agent": user_agent,
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"upgrade-insecure-requests": "1",
}
+18 -1
View File
@@ -6,6 +6,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/login/password", index_route)
app.router.add_get("/home", index_route)
app.router.add_get("/install", index_route)
app.router.add_get("/trial", index_route)
app.router.add_get("/open-app", app_deeplink_route)
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route)
@@ -15,15 +19,23 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings|support}"
"appearance|settings|translations|support|backups}"
),
index_route,
)
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/admin/payments/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/admin/payments/{payment_id:\\d+}", index_route)
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route)
app.router.add_get("/favicon.ico", webapp_current_favicon_route)
app.router.add_get("/apple-touch-icon.png", webapp_current_favicon_route)
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
app.router.add_get("/icon-512.png", webapp_current_favicon_route)
app.router.add_get(WEBAPP_DEFAULT_LOGO_PATH, webapp_default_logo_route)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
@@ -60,6 +72,11 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/i18n", i18n_route)
app.router.add_get("/api/me", me_route)
app.router.add_get("/api/subscription-guides", subscription_guides_route)
app.router.add_get(
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
public_subscription_guides_route,
)
app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
+110 -10
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload
@@ -52,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if db_user.panel_user_uuid
else None
)
install_share_token = (
await subscription_dal.ensure_install_share_token(session, local_sub)
if active and local_sub
else None
)
trial_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
@@ -82,7 +88,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"language_code": lang,
"is_admin": is_admin,
},
"subscription": _serialize_subscription(settings, active, local_sub, lang),
"subscription": _serialize_subscription(
request,
settings,
active,
local_sub,
lang,
install_share_token=install_share_token,
),
"referral": {
"code": referral_code,
"bot_link": referral_link,
@@ -105,7 +118,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app, lang),
"payment_methods": _serialize_payment_methods(
settings,
request.app,
lang,
is_admin=is_admin,
),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
@@ -132,6 +150,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"subscription_purchase_description": settings.subscription_purchase_description(lang),
"subscription_guides_enabled": subscription_guides_available(settings),
"email_auth_enabled": settings.email_auth_configured,
},
}
@@ -179,11 +198,26 @@ def _build_webapp_referral_link(
def _serialize_subscription(
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
request_or_settings: Any,
settings_or_active: Any,
active_or_local_sub: Optional[Any] = None,
local_sub_or_lang: Optional[Any] = None,
lang: Optional[str] = None,
*,
install_share_token: Optional[str] = None,
) -> Dict[str, Any]:
if lang is None:
request = None
settings = request_or_settings
active = settings_or_active
local_sub = active_or_local_sub
lang = str(local_sub_or_lang or "ru")
else:
request = request_or_settings
settings = settings_or_active
active = active_or_local_sub
local_sub = local_sub_or_lang
if not active:
return {
"active": False,
@@ -192,6 +226,9 @@ def _serialize_subscription(
"days_left": 0,
"config_link": None,
"connect_url": None,
"panel_short_uuid": None,
"install_share_token": None,
"install_share_url": None,
}
end_date = active.get("end_date")
@@ -222,7 +259,8 @@ def _serialize_subscription(
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
# max_devices == 0 means unlimited — top-up is pointless in that case.
can_topup_devices = bool(
tariff.has_hwid_device_packages()
tariff.billing_model == "period"
and tariff.has_hwid_device_packages()
and _coerce_int_or_none(active.get("max_devices")) != 0
)
except Exception:
@@ -231,6 +269,23 @@ def _serialize_subscription(
can_topup_traffic = False
can_topup_devices = False
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
share_token = str(
install_share_token or getattr(local_sub, "install_share_token", "") or ""
).strip()
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until")
if extra_hwid_valid_until and extra_hwid_valid_until.tzinfo is None:
extra_hwid_valid_until = extra_hwid_valid_until.replace(tzinfo=timezone.utc)
extra_hwid_next_valid_from = active.get("extra_hwid_devices_next_valid_from")
if extra_hwid_next_valid_from and extra_hwid_next_valid_from.tzinfo is None:
extra_hwid_next_valid_from = extra_hwid_next_valid_from.replace(tzinfo=timezone.utc)
extra_hwid_count = _coerce_int_or_none(active.get("extra_hwid_devices")) or 0
device_topup_renewal_available = bool(
extra_hwid_count > 0
and extra_hwid_valid_until
and end_date
and extra_hwid_valid_until < end_date
)
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
@@ -240,6 +295,9 @@ def _serialize_subscription(
"remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"panel_short_uuid": panel_short_uuid or None,
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
"install_share_url": _build_install_share_link(request, settings, share_token),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
@@ -278,12 +336,46 @@ def _serialize_subscription(
"is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")),
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
"extra_hwid_devices": extra_hwid_count,
"extra_hwid_devices_valid_until": extra_hwid_valid_until.isoformat()
if extra_hwid_valid_until
else None,
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until.strftime("%d.%m.%Y %H:%M")
if extra_hwid_valid_until
else None,
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from.isoformat()
if extra_hwid_next_valid_from
else None,
"device_topup_renewal_available": device_topup_renewal_available,
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
def _build_install_share_link(
request: Optional[web.Request],
settings: Settings,
share_token: str,
) -> Optional[str]:
share_token = subscription_dal.normalize_install_share_token(share_token)
if not share_token or request is None:
return None
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _serialize_plans(
settings: Settings,
lang: str,
@@ -310,7 +402,9 @@ def _serialize_plans(
tariff,
tariff.hwid_device_packages,
lang,
),
)
if tariff.billing_model == "period"
else [],
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
@@ -519,6 +613,8 @@ def _serialize_tariff_change_target(
"title": "recalc_days",
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
}
)
paid_diff = float(options.get("paid_diff_rub") or 0)
@@ -540,6 +636,8 @@ def _serialize_tariff_change_target(
"title": "convert_days_to_gb",
"converted_gb": float(options.get("converted_gb") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
}
)
actions.extend(
@@ -582,6 +680,8 @@ def _serialize_payment_methods(
settings: Settings,
app: web.Application,
lang: str = "ru",
*,
is_admin: bool = False,
) -> List[Dict[str, Any]]:
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
@@ -589,7 +689,7 @@ def _serialize_payment_methods(
for method in settings.payment_methods_order:
method = method.lower()
spec = get_provider_spec(method)
if spec and spec.is_visible(settings, app):
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
-1
View File
@@ -156,7 +156,6 @@ async def admin_panel_actions_callback_handler(
panel_service=panel_service,
session=session,
)
await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data)
elif action == "view_payments":
+52 -21
View File
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
@@ -25,6 +26,44 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _user_email(user: Optional[User]) -> str:
return str(getattr(user, "email", None) or "").strip()
def _format_user_with_email(
*,
first_name: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
fallback: str = "",
) -> str:
parts = []
if first_name:
parts.append(first_name)
if username:
parts.append(f"(@{username})")
display = " ".join(parts).strip() or str(fallback or "").strip()
clean_email = str(email or "").strip()
if clean_email:
display = (
f"{display} · {clean_email}" if display and display != clean_email else clean_email
)
return hd.quote(display)
def _format_log_entry_user(log_entry: MessageLog, translate) -> str:
fallback = (
translate("system_or_unknown_user") if not log_entry.user_id else f"ID: {log_entry.user_id}"
)
return _format_user_with_email(
first_name=log_entry.telegram_first_name,
username=log_entry.telegram_username,
email=_user_email(getattr(log_entry, "author_user", None)),
fallback=fallback,
)
async def display_logs_menu(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
@@ -94,19 +133,7 @@ async def _display_formatted_logs(
log_entries_text = []
for log_entry_model in logs:
user_display_parts = []
if log_entry_model.telegram_first_name:
user_display_parts.append(log_entry_model.telegram_first_name)
if log_entry_model.telegram_username:
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
user_display = " ".join(user_display_parts).strip()
if not user_display:
user_display = (
_("system_or_unknown_user")
if not log_entry_model.user_id
else f"ID: {log_entry_model.user_id}"
)
user_display = _format_log_entry_user(log_entry_model, _)
user_id_display = (
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
@@ -270,10 +297,11 @@ async def process_user_id_for_logs_handler(
return
target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
user_display_name = _format_user_with_email(
first_name=user_model_for_logs.first_name,
username=user_model_for_logs.username,
email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
)
logs_models = await message_log_dal.get_user_message_logs(
@@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler(
await callback.answer()
return
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
user_display_name = _format_user_with_email(
first_name=user_model_for_logs.first_name,
username=user_model_for_logs.username,
email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
)
logs_models = await message_log_dal.get_user_message_logs(
@@ -392,6 +421,7 @@ async def export_logs_csv_handler(
_("admin_csv_header_user_id"),
_("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"),
_("admin_csv_header_email"),
_("admin_csv_header_event_type"),
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"),
@@ -417,6 +447,7 @@ async def export_logs_csv_handler(
log.user_id or "",
log.telegram_username or "",
log.telegram_first_name or "",
_user_email(getattr(log, "author_user", None)),
log.event_type or "",
content_clean,
"Yes" if log.is_admin_event else "No",
File diff suppressed because it is too large Load Diff
@@ -1264,8 +1264,13 @@ async def process_delete_user_confirmation_handler(
return
try:
if user_model.panel_user_uuid:
panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid)
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
session,
target_user_id,
user=user_model,
)
for panel_uuid in panel_user_uuids:
panel_deleted = await panel_service.delete_user_from_panel(panel_uuid)
if not panel_deleted:
await message.answer(
_(
+25
View File
@@ -16,7 +16,12 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu
@@ -133,10 +138,12 @@ async def process_promo_code_input(
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
db_user = await user_dal.get_user_by_id(session, user.id)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
email=getattr(db_user, "email", None) if db_user else None,
suspicious_input=code_input,
)
except Exception as e:
@@ -160,12 +167,30 @@ async def process_promo_code_input(
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
install_links = await ensure_user_install_guide_links(session, settings, user.id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
response_to_user_text = append_install_share_link_text(
response_to_user_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user.id,
)
install_share_url = None
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
)
else:
await session.commit()
+36 -2
View File
@@ -17,12 +17,16 @@ from bot.keyboards.inline.user_keyboards import (
get_language_selection_keyboard,
get_main_menu_inline_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
from bot.services.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
@@ -715,6 +719,23 @@ async def start_command_handler(
),
config_link=config_link_text,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
promo_success_text = append_install_share_link_text(
promo_success_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user_id,
)
install_share_url = None
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
@@ -726,6 +747,7 @@ async def start_command_handler(
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
),
parse_mode="HTML",
)
@@ -891,7 +913,12 @@ async def select_language_callback_handler(
try:
lang_payload = callback.data.split("_", 2)[2]
lang_code, _, return_target = lang_payload.partition(":")
raw_lang_code, _, return_target = lang_payload.partition(":")
lang_code = normalize_locale_language_code(
raw_lang_code,
set(i18n.locales_data.keys()),
prefer_known_base=True,
)
except IndexError:
await safe_answer_callback(
callback,
@@ -899,6 +926,13 @@ async def select_language_callback_handler(
show_alert=True,
)
return
if lang_code not in i18n.locales_data:
await safe_answer_callback(
callback,
"Unsupported language.",
show_alert=True,
)
return
user_id = callback.from_user.id
try:
+108 -11
View File
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -326,6 +330,7 @@ async def select_tariff_period_callback(
settings,
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer()
@@ -378,6 +383,7 @@ async def select_tariff_package_callback(
settings,
sale_mode=sale_mode,
back_callback=back_callback,
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer()
@@ -490,6 +496,7 @@ async def select_tariff_premium_package_callback(
settings,
sale_mode=f"premium_topup@{tariff.key}",
back_callback="tariff_topup:list",
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer()
@@ -518,10 +525,14 @@ async def hwid_devices_list_callback(
await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
return
tariff = config.require(active["tariff_key"])
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard(
tariff,
packages,
@@ -529,14 +540,29 @@ async def hwid_devices_list_callback(
i18n,
settings,
back_callback="main_action:my_devices",
renewal=renewal_available,
)
text_key = (
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
)
await callback.message.edit_text(
get_text(
text_key,
date=active.get("extra_hwid_devices_valid_until_text") or "",
),
reply_markup=markup,
)
await callback.message.edit_text(get_text("select_hwid_device_package"), reply_markup=markup)
await callback.answer()
@router.callback_query(F.data.startswith("hwid_devices:package:"))
@router.callback_query(F.data.startswith("hwid_devices:renewal_package:"))
async def hwid_devices_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
subscription_service: SubscriptionService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
@@ -545,8 +571,11 @@ async def hwid_devices_package_callback(
if not config or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
_, _, tariff_key, count_raw = callback.data.split(":", 3)
_, action, tariff_key, count_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
count = int(count_raw)
package = next(
(
@@ -559,16 +588,39 @@ async def hwid_devices_package_callback(
if not package:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
rub_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="rub",
)
stars_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="stars",
)
if not rub_quote and not stars_quote:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = get_payment_method_keyboard(
count,
package.price,
None,
float(rub_quote.get("price") if rub_quote else 0),
int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0
else None,
settings.DEFAULT_CURRENCY_SYMBOL,
current_lang,
i18n,
settings,
sale_mode=f"hwid_devices@{tariff.key}",
sale_mode=f"{sale_mode_base}@{tariff.key}",
back_callback="hwid_devices:list",
user_id=callback.from_user.id,
)
await callback.message.edit_text(
get_text("choose_payment_method_hwid_devices"), reply_markup=markup
@@ -646,7 +698,9 @@ async def tariff_change_select_callback(
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
rows = []
if options["mode"] == "period_to_period":
rows.append(
@@ -733,7 +787,9 @@ async def tariff_change_confirm_apply_callback(
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
if mode == "recalc_days":
action_text = f"после перехода останется {options.get('recalc_days', 0)} дн."
elif mode == "convert_days_to_gb":
@@ -839,6 +895,7 @@ async def tariff_change_pay_callback(
settings,
sale_mode=f"tariff_upgrade@{tariff_key}",
back_callback=f"tariff_change:confirm_pay:{tariff_key}:{amount_raw}",
user_id=callback.from_user.id,
)
await callback.message.edit_text("Выберите способ оплаты", reply_markup=markup)
await callback.answer()
@@ -1026,12 +1083,50 @@ async def my_subscription_command_handler(
local_sub = await subscription_dal.get_active_subscription_by_user_id(
session, event.from_user.id
)
install_links = await ensure_user_install_guide_links(
session,
settings,
event.from_user.id,
local_subscription=local_sub,
)
install_url = install_links.personal_url
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
text = append_install_share_link_text(text, get_text, install_share_url)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for user %s.",
event.from_user.id,
)
install_share_url = None
# Build rows to prepend above the base "back" markup
prepend_rows = []
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
if install_url:
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=install_url),
)
]
)
if install_share_url:
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("install_guide_share_button"),
url=install_share_url,
)
]
)
elif cfg_link_val:
prepend_rows.append(
[
InlineKeyboardButton(
@@ -1115,7 +1210,8 @@ async def my_subscription_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
if (
tariff_for_devices.hwid_device_packages
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
prepend_rows.append(
@@ -1331,7 +1427,8 @@ async def my_devices_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if (
tariff_for_devices.hwid_device_packages
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
devices_kb.append(
@@ -62,7 +62,12 @@ async def select_subscription_period_callback_handler(
from bot.payment_providers import iter_provider_specs
currency_methods_enabled = any(
spec.price_source != "stars" and spec.is_enabled(settings)
spec.price_source != "stars"
and spec.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
)
for spec in iter_provider_specs()
)
if currency_methods_enabled:
@@ -104,6 +109,7 @@ async def select_subscription_period_callback_handler(
"traffic" if traffic_mode else "subscription", callback_context
),
back_callback=subscription_options_callback(callback_context),
user_id=callback.from_user.id,
)
try:
+38 -2
View File
@@ -14,7 +14,12 @@ from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu
@@ -74,6 +79,7 @@ async def request_trial_confirmation_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -104,9 +110,23 @@ async def request_trial_confirmation_handler(
traffic_gb=traffic_display,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
db_user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_trial_activation(
user_id,
end_date_obj,
username=db_user.username if db_user else callback.from_user.username,
email=getattr(db_user, "email", None) if db_user else None,
)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
@@ -139,6 +159,7 @@ async def request_trial_confirmation_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -214,6 +235,7 @@ async def confirm_activate_trial_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -243,6 +265,13 @@ async def confirm_activate_trial_handler(
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
@@ -266,6 +295,7 @@ async def confirm_activate_trial_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -293,7 +323,13 @@ async def confirm_activate_trial_handler(
if activation_result and activation_result.get("activated") and end_date_obj:
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
db_user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_trial_activation(
user_id,
end_date_obj,
username=db_user.username if db_user else callback.from_user.username,
email=getattr(db_user, "email", None) if db_user else None,
)
try:
from db.dal import ad_dal as _ad_dal
+71 -19
View File
@@ -3,6 +3,9 @@ from typing import Any, Dict, List, Optional, Tuple
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import locale_language_options
from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
BOT_MENU_CONTEXT = "bot"
@@ -54,8 +57,9 @@ def payment_methods_back_callback(
return f"tariff:package:{tariff_key}:{value}"
if sale_base == "premium_topup" and tariff_key:
return f"tariff:premium_package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices"} and tariff_key:
return f"hwid_devices:package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package"
return f"hwid_devices:{action}:{tariff_key}:{value}"
if sale_base == "tariff_upgrade" and tariff_key:
amount = str(price) if price is not None else value
return f"tariff_change:pay:{tariff_key}:{amount}"
@@ -76,17 +80,34 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str:
return f"tariff:select:{tariff_key}{context_suffix}"
if sale_base in {"topup", "premium_topup"}:
return "tariff_topup:list"
if sale_base in {"hwid_device", "hwid_devices"}:
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return "hwid_devices:list"
return subscription_options_callback(context)
def _trial_activation_button(lang: str, i18n_instance, settings: Settings) -> InlineKeyboardButton:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
if settings.SUBSCRIPTION_MINI_APP_URL:
trial_url = subscription_mini_app_trial_url(settings) or settings.SUBSCRIPTION_MINI_APP_URL
return InlineKeyboardButton(
text=_(key="menu_activate_trial_button"),
web_app=WebAppInfo(url=trial_url),
)
return InlineKeyboardButton(
text=_(key="menu_activate_trial_button"),
callback_data="main_action:request_trial",
)
def get_main_menu_inline_keyboard(
lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED:
builder.row(_trial_activation_button(lang, i18n_instance, settings))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
@@ -129,11 +150,7 @@ def get_bot_interface_inline_keyboard(
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED:
builder.row(
InlineKeyboardButton(
text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial"
)
)
builder.row(_trial_activation_button(lang, i18n_instance, settings))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
@@ -231,14 +248,18 @@ def get_language_selection_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
builder = InlineKeyboardBuilder()
builder.button(
text=f"🇬🇧 English {'' if current_lang == 'en' else ''}",
callback_data=f"set_lang_en{callback_suffix}",
)
builder.button(
text=f"🇷🇺 Русский {'' if current_lang == 'ru' else ''}",
callback_data=f"set_lang_ru{callback_suffix}",
)
if hasattr(i18n_instance, "language_options"):
languages = i18n_instance.language_options()
else:
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
for language in languages:
lang_code = language["code"]
checked = "" if current_lang == lang_code else ""
builder.button(
text=f"{language['flag']} {language['label']}{checked}",
callback_data=f"set_lang_{lang_code}{callback_suffix}",
)
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
builder.adjust(1)
return builder.as_markup()
@@ -405,6 +426,7 @@ def get_hwid_device_packages_keyboard(
i18n_instance,
settings: Settings,
back_callback: str = "main_action:my_subscription",
renewal: bool = False,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -417,7 +439,10 @@ def get_hwid_device_packages_keyboard(
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
),
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
f"{tariff.key}:{package.count}"
),
)
)
builder.row(
@@ -436,6 +461,8 @@ def get_payment_method_keyboard(
settings: Settings,
sale_mode: str = "subscription",
back_callback: Optional[str] = None,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
@@ -454,7 +481,16 @@ def get_payment_method_keyboard(
for method in settings.payment_methods_order:
spec = get_provider_spec(method)
if not spec or not spec.callback_prefix or not spec.is_enabled(settings):
if (
not spec
or not spec.callback_prefix
or not spec.is_available_to_user(
settings,
user_id=user_id,
is_admin=is_admin,
require_configured=False,
)
):
continue
callback_data = spec.callback_data(
value=value_str,
@@ -689,13 +725,29 @@ def get_connect_and_main_keyboard(
config_link: Optional[str],
connect_button_url: Optional[str] = None,
preserve_message: bool = False,
install_share_url: Optional[str] = None,
) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
install_url = bot_install_guide_url(settings)
button_target = connect_button_url or config_link
if button_target:
if install_url:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
web_app=WebAppInfo(url=install_url),
)
)
if install_share_url:
builder.row(
InlineKeyboardButton(
text=_("install_guide_share_button"),
url=install_share_url,
)
)
elif button_target:
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
+7 -39
View File
@@ -5,20 +5,17 @@ from typing import Awaitable, Callable, Optional
from aiogram import Bot, Dispatcher
from aiogram.exceptions import TelegramNetworkError
from aiogram.types import BotCommand, MenuButtonDefault, MenuButtonWebApp, WebAppInfo
from sqlalchemy.orm import sessionmaker
from bot.app.controllers.dispatcher_controller import build_dispatcher
from bot.app.factories.build_services import build_core_services
from bot.app.web.web_server import build_and_start_web_app
from bot.handlers.admin.sync_admin import perform_sync
from bot.infra.redis import close_redis
from bot.middlewares.i18n import JsonI18n
from bot.routers import build_root_router
from bot.services.panel_api_service import PanelApiService
from bot.services.settings_override_service import load_overrides_from_db
from bot.services.locale_override_service import load_locale_overrides
from bot.utils.message_queue import init_queue_manager
from config.settings import Settings
from db.database_setup import init_db_connection
from db.database_setup import init_db, init_db_connection
TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0
@@ -156,6 +153,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
if settings.SUBSCRIPTION_MINI_APP_URL:
async def _configure_mini_app_menu() -> None:
menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE,
@@ -169,6 +167,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
logging.info("STARTUP: Mini app domain registered and default menu button restored.")
await _run_telegram_startup_step(
"registering mini app menu button",
_configure_mini_app_menu,
@@ -186,6 +185,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.")
await _run_telegram_startup_step(
"setting bot commands",
_configure_bot_commands,
@@ -200,42 +200,9 @@ async def on_startup_configured(dispatcher: Dispatcher):
except Exception:
logging.exception("STARTUP: Failed to initialize message queue manager.")
# Automatic sync on startup — runs in background so the dispatcher can
# start serving Telegram webhooks immediately even if the panel is slow.
# perform_sync is single-flight, so concurrent admin-triggered runs will
# be skipped while this one is in progress.
logging.info("STARTUP: Bot on_startup_configured completed.")
async def _background_startup_sync(
*,
panel_service: PanelApiService,
session_factory: sessionmaker,
settings: Settings,
i18n_instance: JsonI18n,
) -> None:
try:
async with session_factory() as session:
sync_result = await perform_sync(
panel_service=panel_service,
session=session,
settings=settings,
i18n_instance=i18n_instance,
)
status = sync_result.get("status")
details = sync_result.get("details", "N/A")
if status == "completed":
logging.info(f"STARTUP: Background sync completed successfully. Details: {details}")
elif status == "skipped":
logging.info(f"STARTUP: Background sync skipped: {details}")
else:
logging.warning(
f"STARTUP: Background sync finished with status '{status}'. Details: {details}"
)
except Exception:
logging.exception("STARTUP: Background sync failed.")
async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
@@ -299,9 +266,10 @@ async def run_bot(settings_param: Settings):
if local_async_session_factory is None:
logging.critical("Failed to initialize database connection and session factory. Exiting.")
return
await load_overrides_from_db(settings_param, local_async_session_factory)
await init_db(settings_param, local_async_session_factory)
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
i18n_instance = extra["i18n_instance"]
await load_locale_overrides(i18n_instance, local_async_session_factory)
# Get bot username for YooKassa default return URL if needed
actual_bot_username = "your_bot_username"
+436 -9
View File
@@ -1,7 +1,10 @@
import json
import logging
import os
from typing import Any, Awaitable, Callable, Dict, Optional
import re
import time
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
from aiogram import BaseMiddleware
from aiogram.types import Update, User
@@ -10,14 +13,306 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal
LocaleOverrides = Dict[str, Dict[str, str]]
_LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
LANGUAGE_LABELS: Dict[str, str] = {
"ru": "Русский",
"en": "English",
"de": "Deutsch",
"es": "Español",
"fr": "Français",
"pt-br": "Português (BR)",
"tr": "Türkçe",
"uk": "Українська",
}
LANGUAGE_FLAGS: Dict[str, str] = {
"ru": "🇷🇺",
"en": "🇬🇧",
"de": "🇩🇪",
"es": "🇪🇸",
"fr": "🇫🇷",
"pt-br": "🇧🇷",
"tr": "🇹🇷",
"uk": "🇺🇦",
}
DEFAULT_LANGUAGE_ORDER = ("ru", "en")
LOCALE_KEY_ALIASES: Dict[str, str] = {
"admin_apply": "wa_apply",
"admin_ads_col_status": "admin_status",
"admin_ad_label_source": "admin_ads_col_source",
"admin_back": "wa_back",
"admin_btn_refresh": "admin_refresh",
"admin_btn_save": "admin_save",
"admin_btn_saving": "admin_saving",
"admin_close": "wa_close",
"admin_copied": "wa_copied",
"admin_copy": "wa_copy",
"admin_csv_amount": "admin_amount",
"admin_csv_description": "admin_description",
"admin_csv_payment_id": "admin_id",
"admin_csv_status": "admin_status",
"admin_link_copied": "wa_link_copied",
"admin_next": "wa_next",
"admin_payment_detail_copied": "wa_copied",
"admin_payment_detail_provider": "admin_provider",
"admin_payment_detail_provider_section": "admin_provider",
"admin_payment_detail_user_section": "admin_user",
"admin_payments_col_user_id": "admin_id",
"admin_promo_col_code": "admin_promo_csv_code",
"admin_promo_col_status": "admin_status",
"admin_promo_csv_is_active": "admin_badge_active",
"admin_promo_csv_status": "admin_status",
"admin_promo_label_code": "admin_promo_csv_code",
"admin_promo_unlimited_validity": "admin_promo_unlimited",
"admin_stats_revenue_custom_range_apply": "wa_apply",
"admin_stats_revenue_tooltip_amount": "admin_amount",
"admin_stats_sync_status": "admin_status",
"admin_status_active": "admin_badge_active",
"admin_support_category": "wa_support_category",
"admin_support_category_account": "wa_support_category_account",
"admin_support_category_billing": "wa_support_category_billing",
"admin_support_category_other": "wa_support_category_other",
"admin_support_category_technical": "wa_support_category_technical",
"admin_support_close_ticket": "wa_close",
"admin_support_empty": "wa_support_empty",
"admin_support_filter_active": "wa_support_filter_active",
"admin_support_filter_all": "wa_support_filter_all",
"admin_support_internal_note": "wa_support_internal_note",
"admin_support_no_messages": "wa_support_no_messages",
"admin_support_priority": "wa_support_priority",
"admin_support_priority_high": "wa_support_priority_high",
"admin_support_priority_low": "wa_support_priority_low",
"admin_support_priority_normal": "wa_support_priority_normal",
"admin_support_priority_urgent": "wa_support_priority_urgent",
"admin_support_role_system": "wa_support_role_system",
"admin_support_role_user": "admin_user",
"admin_support_search": "admin_search",
"admin_support_status": "admin_status",
"admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin",
"admin_support_status_awaiting_user": "wa_support_status_awaiting_user",
"admin_support_status_closed": "wa_support_status_closed",
"admin_support_status_open": "wa_support_status_open",
"admin_support_status_resolved": "wa_support_status_resolved",
"admin_support_ticket_number": "wa_support_ticket_number",
"admin_support_user_context": "admin_user",
"admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages",
"admin_tariffs_stat_enabled": "admin_enabled",
"admin_user_btn_cancel": "wa_cancel",
"admin_user_history_until": "wa_until_date",
"admin_user_label_provider": "admin_provider",
"admin_user_short": "admin_user",
"admin_user_stats_total_label": "admin_total",
"back_to_autopay_method_choice_button": "back_to_main_menu_button",
"back_to_payment_methods_button": "back_to_main_menu_button",
"cancel_broadcast_button": "cancel_button",
"csv_no": "no_button",
"csv_yes": "yes_button",
"user_premium_override_status_unlimited": "user_regular_override_status_unlimited",
"user_regular_override_save": "admin_save",
"wa_devices_disconnect_title": "wa_devices_disconnect",
"wa_install_link_copied": "wa_link_copied",
"wa_link_email_modal_title": "wa_settings_link_email_action",
}
def resolve_locale_key(key: object) -> str:
value = str(key or "").strip()
seen: Set[str] = set()
while value in LOCALE_KEY_ALIASES and value not in seen:
seen.add(value)
value = LOCALE_KEY_ALIASES[value]
return value
def is_valid_locale_language_code(value: str) -> bool:
return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value))
def normalize_locale_language_code(
raw: object,
valid_languages: Optional[Set[str]] = None,
*,
prefer_known_base: bool = True,
) -> str:
value = str(raw or "").strip().lower().replace("_", "-")
if not value:
return ""
if prefer_known_base and valid_languages and value not in valid_languages:
base = value.split("-", 1)[0]
if base in valid_languages:
return base
return value
def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str:
return normalize_locale_language_code(raw, valid_languages)
def locale_language_label(code: object) -> str:
value = normalize_locale_language_code(code, prefer_known_base=False)
return LANGUAGE_LABELS.get(value, value.upper())
def locale_language_flag(code: object) -> str:
value = normalize_locale_language_code(code, prefer_known_base=False)
return LANGUAGE_FLAGS.get(value, "🏳️")
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes}
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
return [*preferred, *rest]
def locale_language_options(
codes: Iterable[object],
*,
base_languages: Iterable[object] = (),
) -> List[Dict[str, Any]]:
base_set = set(sort_locale_language_codes(base_languages))
return [
{
"code": code,
"label": locale_language_label(code),
"flag": locale_language_flag(code),
"base": code in base_set,
}
for code in sort_locale_language_codes(codes)
]
def _valid_locale_keys_by_language(
locales_data: Dict[str, Dict[str, str]],
) -> Dict[str, Set[str]]:
return {
lang: {str(key) for key in messages.keys()}
for lang, messages in locales_data.items()
if isinstance(messages, dict)
}
def normalize_locale_overrides_payload(
payload: object,
*,
valid_languages: Optional[Iterable[str]] = None,
valid_keys_by_language: Optional[Dict[str, Set[str]]] = None,
allow_extra_languages: bool = False,
key_aliases: Optional[Dict[str, str]] = None,
) -> Tuple[LocaleOverrides, Dict[str, str]]:
"""Normalize a user/admin supplied locale override JSON payload.
The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``.
For convenience, files may also wrap it as ``{"overrides": {...}}`` or
``{"locales": {...}}``.
"""
if not isinstance(payload, dict):
return {}, {"_payload": "invalid_payload"}
raw_payload = payload
for wrapper_key in ("overrides", "locales"):
wrapped = raw_payload.get(wrapper_key)
if isinstance(wrapped, dict):
raw_payload = wrapped
break
valid_lang_set = {str(lang).lower() for lang in valid_languages or []}
aliases = key_aliases or LOCALE_KEY_ALIASES
def resolve_payload_key(raw_key: str) -> str:
value = raw_key
seen: Set[str] = set()
while value in aliases and value not in seen:
seen.add(value)
value = aliases[value]
return value
all_valid_keys: Set[str] = set()
if valid_keys_by_language:
for keys in valid_keys_by_language.values():
all_valid_keys.update(str(key) for key in keys)
overrides: LocaleOverrides = {}
errors: Dict[str, str] = {}
for raw_lang, raw_messages in raw_payload.items():
lang = normalize_locale_language_code(
raw_lang,
valid_lang_set or None,
prefer_known_base=not allow_extra_languages,
)
error_key = str(raw_lang or "_language")
if not lang:
errors[error_key] = "invalid_language"
continue
if valid_lang_set and lang not in valid_lang_set:
if not allow_extra_languages:
errors[error_key] = "unknown_language"
continue
if not is_valid_locale_language_code(lang):
errors[error_key] = "invalid_language"
continue
elif allow_extra_languages and not is_valid_locale_language_code(lang):
errors[error_key] = "invalid_language"
continue
if not isinstance(raw_messages, dict):
errors[lang] = "invalid_language_bucket"
continue
lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set()
bucket: Dict[str, str] = {}
for raw_key, raw_value in raw_messages.items():
raw_key_text = str(raw_key or "").strip()
key = resolve_payload_key(raw_key_text)
item_error_key = f"{lang}.{raw_key_text or '_key'}"
if not raw_key_text or not key:
errors[item_error_key] = "invalid_key"
continue
if all_valid_keys and key not in all_valid_keys and key not in lang_keys:
errors[item_error_key] = "unknown_key"
continue
if raw_value is None:
continue
if not isinstance(raw_value, str):
errors[item_error_key] = "invalid_value"
continue
if len(raw_value) > 20000:
errors[item_error_key] = "value_too_long"
continue
if raw_key_text in aliases and key in bucket:
continue
bucket[key] = raw_value
if bucket:
overrides[lang] = dict(sorted(bucket.items()))
return dict(sorted(overrides.items())), errors
class JsonI18n:
def __init__(self, path: str, default: str = "en", domain: str = "bot"):
def __init__(
self,
path: str,
default: str = "en",
domain: str = "bot",
overrides_path: Optional[str] = None,
):
self.domain = domain
self.path = path
self.default_lang = default
self.base_locales_data: Dict[str, Dict[str, str]] = {}
self.locale_overrides: LocaleOverrides = {}
self.locales_data: Dict[str, Dict[str, str]] = {}
self._overrides_path: Optional[Path] = None
self._overrides_file_mtime_ns: Optional[int] = None
self._overrides_file_next_check = 0.0
self._overrides_file_check_interval_seconds = 1.0
self._load_locales()
if overrides_path:
self.configure_overrides_file(overrides_path)
self.reload_overrides_from_file(force=True)
logging.info(
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
)
@@ -26,13 +321,26 @@ class JsonI18n:
if not os.path.isdir(self.path):
logging.error(f"Locales path not found or not a directory: {self.path}")
return
loaded: Dict[str, Dict[str, str]] = {}
for item in os.listdir(self.path):
if item.endswith(".json"):
lang_code = item.split(".")[0]
file_path = os.path.join(self.path, item)
try:
with open(file_path, "r", encoding="utf-8") as f:
self.locales_data[lang_code] = json.load(f)
data = json.load(f)
if isinstance(data, dict):
loaded[lang_code] = {
str(key): str(value)
for key, value in data.items()
if isinstance(value, str)
}
else:
logging.error(
"Locale %s from %s is not a JSON object",
lang_code,
file_path,
)
except json.JSONDecodeError as e_json_load:
logging.error(
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
@@ -42,24 +350,143 @@ class JsonI18n:
f"Error loading locale {lang_code} from {file_path}: {e_load}",
exc_info=True,
)
self.base_locales_data = loaded
self._rebuild_effective_locales()
def _rebuild_effective_locales(self) -> None:
effective: Dict[str, Dict[str, str]] = {}
for lang, messages in self.base_locales_data.items():
merged = dict(messages)
merged.update(self.locale_overrides.get(lang, {}))
effective[lang] = merged
fallback_base = (
self.base_locales_data.get(self.default_lang)
or self.base_locales_data.get("en")
or next(iter(self.base_locales_data.values()), {})
)
for lang, messages in self.locale_overrides.items():
if lang in effective:
continue
merged = dict(fallback_base)
merged.update(messages)
effective[lang] = merged
self.locales_data = effective
def _valid_keys_by_language(self) -> Dict[str, Set[str]]:
return _valid_locale_keys_by_language(self.base_locales_data)
def language_options(self) -> List[Dict[str, Any]]:
self.reload_overrides_from_file()
return locale_language_options(
self.locales_data.keys(),
base_languages=self.base_locales_data.keys(),
)
def set_locale_overrides(self, overrides: object) -> Dict[str, str]:
normalized, errors = normalize_locale_overrides_payload(
overrides,
valid_languages=set(self.base_locales_data.keys()),
valid_keys_by_language=self._valid_keys_by_language(),
allow_extra_languages=True,
)
if errors:
logging.warning("Some locale overrides were skipped: %s", errors)
self.locale_overrides = normalized
self._rebuild_effective_locales()
return errors
def configure_overrides_file(self, path: str | Path) -> None:
self._overrides_path = Path(path)
try:
self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns
except FileNotFoundError:
self._overrides_file_mtime_ns = None
except OSError as exc:
logging.warning("Failed to stat locale overrides file %s: %s", path, exc)
self._overrides_file_mtime_ns = None
def reload_overrides_from_file(self, *, force: bool = False) -> bool:
if self._overrides_path is None:
return False
now = time.monotonic()
if not force and now < self._overrides_file_next_check:
return False
self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds
try:
stat = self._overrides_path.stat()
except FileNotFoundError:
if self._overrides_file_mtime_ns is None:
return False
self._overrides_file_mtime_ns = None
logging.info(
"Locale overrides file removed; keeping current in-memory overrides until "
"the DB fallback is reloaded"
)
return False
except OSError as exc:
logging.warning(
"Failed to stat locale overrides file %s: %s",
self._overrides_path,
exc,
)
return False
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
return False
try:
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
logging.warning(
"Failed to parse locale overrides file %s: %s",
self._overrides_path,
exc,
)
self._overrides_file_mtime_ns = stat.st_mtime_ns
return False
except OSError as exc:
logging.warning(
"Failed to read locale overrides file %s: %s",
self._overrides_path,
exc,
)
return False
self._overrides_file_mtime_ns = stat.st_mtime_ns
self.set_locale_overrides(payload)
logging.info("Locale overrides reloaded from %s", self._overrides_path)
return True
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
self.reload_overrides_from_file()
lookup_key = resolve_locale_key(key)
requested_lang_code = normalize_locale_language_code(
lang_code,
set(self.locales_data.keys()),
prefer_known_base=False,
)
requested_base_lang_code = requested_lang_code.split("-", 1)[0]
# Determine effective language with robust fallback
if lang_code and lang_code in self.locales_data:
effective_lang_code = lang_code
if requested_lang_code and requested_lang_code in self.locales_data:
effective_lang_code = requested_lang_code
elif requested_base_lang_code and requested_base_lang_code in self.locales_data:
effective_lang_code = requested_base_lang_code
elif self.default_lang in self.locales_data:
effective_lang_code = self.default_lang
elif "en" in self.locales_data:
effective_lang_code = "en"
else:
effective_lang_code = lang_code or self.default_lang
effective_lang_code = requested_lang_code or self.default_lang
lang_data = self.locales_data.get(effective_lang_code)
if lang_data is None:
# Try explicit fallback to English if available
fallback_data = self.locales_data.get("en")
if fallback_data is not None:
text = fallback_data.get(key)
text = fallback_data.get(lookup_key)
if text is not None:
try:
return text.format(**kwargs) if kwargs else text
@@ -70,11 +497,11 @@ class JsonI18n:
)
return key.format(**kwargs) if kwargs else key
text = lang_data.get(key)
text = lang_data.get(lookup_key)
if text is None:
if effective_lang_code != self.default_lang:
default_lang_data = self.locales_data.get(self.default_lang, {})
text = default_lang_data.get(key)
text = default_lang_data.get(lookup_key)
if text is None:
logging.warning(
+5 -14
View File
@@ -8,7 +8,7 @@ from aiogram.types import User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
@@ -55,22 +55,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
)
# Also update description on panel if linked
# Keep panel identity fields fresh, but do not rewrite
# description from profile changes. Remnawave may return
# description with lossy encoding in list views.
try:
panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid:
description_text = "\n".join(
[
db_user.email or "",
username_for_display(tg_user.username, with_at=False)
if sanitized_username is not None
else "",
sanitized_first_name or "",
sanitized_last_name or "",
]
).strip()
panel_payload = {
"description": description_text,
"telegramId": tg_user.id,
}
if db_user.email:
@@ -81,7 +72,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
)
except Exception as e_upd_desc:
logging.warning(
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
f"ProfileSyncMiddleware: Failed to update panel identity for user {tg_user.id}: {e_upd_desc}" # noqa: E501
)
except Exception as e:
logging.error(
@@ -22,6 +22,7 @@ from .registry import (
iter_unique_provider_routers,
manifest_field_default,
pending_statuses,
provider_admin_only_pairs,
provider_emoji_map,
provider_label_map,
provider_telegram_button_text,
@@ -53,6 +54,7 @@ __all__ = [
"pending_statuses",
"provider_telegram_button_text",
"provider_emoji_map",
"provider_admin_only_pairs",
"provider_label_map",
"provider_webhook_metadata",
"resolve_provider_presentation",
+109 -3
View File
@@ -28,6 +28,8 @@ class ProviderEnvConfig(BaseSettings):
env vars it consumes no edits in the global ``Settings`` required.
"""
ADMIN_ONLY_ENABLED: bool = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
@@ -36,6 +38,15 @@ class ProviderEnvConfig(BaseSettings):
)
def provider_runtime_enabled(config: Any, *admin_only_attrs: str) -> bool:
"""Return True when a provider should run for public or admin-only payments."""
if bool(getattr(config, "ENABLED", False)):
return True
attrs = admin_only_attrs or ("ADMIN_ONLY_ENABLED",)
return any(bool(getattr(config, attr, False)) for attr in attrs)
@dataclass(frozen=True)
class ProviderConfigBundle:
"""Functional config + presentation overrides for a single provider."""
@@ -68,6 +79,9 @@ class ProviderManifestField:
attr: Optional[str] = (
None # attribute name on the target model; defaults to key without env_prefix
)
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
i18n_subsection_key: Optional[str] = None
@dataclass(frozen=True)
@@ -99,6 +113,11 @@ class WebAppPaymentContext:
description: str
sale_mode: str
traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None
hwid_proration_ratio: Optional[float] = None
hwid_full_price: Optional[float] = None
EnabledPredicate = Callable[[Any], bool]
@@ -135,11 +154,23 @@ class PaymentProviderSpec:
config_class: Optional[Type[ProviderEnvConfig]] = None
presentation_class: Optional[Type[ProviderEnvConfig]] = None
manifest_fields: Sequence[ProviderManifestField] = ()
enabled_manifest_key: Optional[str] = None
admin_only_manifest_key: Optional[str] = None
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
admin_only_enabled: Optional[EnabledPredicate] = None
@property
def settings_key(self) -> str:
return self.id.upper()
@property
def enabled_field_key(self) -> str:
return self.enabled_manifest_key or f"{self.settings_key}_ENABLED"
@property
def admin_only_field_key(self) -> str:
return self.admin_only_manifest_key or f"{self.settings_key}_ADMIN_ONLY_ENABLED"
@property
def default_telegram_emoji(self) -> str:
return self.telegram_emoji or self.emoji
@@ -148,7 +179,7 @@ class PaymentProviderSpec:
def method_ids(self) -> tuple[str, ...]:
return (self.id, *tuple(self.aliases))
def is_enabled(self, source: Any) -> bool:
def _predicate_value(self, predicate: EnabledPredicate, source: Any) -> bool:
# If this spec carries a provider-local config_class, prefer the live
# config bundle so callers can pass plain Settings without having to
# know about provider-local env layouts.
@@ -157,8 +188,46 @@ class PaymentProviderSpec:
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bool(self.enabled(bundle.config))
return bool(self.enabled(source))
return bool(predicate(bundle.config))
return bool(predicate(source))
def is_enabled(self, source: Any) -> bool:
return self._predicate_value(self.enabled, source)
def is_admin_only_enabled(self, source: Any) -> bool:
if self.admin_only_enabled is not None:
return self._predicate_value(self.admin_only_enabled, source)
if self.config_class is not None and self.service_key:
from .registry import get_provider_bundle
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bool(getattr(bundle.config, self.admin_only_config_attr, False))
return bool(getattr(source, self.admin_only_field_key, False))
def is_effectively_enabled(self, source: Any) -> bool:
return self.is_enabled(source) or self.is_admin_only_enabled(source)
def _is_admin_user(
self,
source: Any,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> bool:
if is_admin is not None:
return bool(is_admin)
if user_id is None:
return False
try:
normalized_user_id = int(user_id)
except (TypeError, ValueError):
return False
try:
admin_ids = {int(item) for item in (getattr(source, "ADMIN_IDS", None) or [])}
except (TypeError, ValueError):
return False
return normalized_user_id in admin_ids
def is_service_configured(self, app: Any) -> bool:
if not self.requires_configured_service:
@@ -171,6 +240,43 @@ class PaymentProviderSpec:
def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app)
def is_available_to_user(
self,
source: Any,
app: Any = None,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
require_configured: bool = True,
) -> bool:
public_enabled = self.is_enabled(source)
admin_only_visible = self.is_admin_only_enabled(source) and self._is_admin_user(
source,
user_id=user_id,
is_admin=is_admin,
)
if not (public_enabled or admin_only_visible):
return False
if require_configured and app is not None and not self.is_service_configured(app):
return False
return True
def is_visible_for_user(
self,
source: Any,
app: Any,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> bool:
return self.is_available_to_user(
source,
app,
user_id=user_id,
is_admin=is_admin,
require_configured=True,
)
def load_router(self) -> Any:
return self.router
+55 -4
View File
@@ -26,6 +26,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
PaymentSuccessRequest,
@@ -37,9 +38,12 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
render_payment_link,
sale_mode_base,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
@@ -119,7 +123,7 @@ class CryptoPayService:
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.config.TOKEN)
return bool(provider_runtime_enabled(self.config) and self.config.TOKEN)
@property
def client(self):
@@ -154,13 +158,14 @@ class CryptoPayService:
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
payment_record = await payment_dal.create_payment_record(
session,
@@ -176,7 +181,17 @@ class CryptoPayService:
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio")
if hwid_quote
else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
},
)
await session.commit()
@@ -191,7 +206,7 @@ class CryptoPayService:
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if is_traffic else None,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
}
)
try:
@@ -255,6 +270,14 @@ class CryptoPayService:
referral_service: ReferralService = app["referral_service"]
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("CryptoPay webhook: payment %s not found.", payment_db_id)
return
if payment.status == "succeeded":
logging.info("CryptoPay webhook: payment %s already succeeded.", payment_db_id)
return
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -349,11 +372,29 @@ async def pay_crypto_callback_handler(
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=cryptopay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
@@ -366,6 +407,7 @@ async def pay_crypto_callback_handler(
amount=parts.price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
if invoice_url:
@@ -415,6 +457,15 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
"pricing_period_months": ctx.hwid_pricing_period_months,
"proration_ratio": ctx.hwid_proration_ratio,
"full_price": ctx.hwid_full_price,
}
if ctx.hwid_valid_from and ctx.hwid_valid_until
else None,
)
if not url:
return payment_failed()
+25 -3
View File
@@ -29,6 +29,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -48,7 +49,9 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -152,14 +155,14 @@ class FreeKassaService(HttpClientMixin):
logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled."
)
if config.ENABLED and not self.server_ip:
if provider_runtime_enabled(config) and not self.server_ip:
logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
)
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.shop_id and self.api_key)
return bool(provider_runtime_enabled(self.config) and self.shop_id and self.api_key)
@property
def shop_id(self):
@@ -380,10 +383,10 @@ class FreeKassaService(HttpClientMixin):
)
return web.Response(status=500, text="processing_error")
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
months = payment_units_for_activation(payment, sale_mode)
success_prefix: Optional[str] = None
if provider_payment_id:
@@ -451,6 +454,14 @@ async def pay_fk_callback_handler(
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
@@ -461,6 +472,16 @@ async def pay_fk_callback_handler(
logging.error("Invalid pay_fk data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=freekassa_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (
getattr(freekassa_service, "default_currency", None)
@@ -477,6 +498,7 @@ async def pay_fk_callback_handler(
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+24 -2
View File
@@ -27,6 +27,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -48,6 +49,8 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -241,7 +244,7 @@ class HeleketService(HttpClientMixin):
# ``False`` state from startup and the button would never appear.
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.merchant_id and self.api_key)
return bool(provider_runtime_enabled(self.config) and self.merchant_id and self.api_key)
@property
def base_url(self) -> str:
@@ -467,10 +470,10 @@ class HeleketService(HttpClientMixin):
)
return web.Response(status=500, text="processing_error")
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_units = payment_units_for_activation(payment, sale_mode)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
@@ -546,6 +549,14 @@ async def pay_heleket_callback_handler(
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not heleket_service or not heleket_service.configured:
logging.error("Heleket service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
@@ -556,6 +567,16 @@ async def pay_heleket_callback_handler(
logging.error("Invalid pay_heleket data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=heleket_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
payment_description = describe_payment(translator, parts)
@@ -568,6 +589,7 @@ async def pay_heleket_callback_handler(
months=parts.months,
provider="heleket",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+53 -8
View File
@@ -23,6 +23,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -44,7 +45,9 @@ from .shared import (
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
safe_callback_answer,
)
@@ -66,7 +69,9 @@ class PlategaConfig(ProviderEnvConfig):
SECRET: Optional[str] = None
PAYMENT_METHOD: int = Field(default=2)
SBP_ENABLED: bool = Field(default=False)
SBP_ADMIN_ONLY_ENABLED: bool = Field(default=False)
CRYPTO_ENABLED: bool = Field(default=False)
CRYPTO_ADMIN_ONLY_ENABLED: bool = Field(default=False)
SBP_METHOD: int = Field(default=2)
CRYPTO_METHOD: int = Field(default=13)
RETURN_URL: Optional[str] = None
@@ -161,7 +166,15 @@ class PlategaService(HttpClientMixin):
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.merchant_id and self.secret)
return bool(
provider_runtime_enabled(
self.config,
"SBP_ADMIN_ONLY_ENABLED",
"CRYPTO_ADMIN_ONLY_ENABLED",
)
and self.merchant_id
and self.secret
)
@property
def base_url(self) -> str:
@@ -291,10 +304,10 @@ class PlategaService(HttpClientMixin):
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_months = payment_units_for_activation(payment, sale_mode)
if status == "CONFIRMED":
if amount_raw is not None:
@@ -395,17 +408,23 @@ def _resolve_platega_variant(
) -> Optional[Tuple[str, int]]:
"""Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled."""
if callback_prefix == "pay_platega_crypto":
if not config.CRYPTO_ENABLED:
if not (config.CRYPTO_ENABLED or config.CRYPTO_ADMIN_ONLY_ENABLED):
return None
return "crypto", config.CRYPTO_METHOD
if callback_prefix == "pay_platega_sbp":
if not config.SBP_ENABLED:
if not (config.SBP_ENABLED or config.SBP_ADMIN_ONLY_ENABLED):
return None
return "sbp", config.sbp_method_resolved
# Legacy "pay_platega:" callback — keep working as SBP.
return "sbp", config.sbp_method_resolved
def _platega_spec_for_callback_prefix(callback_prefix: str) -> PaymentProviderSpec:
if callback_prefix == "pay_platega_crypto":
return CRYPTO_SPEC
return SBP_SPEC
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
@@ -429,6 +448,15 @@ async def pay_platega_callback_handler(
return
callback_prefix, _, _ = (callback.data or "").partition(":")
spec = _platega_spec_for_callback_prefix(callback_prefix)
if not spec.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
variant = (
_resolve_platega_variant(callback_prefix, platega_service.config)
if platega_service
@@ -449,6 +477,16 @@ async def pay_platega_callback_handler(
logging.error("Invalid pay_platega data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=platega_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -461,6 +499,7 @@ async def pay_platega_callback_handler(
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
@@ -538,11 +577,13 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
if not service or not service.configured:
return payment_unavailable()
if variant == "platega_crypto":
if not service.config.CRYPTO_ENABLED:
if not (service.config.CRYPTO_ENABLED or service.config.CRYPTO_ADMIN_ONLY_ENABLED):
return payment_unavailable()
platega_method_id = service.config.CRYPTO_METHOD
else:
if variant == "platega_sbp" and not service.config.SBP_ENABLED:
if variant == "platega_sbp" and not (
service.config.SBP_ENABLED or service.config.SBP_ADMIN_ONLY_ENABLED
):
return payment_unavailable()
platega_method_id = service.config.sbp_method_resolved
@@ -738,6 +779,8 @@ SBP_SPEC = PaymentProviderSpec(
enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "SBP_ENABLED", False)
),
admin_only_enabled=lambda config: bool(getattr(config, "SBP_ADMIN_ONLY_ENABLED", False)),
admin_only_config_attr="SBP_ADMIN_ONLY_ENABLED",
service_key="platega_service",
callback_prefix="pay_platega_sbp",
aliases=("platega",),
@@ -749,7 +792,7 @@ SBP_SPEC = PaymentProviderSpec(
config_class=PlategaConfig,
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega SBP", "CreditCard", "PLATEGA_SBP"),
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
)
CRYPTO_SPEC = PaymentProviderSpec(
@@ -767,12 +810,14 @@ CRYPTO_SPEC = PaymentProviderSpec(
enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "CRYPTO_ENABLED", False)
),
admin_only_enabled=lambda config: bool(getattr(config, "CRYPTO_ADMIN_ONLY_ENABLED", False)),
admin_only_config_attr="CRYPTO_ADMIN_ONLY_ENABLED",
service_key="platega_service",
callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega Crypto", "Bitcoin", "PLATEGA_CRYPTO"),
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+44
View File
@@ -321,8 +321,13 @@ def pending_statuses() -> List[str]:
def iter_provider_manifest_fields() -> Iterable[tuple[PaymentProviderSpec, ProviderManifestField]]:
"""Yield (spec, manifest_field) for every fragment declared on a provider SPEC."""
for spec in PAYMENT_PROVIDER_SPECS:
emitted_keys: set[str] = set()
for field in spec.manifest_fields:
emitted_keys.add(field.key)
yield spec, field
admin_only_field = provider_admin_only_manifest_field(spec)
if admin_only_field is not None and admin_only_field.key not in emitted_keys:
yield spec, admin_only_field
def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, ProviderManifestField]]:
@@ -333,6 +338,45 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide
return None
def provider_admin_only_manifest_field(
spec: PaymentProviderSpec,
) -> Optional[ProviderManifestField]:
if spec.config_class is None:
return None
subsection = spec.label
for field in spec.manifest_fields:
if field.subsection:
subsection = field.subsection
break
return ProviderManifestField(
spec.admin_only_field_key,
"bool",
"Only for admins",
(
"Shows this payment method only to users from ADMIN_IDS. "
"Webhooks and provider services remain active for admin test payments."
),
subsection=subsection,
attr=spec.admin_only_config_attr,
i18n_label_key="admin_settings_provider_admin_only_label",
i18n_description_key="admin_settings_provider_admin_only_description",
)
def provider_admin_only_pairs() -> List[tuple[str, str]]:
pairs: List[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
for spec in PAYMENT_PROVIDER_SPECS:
pair = (spec.enabled_field_key, spec.admin_only_field_key)
if pair in seen:
continue
seen.add(pair)
pairs.append(pair)
return pairs
def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]:
if spec.webhook_path and spec.webhook_route:
return spec
+31 -2
View File
@@ -25,6 +25,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -45,7 +46,9 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -135,7 +138,7 @@ class SeverPayService(HttpClientMixin):
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.mid and self.token)
return bool(provider_runtime_enabled(self.config) and self.mid and self.token)
@property
def base_url(self) -> str:
@@ -268,12 +271,19 @@ class SeverPayService(HttpClientMixin):
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
resolved_provider_id = provider_payment_id or str(payment.payment_id)
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_months = payment_units_for_activation(payment, sale_mode)
if status == "success":
if payment.status == "succeeded":
logging.info(
"SeverPay webhook: payment %s already succeeded.",
payment.payment_id,
)
return web.json_response({"status": True})
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -395,6 +405,14 @@ async def pay_severpay_callback_handler(
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
@@ -405,6 +423,16 @@ async def pay_severpay_callback_handler(
logging.error("Invalid pay_severpay data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=severpay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -417,6 +445,7 @@ async def pay_severpay_callback_handler(
months=parts.months,
provider="severpay",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
@@ -15,6 +15,7 @@ from .callbacks import (
notify_service_unavailable,
parse_payment_callback,
payment_link_message_text,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
@@ -35,10 +36,12 @@ from .common import (
json_error,
make_translator,
mark_payment_failed_creation,
parse_positive_int_units,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
@@ -55,6 +58,7 @@ from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
@@ -82,6 +86,7 @@ __all__ = [
"build_payment_description",
"build_payment_record_payload",
"build_success_message",
"append_hwid_renewal_note",
"coerce_payment_db_id",
"create_base_payment_record",
"create_webapp_payment_record",
@@ -100,6 +105,7 @@ __all__ = [
"lookup_payment_by_order_or_provider_id",
"make_translator",
"mark_payment_failed_creation",
"parse_positive_int_units",
"notify_admins_payment_received",
"notify_callback_parse_error",
"notify_payment_gateway_failure",
@@ -111,8 +117,10 @@ __all__ = [
"payment_link_message_text",
"payment_link_response",
"payment_record_amounts",
"payment_units_for_activation",
"payment_unavailable",
"post_json_request",
"quote_hwid_callback_parts",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
@@ -20,7 +20,10 @@ from .common import (
build_payment_description,
format_human_units,
mark_payment_failed_creation,
parse_positive_int_units,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_tariff_key,
)
@@ -112,6 +115,37 @@ def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str
)
async def quote_hwid_callback_parts(
*,
session: AsyncSession,
user_id: int,
parts: PaymentCallbackParts,
subscription_service,
currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None
device_count = parse_positive_int_units(parts.months)
if device_count is None:
return None, None
quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=device_count,
tariff_key=sale_mode_tariff_key(parts.sale_mode),
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
currency=currency,
)
if not quote:
return None, None
quoted_parts = PaymentCallbackParts(
months=device_count,
price=float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
def payment_link_message_text(
translator: Translator,
parts: PaymentCallbackParts,
+63 -4
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any, Callable, Optional
from aiohttp import web
@@ -36,6 +36,20 @@ def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
def parse_positive_int_units(value: Any) -> Optional[int]:
"""Return a positive integer only when the input represents whole units exactly."""
if isinstance(value, bool):
return None
try:
decimal_value = Decimal(str(value).strip())
except (InvalidOperation, ValueError):
return None
if not decimal_value.is_finite() or decimal_value != decimal_value.to_integral_value():
return None
integer_value = int(decimal_value)
return integer_value if integer_value > 0 else None
def format_human_units(value: Any) -> str:
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
numeric = float(value)
@@ -60,7 +74,7 @@ def build_payment_description(
"payment_description_traffic",
traffic_gb=human_value if human_value is not None else format_human_units(months),
)
if base in {"hwid_device", "hwid_devices"}:
if base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return translator("payment_description_hwid_devices", count=int(float(months)))
return translator("payment_description_subscription", months=int(float(months)))
@@ -75,6 +89,7 @@ def build_payment_record_payload(
months: Any,
provider: str,
sale_mode: str,
hwid_quote: Optional[dict] = None,
) -> dict:
"""Assemble the payment-record dict that every callback handler used to inline.
@@ -85,7 +100,7 @@ def build_payment_record_payload(
base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode)
return {
payload = {
"user_id": user_id,
"amount": amount,
"currency": currency,
@@ -98,6 +113,17 @@ def build_payment_record_payload(
"purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
}
if hwid_quote and is_hwid:
payload.update(
{
"hwid_valid_from": hwid_quote.get("valid_from"),
"hwid_valid_until": hwid_quote.get("valid_until"),
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
"hwid_full_price": hwid_quote.get("full_price"),
}
)
return payload
@dataclass(frozen=True)
@@ -119,7 +145,7 @@ def sale_mode_is_traffic(sale_mode: str) -> bool:
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
@@ -152,6 +178,24 @@ def payment_record_amounts(
)
def payment_units_for_activation(payment: Any, sale_mode: str) -> Any:
"""Resolve purchased units from a payment record for webhook activation."""
base = sale_mode_base(sale_mode)
if sale_mode_is_traffic(base):
return (
getattr(payment, "purchased_gb", None)
or getattr(payment, "subscription_duration_months", None)
or 1
)
if sale_mode_is_hwid_devices(base):
return (
getattr(payment, "purchased_hwid_devices", None)
or getattr(payment, "subscription_duration_months", None)
or 1
)
return getattr(payment, "subscription_duration_months", None) or 1
def json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
@@ -194,6 +238,11 @@ async def create_base_payment_record(
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
hwid_valid_from: Optional[Any] = None,
hwid_valid_until: Optional[Any] = None,
hwid_pricing_period_months: Optional[int] = None,
hwid_proration_ratio: Optional[float] = None,
hwid_full_price: Optional[float] = None,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
@@ -209,6 +258,11 @@ async def create_base_payment_record(
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
"hwid_valid_from": hwid_valid_from,
"hwid_valid_until": hwid_valid_until,
"hwid_pricing_period_months": hwid_pricing_period_months,
"hwid_proration_ratio": hwid_proration_ratio,
"hwid_full_price": hwid_full_price,
},
)
await session.commit()
@@ -241,6 +295,11 @@ async def create_webapp_payment_record(
tariff_key=amounts.tariff_key,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
hwid_valid_from=ctx.hwid_valid_from,
hwid_valid_until=ctx.hwid_valid_until,
hwid_pricing_period_months=ctx.hwid_pricing_period_months,
hwid_proration_ratio=ctx.hwid_proration_ratio,
hwid_full_price=ctx.hwid_full_price,
)
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
@@ -18,6 +19,7 @@ from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def is_traffic_sale_base(sale_base: str) -> bool:
@@ -70,7 +72,6 @@ class SuccessMessage:
months: Any
base_end_date: Optional[datetime]
final_end_date: Optional[datetime]
config_link_text: str
applied_referee_bonus_days: int = 0
applied_promo_bonus_days: int = 0
inviter_name: Optional[str] = None
@@ -97,7 +98,11 @@ def build_success_message(payload: SuccessMessage) -> str:
"payment_successful_traffic_full",
traffic_gb=format_human_units(payload.months),
end_date=end_text,
config_link=payload.config_link_text,
)
if base in _HWID_DEVICE_MODES:
return _(
"payment_successful_hwid_devices_full",
count=format_human_units(payload.months),
)
if payload.applied_referee_bonus_days and payload.final_end_date:
base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text)
@@ -108,7 +113,6 @@ def build_success_message(payload: SuccessMessage) -> str:
bonus_days=payload.applied_referee_bonus_days,
final_end_date=end_text,
inviter_name=payload.inviter_name or _("friend_placeholder"),
config_link=payload.config_link_text,
)
if payload.applied_promo_bonus_days and payload.final_end_date:
return _(
@@ -116,16 +120,36 @@ def build_success_message(payload: SuccessMessage) -> str:
months=payload.months,
bonus_days=payload.applied_promo_bonus_days,
end_date=end_text,
config_link=payload.config_link_text,
)
return _(
"payment_successful_full",
months=payload.months,
end_date=end_text,
config_link=payload.config_link_text,
)
def append_hwid_renewal_note(
text: str,
translator: Translator,
*,
count: Any,
valid_until: Optional[datetime],
) -> str:
try:
count_int = int(count or 0)
except (TypeError, ValueError):
count_int = 0
if count_int <= 0:
return text
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
note = translator(
"payment_successful_hwid_devices_renewal_note",
count=format_human_units(count_int),
date=date_text,
)
return f"{text}\n\n{note}"
async def send_success_message_to_user(
*,
bot: Bot,
@@ -136,6 +160,7 @@ async def send_success_message_to_user(
settings: Any,
config_link_display: Optional[str],
connect_button_url: Optional[str],
install_share_url: Optional[str] = None,
include_keyboard: bool = True,
log_prefix: str = "payment_providers",
) -> None:
@@ -148,6 +173,7 @@ async def send_success_message_to_user(
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
preserve_message=True,
)
try:
@@ -177,6 +203,7 @@ async def notify_admins_payment_received(
traffic_is_premium: bool,
tariff_key: Optional[str],
log_prefix: str = "payment_providers",
email: Optional[str] = None,
) -> None:
"""Push the standard ``notify_payment_received`` to the admin log channel."""
try:
@@ -189,6 +216,7 @@ async def notify_admins_payment_received(
traffic_gb=traffic_gb_for_admin,
payment_provider=payment_provider,
username=username,
email=email,
traffic_is_premium=traffic_is_premium,
tariff_key=tariff_key,
)
@@ -299,7 +327,6 @@ async def finalize_successful_payment(
config_link_display, connect_button_url = await prepare_config_links(
req.settings, raw_config_link
)
config_link_text = config_link_display or translator("config_link_not_available")
base_end_date = activation.get("end_date") if activation else None
final_end_date = base_end_date
@@ -323,15 +350,41 @@ async def finalize_successful_payment(
),
base_end_date=base_end_date,
final_end_date=final_end_date,
config_link_text=config_link_text,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name,
)
)
if is_subscription and activation:
success_text = append_hwid_renewal_note(
success_text,
translator,
count=activation.get("hwid_devices_renewal_recommended_count"),
valid_until=activation.get("hwid_devices_valid_until"),
)
if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
install_share_url = None
if not req.skip_keyboard:
install_links = await ensure_user_install_guide_links(
req.session,
req.settings,
req.user_id,
)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to persist install guide share token for user %s.",
req.log_prefix,
req.user_id,
)
install_share_url = None
await send_success_message_to_user(
bot=req.bot,
user_id=req.user_id,
@@ -341,6 +394,7 @@ async def finalize_successful_payment(
settings=req.settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix,
)
@@ -359,6 +413,7 @@ async def finalize_successful_payment(
traffic_gb_for_admin=traffic_gb_for_activation,
payment_provider=req.provider_notification,
username=db_user.username if db_user else None,
email=getattr(db_user, "email", None) if db_user else None,
traffic_is_premium=base == "premium_topup",
tariff_key=tariff_key,
log_prefix=req.log_prefix,
+38 -9
View File
@@ -35,6 +35,7 @@ from .shared import (
payment_failed,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
safe_callback_answer,
sale_mode_base,
sale_mode_tariff_key,
@@ -80,9 +81,10 @@ class StarsService:
stars_price: int,
description: str,
sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> Optional[int]:
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
@@ -93,7 +95,15 @@ class StarsService:
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -133,6 +143,14 @@ class StarsService:
i18n_data: dict,
sale_mode: str = "subscription",
) -> None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("Stars: payment %s not found.", payment_db_id)
return
if payment.status == "succeeded":
logging.info("Stars: payment %s already succeeded.", payment_db_id)
return
try:
payment_record = await payment_dal.update_provider_payment_and_status(
session,
@@ -152,9 +170,6 @@ class StarsService:
else int(message.from_user.id)
)
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("Stars: payment %s vanished after status update.", payment_db_id)
return
await finalize_successful_payment(
PaymentSuccessRequest(
@@ -197,11 +212,25 @@ async def pay_stars_callback_handler(
await notify_callback_parse_error(callback, translator)
return
if not settings.STARS_ENABLED:
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=stars_service.subscription_service,
currency="stars",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
@@ -217,6 +246,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
if payment_db_id:
@@ -440,9 +470,8 @@ SPEC = PaymentProviderSpec(
webapp_icon="Sparkles",
telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"},
pending_status="pending_stars",
# STARS_ENABLED stays on the global Settings subscription_options reads
# it together with STARS_PRICE_* fields, so it has cross-cutting bizlogic
# reach beyond just the provider flag.
# Stars toggles stay on global Settings because stars_subscription_options
# reads them together with STARS_PRICE_* fields.
enabled=lambda settings: bool(getattr(settings, "STARS_ENABLED", False)),
service_key="stars_service",
callback_prefix="pay_stars",
+625 -115
View File
@@ -2,7 +2,7 @@ import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Mapping, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
@@ -28,6 +28,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -48,13 +49,77 @@ from .shared import (
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
sale_mode_base,
)
router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata"
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
_WATA_LINK_MIN_TTL_MINUTES = 15
_WATA_LINK_MAX_TTL_MINUTES = 30 * 24 * 60
def _clamp_wata_link_ttl_minutes(value: Any, *, default: int) -> int:
if isinstance(value, str):
value = value.strip()
try:
minutes = int(value)
except (TypeError, ValueError):
return default
return min(_WATA_LINK_MAX_TTL_MINUTES, max(_WATA_LINK_MIN_TTL_MINUTES, minutes))
def _parse_wata_datetime(raw: Any) -> Optional[datetime]:
if not raw:
return None
try:
iso_value = str(raw).strip()
if iso_value.endswith("Z"):
iso_value = iso_value[:-1] + "+00:00"
parsed = datetime.fromisoformat(iso_value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
except (TypeError, ValueError):
return None
def _wata_success_status(status: int, _body: Any) -> bool:
return 200 <= status < 300
def _normalized_wata_status(payload: Optional[Mapping[str, Any]]) -> str:
if not payload:
return ""
return (
str(
payload.get("transactionStatus")
or payload.get("status")
or payload.get("statusName")
or ""
)
.strip()
.lower()
)
def _wata_transaction_id(payload: Optional[Mapping[str, Any]]) -> Optional[str]:
return first_value(payload, "transactionId", "id")
def _wata_payment_link_id(payload: Optional[Mapping[str, Any]]) -> Optional[str]:
return first_value(payload, "paymentLinkId", "payment_link_id")
class WataConfig(ProviderEnvConfig):
@@ -70,21 +135,15 @@ class WataConfig(ProviderEnvConfig):
BASE_URL: str = Field(default="https://api.wata.pro/api/h2h")
RETURN_URL: Optional[str] = None
FAILED_URL: Optional[str] = None
PAYMENT_LINK_TTL_DAYS: int = Field(default=3)
LINK_TTL_MINUTES: int = Field(default=_WATA_LINK_DEFAULT_TTL_MINUTES)
WEBHOOK_VERIFY_SIGNATURE: bool = Field(default=True)
PUBLIC_KEY: Optional[str] = None
TRUSTED_IPS: str = Field(default="62.84.126.140,51.250.106.150")
@field_validator("PAYMENT_LINK_TTL_DAYS", mode="before")
@field_validator("LINK_TTL_MINUTES", mode="before")
@classmethod
def _clamp_ttl(cls, v):
if isinstance(v, str):
v = v.strip()
try:
value = int(v)
except (TypeError, ValueError):
return 3
return min(30, max(1, value))
def _clamp_link_ttl_minutes(cls, v):
return _clamp_wata_link_ttl_minutes(v, default=_WATA_LINK_DEFAULT_TTL_MINUTES)
@field_validator("API_TOKEN", "RETURN_URL", "FAILED_URL", "PUBLIC_KEY", mode="before")
@classmethod
@@ -141,13 +200,13 @@ class WataService(HttpClientMixin):
self._default_return_url = default_return_url
self._cached_public_key_pem = None # populated by webhook on first verify
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=10)
if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.")
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.api_token)
return bool(provider_runtime_enabled(self.config) and self.api_token)
@property
def base_url(self) -> str:
@@ -166,8 +225,8 @@ class WataService(HttpClientMixin):
return self.config.FAILED_URL or self.return_url
@property
def payment_link_ttl_days(self) -> int:
return self.config.PAYMENT_LINK_TTL_DAYS
def payment_link_ttl_minutes(self) -> int:
return self.config.LINK_TTL_MINUTES
@property
def verify_webhook_signature(self) -> bool:
@@ -200,7 +259,9 @@ class WataService(HttpClientMixin):
return False, {"message": "service_not_configured"}
session = await self._get_session()
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
expires_at = (
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
).replace(microsecond=0)
body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
@@ -208,7 +269,7 @@ class WataService(HttpClientMixin):
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
"failRedirectUrl": self.failed_url,
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
"expirationDateTime": expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
return await post_json_request(
session,
@@ -216,6 +277,120 @@ class WataService(HttpClientMixin):
body=body,
headers=self._auth_headers(),
log_prefix="Wata create_payment_link",
is_success=_wata_success_status,
)
async def _get_json(
self,
url: str,
*,
params: Optional[Mapping[str, Any]] = None,
log_prefix: str,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("WataService is not configured. Cannot fetch provider state.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
try:
async with session.get(
url,
params=dict(params or {}),
headers=self._auth_headers(),
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if not _wata_success_status(response.status, response_data):
logging.error(
"%s: API returned error (status=%s, body=%s)",
log_prefix,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)}
async def get_payment_link(self, payment_link_id: str) -> Tuple[bool, Dict[str, Any]]:
return await self._get_json(
f"{self.base_url}/links/{payment_link_id}",
log_prefix="Wata get_payment_link",
)
async def try_reuse_pending_link(self, payment: Any) -> Optional[str]:
"""Return the existing payment link URL if it's still usable; else None.
Used to avoid creating duplicate Wata links each time a user re-clicks
the pay button. Repeated abandoned links inflate Wata's anti-fraud
signals and can cause downstream bank-side rejections during the
bank-selection step.
"""
if not self.configured:
return None
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
if not provider_payment_id:
return None
success, data = await self.get_payment_link(provider_payment_id)
if not success or not isinstance(data, dict):
return None
status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower()
if status and status not in _WATA_LINK_OPENED_STATUSES:
return None
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
if expiration_raw:
exp_dt = _parse_wata_datetime(expiration_raw)
if exp_dt is None:
logging.warning(
"Wata try_reuse_pending_link: unparseable expirationDateTime %r",
expiration_raw,
)
return None
if exp_dt <= datetime.now(timezone.utc):
return None
return first_value(data, "url", "paymentUrl", "payment_url")
async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]:
return await self._get_json(
f"{self.base_url}/transactions/{transaction_id}",
log_prefix="Wata get_transaction",
)
async def search_transactions(
self,
*,
order_id: Optional[str] = None,
payment_link_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 5,
) -> Tuple[bool, Dict[str, Any]]:
params: Dict[str, Any] = {
"skipCount": 0,
"maxResultCount": max(1, min(int(limit or 5), 1000)),
}
if order_id:
params["orderId"] = order_id
if payment_link_id:
params["paymentLinkId"] = payment_link_id
if status:
params["statuses"] = status
return await self._get_json(
f"{self.base_url}/transactions",
params=params,
log_prefix="Wata search_transactions",
)
async def _get_public_key_pem(self) -> Optional[str]:
@@ -256,6 +431,287 @@ class WataService(HttpClientMixin):
logging.exception("Wata webhook: signature verification failed.")
return False
def _transaction_matches_payment(
self,
payload: Mapping[str, Any],
payment: Any,
*,
provider_payment_id: Optional[str],
) -> bool:
order_id = str(payload.get("orderId") or "").strip()
if order_id and order_id == str(payment.payment_id):
return True
payment_link_id = _wata_payment_link_id(payload)
if payment_link_id and provider_payment_id and payment_link_id == provider_payment_id:
return True
transaction_id = _wata_transaction_id(payload)
if transaction_id and provider_payment_id and transaction_id == provider_payment_id:
return True
return False
async def _find_transaction_for_payment(
self,
payment: Any,
*,
status: str,
) -> Optional[Dict[str, Any]]:
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
success, response_data = await self.search_transactions(
order_id=str(payment.payment_id),
status=status,
limit=5,
)
if success:
for item in response_data.get("items") or []:
if not isinstance(item, dict):
continue
if _normalized_wata_status(item) != status.lower():
continue
if self._transaction_matches_payment(
item,
payment,
provider_payment_id=provider_payment_id or None,
):
return item
return None
async def _mark_paid_from_payload(
self,
session: AsyncSession,
payment: Any,
payload: Mapping[str, Any],
*,
log_prefix: str,
) -> Optional[Any]:
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
if current:
payment = current
if payment.status == "succeeded":
return payment
transaction_id = _wata_transaction_id(payload) or str(payment.payment_id)
amount_raw = payload.get("amount")
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if amount_raw is not None:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"%s: amount mismatch for payment %s (expected %s, got %s)",
log_prefix,
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"%s: failed to compare amounts for %s: %s",
log_prefix,
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark payment %s as succeeded.",
log_prefix,
transaction_id,
)
return None
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
payment_units = payment_units_for_activation(payment, sale_mode)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
provider_subscription="wata",
provider_notification="wata",
db_user=payment.user,
log_prefix=log_prefix,
)
)
if outcome is None:
return None
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
async def _mark_declined_from_payload(
self,
session: AsyncSession,
payment: Any,
payload: Mapping[str, Any],
*,
log_prefix: str,
notify_user: bool,
) -> Optional[Any]:
transaction_id = _wata_transaction_id(payload) or str(payment.payment_id)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark payment %s as failed.",
log_prefix,
transaction_id,
)
return None
if notify_user:
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
def _local_payment_link_ttl_expired(self, payment: Any) -> bool:
created_at = getattr(payment, "created_at", None)
if isinstance(created_at, datetime):
created_dt = (
created_at.replace(tzinfo=timezone.utc)
if created_at.tzinfo is None
else created_at.astimezone(timezone.utc)
)
else:
created_dt = _parse_wata_datetime(created_at)
if created_dt is None:
return False
expires_at = created_dt + timedelta(minutes=self.payment_link_ttl_minutes)
return expires_at <= datetime.now(timezone.utc)
async def _expired_link_payload_for_payment(self, payment: Any) -> Optional[Mapping[str, Any]]:
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
if not provider_payment_id:
return None
success, data = await self.get_payment_link(provider_payment_id)
if not success or not isinstance(data, dict):
status_code = data.get("status") if isinstance(data, dict) else None
if status_code == 404 and self._local_payment_link_ttl_expired(payment):
return {"id": provider_payment_id}
return None
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
expiration_dt = _parse_wata_datetime(expiration_raw)
if expiration_dt is None:
return None
if expiration_dt > datetime.now(timezone.utc):
return None
return data
async def _mark_expired_link(
self,
session: AsyncSession,
payment: Any,
payload: Mapping[str, Any],
*,
log_prefix: str,
) -> Optional[Any]:
provider_payment_id = (
first_value(payload, "id", "paymentLinkId", "payment_link_id")
or getattr(payment, "provider_payment_id", None)
or str(payment.payment_id)
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
"canceled",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark expired payment link %s as canceled.",
log_prefix,
provider_payment_id,
)
return None
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
async def refresh_payment_status(self, session: AsyncSession, payment: Any) -> Any:
if str(getattr(payment, "provider", "") or "").lower() != "wata":
return payment
if not self.configured:
return payment
current_status = str(getattr(payment, "status", "") or "").lower()
if current_status == "succeeded" or current_status in {
"failed",
"canceled",
"cancelled",
"failed_creation",
}:
return payment
paid_payload = await self._find_transaction_for_payment(payment, status="Paid")
if paid_payload:
refreshed = await self._mark_paid_from_payload(
session,
payment,
paid_payload,
log_prefix="Wata status refresh",
)
return refreshed or payment
declined_payload = await self._find_transaction_for_payment(payment, status="Declined")
if declined_payload:
refreshed = await self._mark_declined_from_payload(
session,
payment,
declined_payload,
log_prefix="Wata status refresh",
notify_user=False,
)
return refreshed or payment
expired_link_payload = await self._expired_link_payload_for_payment(payment)
if expired_link_payload:
refreshed = await self._mark_expired_link(
session,
payment,
expired_link_payload,
log_prefix="Wata status refresh",
)
return refreshed or payment
return payment
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="wata_disabled")
@@ -287,12 +743,11 @@ class WataService(HttpClientMixin):
return web.Response(status=400, text="bad_request")
transaction_id = str(payload.get("transactionId") or "").strip()
payment_link_id = str(payload.get("paymentLinkId") or payload.get("id") or "").strip()
status = str(payload.get("transactionStatus") or "").strip().lower()
order_id_raw = payload.get("orderId")
amount_raw = payload.get("amount")
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not status or not (transaction_id or order_id_raw):
if not status or not (transaction_id or order_id_raw or payment_link_id):
logging.error("Wata webhook: missing transaction status or ids: %s", payload)
return web.Response(status=400, text="missing_fields")
@@ -302,106 +757,63 @@ class WataService(HttpClientMixin):
order_id_raw=order_id_raw,
provider_payment_id=transaction_id or None,
)
if not payment and payment_link_id:
payment = await lookup_payment_by_order_or_provider_id(
session,
provider_payment_id=payment_link_id,
)
if not payment:
logging.error(
"Wata webhook: payment not found (order_id=%s, transaction_id=%s)",
"Wata webhook: payment not found "
"(order_id=%s, transaction_id=%s, payment_link_id=%s)",
order_id_raw,
transaction_id,
payment_link_id,
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "paid":
if payment.status == "succeeded":
return web.Response(text="ok")
resolved_transaction_id = transaction_id or str(payment.payment_id)
if status in _WATA_IN_PROGRESS_STATUSES:
if transaction_id and payment.provider_payment_id != transaction_id:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
payment.status,
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to persist transaction id %s for payment %s.",
transaction_id,
payment.payment_id,
)
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status == "paid":
if amount_raw is not None:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"Wata webhook: amount mismatch for payment %s "
"(expected %s, got %s)",
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"Wata webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_transaction_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to mark payment %s as succeeded.",
resolved_transaction_id,
)
return web.Response(status=500, text="processing_error")
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
provider_subscription="wata",
provider_notification="wata",
db_user=payment.user,
log_prefix="Wata webhook",
)
)
if outcome is None:
if not await self._mark_paid_from_payload(
session,
payment,
payload,
log_prefix="Wata webhook",
):
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status == "declined":
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_transaction_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to mark payment %s as failed.",
resolved_transaction_id,
)
if not await self._mark_declined_from_payload(
session,
payment,
payload,
log_prefix="Wata webhook",
notify_user=True,
):
return web.Response(status=500, text="processing_error")
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.Response(text="ok")
logging.warning(
@@ -409,7 +821,7 @@ class WataService(HttpClientMixin):
status,
transaction_id,
)
return web.Response(status=202, text="status_ignored")
return web.Response(text="status_ignored")
@router.callback_query(F.data.startswith("pay_wata:"))
@@ -428,6 +840,14 @@ async def pay_wata_callback_handler(
await notify_callback_parse_error(callback, translator)
return
if not SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
await notify_service_unavailable(callback, translator)
return
if not wata_service or not wata_service.configured:
logging.error("Wata service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
@@ -438,9 +858,52 @@ async def pay_wata_callback_handler(
logging.error("Invalid pay_wata data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=wata_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
months_for_lookup = (
reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="wata",
pending_status="pending_wata",
amount=parts.price,
sale_mode=parts.sale_mode,
months=months_for_lookup,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
since_minutes=wata_service.payment_link_ttl_minutes,
)
if reusable_payment is not None:
reusable_url = await wata_service.try_reuse_pending_link(reusable_payment)
if reusable_url:
await safe_callback_answer(callback)
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
@@ -450,6 +913,7 @@ async def pay_wata_callback_handler(
months=parts.months,
provider="wata",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
@@ -463,6 +927,8 @@ async def pay_wata_callback_handler(
await notify_payment_record_failure(callback, translator)
return
await safe_callback_answer(callback)
success, response_data = await wata_service.create_payment_link(
payment_db_id=payment_record.payment_id,
amount=parts.price,
@@ -478,8 +944,8 @@ async def pay_wata_callback_handler(
session=session,
payment=payment_record,
api_success=success,
payment_url=first_value(response_data, "url"),
provider_payment_id=first_value(response_data, "id"),
payment_url=first_value(response_data, "url", "paymentUrl", "payment_url"),
provider_payment_id=first_value(response_data, "id", "paymentLinkId"),
log_prefix=_LOG,
)
@@ -491,6 +957,45 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
reuse_amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
months_for_lookup = (
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
)
try:
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
ctx.session,
user_id=ctx.user_id,
provider="wata",
pending_status="pending_wata",
amount=ctx.price,
sale_mode=ctx.sale_mode,
months=months_for_lookup,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
since_minutes=service.payment_link_ttl_minutes,
)
except Exception:
logging.exception("Wata WebApp: lookup of reusable payment failed")
reusable_payment = None
if reusable_payment is not None:
try:
reusable_url = await service.try_reuse_pending_link(reusable_payment)
except Exception:
logging.exception("Wata WebApp: failed to verify reusable link")
reusable_url = None
if reusable_url:
return payment_link_response(
payment_url=reusable_url,
payment_id=reusable_payment.payment_id,
)
try:
payment = await create_webapp_payment_record(
ctx,
@@ -514,8 +1019,10 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(response_data, "url") if success else None,
provider_payment_id=first_value(response_data, "id"),
payment_url=first_value(response_data, "url", "paymentUrl", "payment_url")
if success
else None,
provider_payment_id=first_value(response_data, "id", "paymentLinkId"),
log_prefix="Wata",
)
@@ -623,14 +1130,17 @@ _CONFIG_MANIFEST = (
"WATA_FAILED_URL", "url", "Failed URL", subsection="Wata", attr="FAILED_URL"
),
ProviderManifestField(
"WATA_PAYMENT_LINK_TTL_DAYS",
"WATA_LINK_TTL_MINUTES",
"int",
"Payment link lifetime (days)",
description="1..30; Wata defaults to 3 days and allows up to 30 days.",
"Payment link lifetime (minutes)",
description=(
"15..43200; default 15 minutes. Wata requires more than 10 minutes "
"and allows up to 30 days."
),
subsection="Wata",
min=1,
max=30,
attr="PAYMENT_LINK_TTL_DAYS",
min=_WATA_LINK_MIN_TTL_MINUTES,
max=_WATA_LINK_MAX_TTL_MINUTES,
attr="LINK_TTL_MINUTES",
),
ProviderManifestField(
"WATA_WEBHOOK_VERIFY_SIGNATURE",
+200 -26
View File
@@ -36,6 +36,7 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
@@ -48,9 +49,12 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
PaymentCallbackParts,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
create_webapp_payment_record,
format_human_units,
@@ -59,10 +63,12 @@ from .shared import (
make_translator,
mark_payment_failed_creation,
notify_admins_payment_received,
parse_positive_int_units,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
resolve_inviter_name,
send_success_message_to_user,
)
@@ -139,10 +145,12 @@ class YooKassaService:
bot_username_for_default_return: Optional[str] = None,
settings_obj: Optional[Settings] = None,
config: Optional[YooKassaConfig] = None,
subscription_service: Optional[SubscriptionService] = None,
):
self.settings = settings_obj
self.config = config or YooKassaConfig()
self.subscription_service = subscription_service
self._bot_username_for_default_return = bot_username_for_default_return
self._configured_return_url_override = configured_return_url
self._sdk_configured_for = (
@@ -150,7 +158,7 @@ class YooKassaService:
)
if not self.configured:
if not self.config.ENABLED:
if not provider_runtime_enabled(self.config):
logging.warning(
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501
)
@@ -163,7 +171,9 @@ class YooKassaService:
@property
def configured(self) -> bool:
if not (self.config.ENABLED and self.config.SHOP_ID and self.config.SECRET_KEY):
if not (
provider_runtime_enabled(self.config) and self.config.SHOP_ID and self.config.SECRET_KEY
):
return False
self._ensure_sdk_configured()
return self._sdk_configured_for is not None
@@ -254,6 +264,9 @@ class YooKassaService:
if save_payment_method:
# Ask YooKassa to save method for off-session charges
builder.set_save_payment_method(True)
elif not payment_method_id:
# Keep the Smart Payment form unrestricted for one-off payments.
builder.set_save_payment_method(False)
if payment_method_id:
# Use a previously saved payment method for merchant-initiated payments
builder.set_payment_method_id(payment_method_id)
@@ -410,6 +423,55 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"77.75.154.128/25",
"2a02:5180::/32",
]
HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def _is_hwid_device_sale_base(sale_mode_base: str) -> bool:
return sale_mode_base in HWID_DEVICE_SALE_BASES
def _metadata_value_present(value: Optional[Any]) -> bool:
return value is not None and str(value).strip() != ""
def _resolve_yookassa_activation_amounts(
*,
sale_mode_base: str,
subscription_months_raw: Optional[Any],
traffic_gb_raw: Optional[Any],
hwid_devices_raw: Optional[Any],
) -> tuple[float, float, int, int, Optional[float]]:
subscription_months = float(subscription_months_raw or 0)
traffic_amount_gb = (
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
)
hwid_devices_count = 0
if _metadata_value_present(hwid_devices_raw):
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
if parsed_hwid_devices is None:
raise ValueError("Invalid HWID device count")
hwid_devices_count = parsed_hwid_devices
elif _is_hwid_device_sale_base(sale_mode_base):
parsed_hwid_devices = parse_positive_int_units(subscription_months_raw)
if parsed_hwid_devices is None:
raise ValueError("Invalid HWID device count")
hwid_devices_count = parsed_hwid_devices
if sale_mode_base == "subscription":
months_for_activation = int(subscription_months)
elif _is_hwid_device_sale_base(sale_mode_base):
months_for_activation = hwid_devices_count
else:
months_for_activation = int(traffic_amount_gb)
traffic_gb_for_activation = traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
return (
subscription_months,
traffic_amount_gb,
hwid_devices_count,
months_for_activation,
traffic_gb_for_activation,
)
async def process_successful_payment(
@@ -427,6 +489,7 @@ async def process_successful_payment(
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
hwid_devices_str = metadata.get("hwid_devices")
sale_mode = metadata.get("sale_mode") or (
"traffic" if settings.traffic_sale_mode else "subscription"
)
@@ -439,7 +502,11 @@ async def process_successful_payment(
# we will create/ensure a payment record idempotently using provider payment id.
if (
not user_id_str
or (not subscription_months_str and not traffic_gb_str)
or not (
_metadata_value_present(subscription_months_str)
or _metadata_value_present(traffic_gb_str)
or _metadata_value_present(hwid_devices_str)
)
or (not payment_db_id_str and not auto_renew_subscription_id_str)
):
logging.error(
@@ -450,8 +517,18 @@ async def process_successful_payment(
db_user = None
try:
user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
(
subscription_months,
traffic_amount_gb,
hwid_devices_count,
months_for_activation,
traffic_gb_for_activation,
) = _resolve_yookassa_activation_amounts(
sale_mode_base=sale_mode_base,
subscription_months_raw=subscription_months_str,
traffic_gb_raw=traffic_gb_str,
hwid_devices_raw=hwid_devices_str,
)
payment_db_id = (
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
)
@@ -469,6 +546,21 @@ async def process_successful_payment(
payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
logging.error(
"YooKassa HWID payment %s has invalid device count in metadata: %s",
yk_payment_id_from_hook,
metadata,
)
if payment_db_id is not None:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id,
"failed_metadata_error",
yk_payment_id_from_hook,
)
return
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
@@ -613,9 +705,6 @@ async def process_successful_payment(
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = (
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
@@ -625,12 +714,12 @@ async def process_successful_payment(
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_gb=traffic_gb_for_activation,
)
if not activation_details or not activation_details.get("end_date"):
if not activation_details or (
sale_mode_base == "subscription" and not activation_details.get("end_date")
):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
)
@@ -648,7 +737,7 @@ async def process_successful_payment(
)
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
base_subscription_end_date = activation_details["end_date"]
base_subscription_end_date = activation_details.get("end_date")
final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
@@ -683,6 +772,11 @@ async def process_successful_payment(
if not receipt_item_name:
if is_traffic_sale_base(sale_mode_base):
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
elif _is_hwid_device_sale_base(sale_mode_base):
receipt_item_name = _(
"payment_description_hwid_devices",
count=hwid_devices_count,
)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
months=int(subscription_months)
@@ -702,7 +796,6 @@ async def process_successful_payment(
config_link_display, connect_button_url = await prepare_config_links(
settings, activation_details.get("subscription_url") if activation_details else None
)
config_link_text = config_link_display or _("config_link_not_available")
# Auto-renew charges show a concise message and skip the connect keyboard, so
# they bypass the shared success-message builder.
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
@@ -712,7 +805,11 @@ async def process_successful_payment(
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
)
include_keyboard = False
elif not final_end_date_for_user and not is_traffic_sale_base(sale_mode_base):
elif (
sale_mode_base == "subscription"
and not final_end_date_for_user
and not is_traffic_sale_base(sale_mode_base)
):
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
)
@@ -729,11 +826,14 @@ async def process_successful_payment(
months=(
traffic_label
if is_traffic_sale_base(sale_mode_base)
else int(subscription_months)
else (
hwid_devices_count
if _is_hwid_device_sale_base(sale_mode_base)
else int(subscription_months)
)
),
base_end_date=base_subscription_end_date,
final_end_date=final_end_date_for_user,
config_link_text=config_link_text,
applied_referee_bonus_days=applied_referee_bonus_days_from_referral or 0,
applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name,
@@ -741,6 +841,19 @@ async def process_successful_payment(
)
)
include_keyboard = True
if sale_mode_base == "subscription" and activation_details:
details_message = append_hwid_renewal_note(
details_message,
translator,
count=activation_details.get("hwid_devices_renewal_recommended_count"),
valid_until=activation_details.get("hwid_devices_valid_until"),
)
install_share_url = None
if include_keyboard:
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
await send_success_message_to_user(
bot=bot,
user_id=user_id,
@@ -750,6 +863,7 @@ async def process_successful_payment(
settings=settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=include_keyboard,
log_prefix="YooKassa webhook",
)
@@ -773,6 +887,7 @@ async def process_successful_payment(
),
payment_provider="yookassa",
username=user_for_notify.username if user_for_notify else None,
email=getattr(user_for_notify, "email", None) if user_for_notify else None,
traffic_is_premium=sale_mode_base == "premium_topup",
tariff_key=tariff_for_log,
log_prefix="YooKassa webhook",
@@ -1191,6 +1306,7 @@ async def _initiate_yk_payment(
payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message:
@@ -1202,7 +1318,7 @@ async def _initiate_yk_payment(
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
if sale_base in HWID_DEVICE_SALE_BASES
else get_text("payment_description_subscription", months=int(months))
)
)
@@ -1218,9 +1334,14 @@ async def _initiate_yk_payment(
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
}
db_payment_record = None
@@ -1257,6 +1378,8 @@ async def _initiate_yk_payment(
}
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
yookassa_metadata["traffic_gb"] = str(months)
if sale_base in HWID_DEVICE_SALE_BASES:
yookassa_metadata["hwid_devices"] = str(months)
if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
@@ -1460,6 +1583,29 @@ async def _initiate_yk_payment(
return False
async def _yookassa_available_to_callback_user(
callback: types.CallbackQuery,
settings: Settings,
get_text,
) -> bool:
if SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
):
return True
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
if callback.message:
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return False
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(
callback: types.CallbackQuery,
@@ -1479,6 +1625,9 @@ async def pay_yk_callback_handler(
pass
return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
@@ -1509,6 +1658,23 @@ async def pay_yk_callback_handler(
return
months, price_rub, sale_mode = parsed
hwid_quote = None
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency="rub",
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
@@ -1585,6 +1751,7 @@ async def pay_yk_callback_handler(
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
sale_mode=sale_mode,
hwid_quote=hwid_quote,
)
try:
await callback.answer()
@@ -1611,6 +1778,9 @@ async def pay_yk_new_card_handler(
pass
return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.")
try:
@@ -1695,6 +1865,9 @@ async def pay_yk_saved_list_handler(
pass
return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
@@ -1855,6 +2028,9 @@ async def pay_yk_use_saved_handler(
pass
return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try:
@@ -2513,6 +2689,7 @@ def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
bot_username_for_default_return=ctx.bot_username_for_default_return,
settings_obj=ctx.settings,
config=config,
subscription_service=ctx.subscription_service,
)
@@ -2557,10 +2734,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
metadata=metadata,
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
save_payment_method=bool(
service.config.autopayments_active
and service.config.AUTOPAYMENTS_REQUIRE_CARD_BINDING
),
save_payment_method=False,
)
payment_url = response.get("confirmation_url") if response else None
if not payment_url:
@@ -2700,8 +2874,8 @@ SPEC = PaymentProviderSpec(
id="yookassa",
provider_key="yookassa",
label="YooKassa",
webapp_label="Банковская карта",
webapp_labels={"ru": "Банковская карта", "en": "Bank card"},
webapp_label="ЮKassa",
webapp_labels={"ru": "ЮKassa", "en": "YooKassa"},
webapp_icon="CreditCard",
telegram_labels={"ru": "ЮKassa", "en": "YooKassa"},
telegram_emoji="💳",
+73
View File
@@ -0,0 +1,73 @@
import hashlib
import json
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
BACKUP_APP_ID = "remnawave-minishop"
BACKUP_FILENAME_PREFIX = "minishop-"
BACKUP_FORMAT_VERSION = 1
BACKUP_MANIFEST_NAME = "manifest.json"
def backup_filename_timestamp() -> str:
return datetime.now().astimezone().strftime("%Y%m%d-%H-%M")
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def build_file_records(source_dir: Path) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for path in sorted(source_dir.rglob("*")):
if not path.is_file():
continue
relative = path.relative_to(source_dir).as_posix()
if relative == BACKUP_MANIFEST_NAME:
continue
stat = path.stat()
records.append(
{
"path": relative,
"size_bytes": int(stat.st_size),
"sha256": file_sha256(path),
}
)
return records
def attach_archive_integrity(
manifest: dict[str, Any],
*,
file_records: list[dict[str, Any]],
) -> None:
manifest["app"] = BACKUP_APP_ID
manifest["format_version"] = BACKUP_FORMAT_VERSION
manifest["archive"] = {
"files": file_records,
}
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
(source_dir / BACKUP_MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def write_zip_from_directory(source_dir: Path, archive_path: Path) -> None:
with zipfile.ZipFile(
archive_path,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=6,
) as archive:
for path in sorted(source_dir.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(source_dir).as_posix())
@@ -0,0 +1,642 @@
import asyncio
import hashlib
import hmac
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import zipfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Optional
from bot.services.backup_archive import (
BACKUP_APP_ID,
BACKUP_FILENAME_PREFIX,
BACKUP_FORMAT_VERSION,
BACKUP_MANIFEST_NAME,
attach_archive_integrity,
backup_filename_timestamp,
build_file_records,
write_manifest,
write_zip_from_directory,
)
from bot.services.backup_worker import (
DEFAULT_COMPOSE_EXCLUDED_DIRS,
)
from config.settings import Settings
logger = logging.getLogger(__name__)
BACKUP_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024
BACKUP_MAX_MEMBERS = 20_000
BACKUP_MAX_MEMBER_BYTES = 4 * 1024 * 1024 * 1024
BACKUP_MAX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024
BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024
BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024
BACKUP_MAX_COMPRESSION_RATIO = 200
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$")
class BackupArchiveError(ValueError):
"""The selected archive cannot be used for restore."""
class BackupRestoreError(RuntimeError):
"""Restore command failed after archive validation."""
@dataclass
class BackupArchiveInfo:
name: str
path: Path
size_bytes: int
modified_at: datetime
created_at: Optional[str] = None
created_at_local: Optional[str] = None
has_database: bool = False
has_compose: bool = False
database_name: Optional[str] = None
compose_files_count: int = 0
warnings: list[str] = field(default_factory=list)
manifest: dict[str, Any] = field(default_factory=dict)
def to_payload(self) -> dict[str, Any]:
return {
"name": self.name,
"size_bytes": self.size_bytes,
"modified_at": self.modified_at.isoformat(),
"created_at": self.created_at,
"created_at_local": self.created_at_local,
"has_database": self.has_database,
"has_compose": self.has_compose,
"database_name": self.database_name,
"compose_files_count": self.compose_files_count,
"warnings": self.warnings,
"manifest": self.manifest,
}
@dataclass
class BackupRestoreResult:
archive_name: str
started_at: datetime
completed_at: datetime
database_restored: bool = False
compose_files_restored: int = 0
compose_target_dir: Optional[str] = None
compose_pre_restore_archive: Optional[str] = None
warnings: list[str] = field(default_factory=list)
def to_payload(self) -> dict[str, Any]:
return {
"archive_name": self.archive_name,
"started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat(),
"database_restored": self.database_restored,
"compose_files_restored": self.compose_files_restored,
"compose_target_dir": self.compose_target_dir,
"compose_pre_restore_archive": self.compose_pre_restore_archive,
"warnings": self.warnings,
}
class BackupRestoreService:
def __init__(self, settings: Settings):
self.settings = settings
def backup_dir(self) -> Path:
path = Path(self.settings.BACKUP_DIR).expanduser()
path.mkdir(parents=True, exist_ok=True)
return path
def list_archives(self) -> list[BackupArchiveInfo]:
backup_dir = self.backup_dir()
archives = []
for path in backup_dir.glob("*.zip"):
if not path.is_file():
continue
try:
archives.append(self.inspect_archive(path))
except BackupArchiveError as exc:
logger.warning("Skipping invalid backup archive %s: %s", path, exc)
return sorted(archives, key=lambda item: item.modified_at, reverse=True)
def archive_path_for_name(self, archive_name: str) -> Path:
raw_name = str(archive_name or "").strip()
safe_name = Path(raw_name).name
if not raw_name or safe_name != raw_name or not SAFE_ARCHIVE_NAME_RE.fullmatch(safe_name):
raise BackupArchiveError("Invalid archive name")
backup_dir = self.backup_dir().resolve()
archive_path = (backup_dir / safe_name).resolve()
try:
archive_path.relative_to(backup_dir)
except ValueError as exc:
raise BackupArchiveError("Archive path escapes backup directory") from exc
if not archive_path.is_file():
raise BackupArchiveError("Archive does not exist")
return archive_path
def inspect_archive(self, archive_path: Path) -> BackupArchiveInfo:
if not zipfile.is_zipfile(archive_path):
raise BackupArchiveError("Archive is not a valid ZIP file")
stat = archive_path.stat()
warnings: list[str] = []
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist())
manifest = self._read_manifest(archive)
has_database = self._find_database_dump_member(archive) is not None
compose_members = self._compose_file_members(archive)
manifest_warnings = manifest.get("warnings")
if isinstance(manifest_warnings, list):
warnings.extend(str(item) for item in manifest_warnings if item)
postgres = manifest.get("postgres") if isinstance(manifest.get("postgres"), dict) else {}
compose = manifest.get("compose") if isinstance(manifest.get("compose"), dict) else {}
return BackupArchiveInfo(
name=archive_path.name,
path=archive_path,
size_bytes=int(stat.st_size),
modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
created_at=str(manifest.get("created_at") or "") or None,
created_at_local=str(manifest.get("created_at_local") or "") or None,
has_database=has_database,
has_compose=bool(compose_members),
database_name=str(postgres.get("database") or "") or None,
compose_files_count=int(compose.get("files_count") or len(compose_members)),
warnings=warnings,
manifest=manifest,
)
def import_uploaded_archive(
self,
temp_path: Path,
original_filename: str = "",
) -> BackupArchiveInfo:
self._validate_archive_for_restore(temp_path)
digest = self._file_digest(temp_path)
stamp = backup_filename_timestamp()
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip"
target_path = self._unique_archive_path(archive_name)
temp_path.replace(target_path)
return self.inspect_archive(target_path)
async def restore_archive(
self,
archive_name: str,
*,
restore_database: bool,
restore_compose: bool,
) -> BackupRestoreResult:
return await asyncio.to_thread(
self.restore_archive_sync,
archive_name,
restore_database=restore_database,
restore_compose=restore_compose,
)
def restore_archive_sync(
self,
archive_name: str,
*,
restore_database: bool,
restore_compose: bool,
) -> BackupRestoreResult:
if not restore_database and not restore_compose:
raise BackupArchiveError("Select at least one restore target")
archive_path = self.archive_path_for_name(archive_name)
self._validate_archive_for_restore(archive_path)
started_at = datetime.now(timezone.utc)
warnings: list[str] = []
with tempfile.TemporaryDirectory(
prefix=f"restore-{archive_path.stem}-",
dir=self.backup_dir(),
) as tmp:
temp_dir = Path(tmp)
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist())
db_member = self._find_database_dump_member(archive) if restore_database else None
compose_members = self._compose_file_members(archive) if restore_compose else []
if restore_database and db_member is None:
raise BackupArchiveError("Archive does not contain a database dump")
if restore_compose and not compose_members:
raise BackupArchiveError("Archive does not contain compose files")
compose_target_dir: Optional[Path] = None
compose_pre_restore_archive: Optional[Path] = None
if restore_compose:
compose_target_dir = self._compose_restore_target_dir()
self._assert_compose_target_writable(compose_target_dir)
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
database_restored = False
if db_member is not None:
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
self._run_pg_restore(dump_path)
database_restored = True
compose_files_restored = 0
if compose_target_dir is not None:
compose_files_restored = self._restore_compose_members(
archive,
compose_members,
compose_target_dir,
)
return BackupRestoreResult(
archive_name=archive_path.name,
started_at=started_at,
completed_at=datetime.now(timezone.utc),
database_restored=database_restored,
compose_files_restored=compose_files_restored,
compose_target_dir=str(compose_target_dir) if compose_target_dir else None,
compose_pre_restore_archive=str(compose_pre_restore_archive)
if compose_pre_restore_archive
else None,
warnings=warnings,
)
def _run_pg_restore(self, dump_path: Path) -> None:
pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
pg_restore_path = pg_restore_path or "pg_restore"
if shutil.which(pg_restore_path) is None and Path(pg_restore_path).name == pg_restore_path:
raise BackupRestoreError(
"pg_restore executable was not found. Rebuild the backend image with "
"PostgreSQL client tools."
)
env = os.environ.copy()
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
command = [
pg_restore_path,
"-h",
self.settings.POSTGRES_HOST,
"-p",
str(self.settings.POSTGRES_PORT),
"-U",
self.settings.POSTGRES_USER,
"-d",
self.settings.POSTGRES_DB,
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
str(dump_path),
]
timeout = max(
30,
int(
getattr(
self.settings,
"BACKUP_PG_RESTORE_TIMEOUT_SECONDS",
self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS,
)
or 1800
),
)
result = subprocess.run(
command,
check=False,
capture_output=True,
env=env,
text=True,
timeout=timeout,
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
raise BackupRestoreError(
f"pg_restore failed with exit code {result.returncode}: {stderr[:500]}"
)
def _compose_restore_target_dir(self) -> Path:
target_raw = (
getattr(self.settings, "BACKUP_COMPOSE_RESTORE_DIR", None)
or self.settings.BACKUP_COMPOSE_SOURCE_DIR
or ""
)
if not str(target_raw).strip():
raise BackupArchiveError("Compose restore directory is not configured")
return Path(str(target_raw)).expanduser()
def _assert_compose_target_writable(self, target_dir: Path) -> None:
if not target_dir.exists() or not target_dir.is_dir():
raise BackupArchiveError(
f"Compose restore directory is unavailable: {target_dir}. "
"Mount the compose folder into the backend container."
)
probe = target_dir / f".restore-write-test-{os.getpid()}"
try:
probe.write_text("", encoding="utf-8")
probe.unlink()
except OSError as exc:
raise BackupArchiveError(
f"Compose restore directory is not writable: {target_dir}"
) from exc
def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]:
stamp = backup_filename_timestamp()
archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip")
excluded_dirs = self._compose_excluded_dirs()
files_count = 0
with tempfile.TemporaryDirectory(
prefix=f"{archive_path.stem}-",
dir=self.backup_dir(),
) as tmp:
staging_dir = Path(tmp)
compose_dir = staging_dir / "compose"
for path in sorted(target_dir.rglob("*")):
relative = path.relative_to(target_dir)
if any(part in excluded_dirs for part in relative.parts):
continue
if path.is_dir() or path.is_symlink():
continue
destination = compose_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, destination)
files_count += 1
if files_count <= 0:
return None
completed_at = datetime.now(timezone.utc)
manifest = {
"app": BACKUP_APP_ID,
"format_version": BACKUP_FORMAT_VERSION,
"type": "compose-pre-restore",
"created_at": completed_at.isoformat(),
"created_at_local": completed_at.astimezone().isoformat(),
"postgres": {
"database": self.settings.POSTGRES_DB,
"included": False,
},
"compose": {
"source_dir": str(target_dir),
"included": True,
"files_count": files_count,
},
"warnings": [],
}
attach_archive_integrity(
manifest,
file_records=build_file_records(staging_dir),
)
write_manifest(staging_dir, manifest)
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
try:
write_zip_from_directory(staging_dir, tmp_archive)
tmp_archive.replace(archive_path)
finally:
if tmp_archive.exists():
try:
tmp_archive.unlink()
except OSError:
logger.warning("Failed to remove temporary snapshot %s", tmp_archive)
return archive_path
def _restore_compose_members(
self,
archive: zipfile.ZipFile,
members: list[zipfile.ZipInfo],
target_dir: Path,
) -> int:
target_root = target_dir.resolve()
restored = 0
for member in members:
relative = PurePosixPath(member.filename).relative_to("compose")
destination = target_root.joinpath(*relative.parts).resolve()
try:
destination.relative_to(target_root)
except ValueError as exc:
raise BackupArchiveError(
f"Unsafe compose archive member: {member.filename}"
) from exc
destination.parent.mkdir(parents=True, exist_ok=True)
temp_destination = destination.with_name(
f".{destination.name}.restore-{os.getpid()}.tmp"
)
try:
with archive.open(member) as source, temp_destination.open("wb") as target:
shutil.copyfileobj(source, target)
temp_destination.replace(destination)
finally:
if temp_destination.exists():
try:
temp_destination.unlink()
except OSError:
logger.warning(
"Failed to remove temporary restore file %s",
temp_destination,
)
restored += 1
return restored
def _extract_database_dump(
self,
archive: zipfile.ZipFile,
member: zipfile.ZipInfo,
temp_dir: Path,
) -> Path:
dump_dir = temp_dir / "database"
dump_dir.mkdir(parents=True, exist_ok=True)
dump_path = dump_dir / Path(member.filename).name
with archive.open(member) as source, dump_path.open("wb") as target:
shutil.copyfileobj(source, target)
return dump_path
def _find_database_dump_member(self, archive: zipfile.ZipFile) -> Optional[zipfile.ZipInfo]:
candidates = [
item
for item in archive.infolist()
if not item.is_dir()
and item.filename.startswith("database/")
and PurePosixPath(item.filename).suffix.lower() in {".dump", ".backup"}
]
return sorted(candidates, key=lambda item: item.filename)[0] if candidates else None
def _compose_file_members(self, archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
members = [
item
for item in archive.infolist()
if not item.is_dir() and item.filename.startswith("compose/")
]
self._validate_compose_members(members)
return members
def _validate_zip_members(self, members: list[zipfile.ZipInfo]) -> None:
if len(members) > BACKUP_MAX_MEMBERS:
raise BackupArchiveError("Archive contains too many files")
seen: set[str] = set()
total_size = 0
for member in members:
filename = member.filename
if "\\" in filename or "\x00" in filename:
raise BackupArchiveError(f"Unsafe archive member path: {filename}")
path = PurePosixPath(member.filename)
if (
not path.parts
or path.is_absolute()
or ".." in path.parts
or any(part in {"", "."} for part in path.parts)
):
raise BackupArchiveError(f"Unsafe archive member path: {member.filename}")
if member.is_dir():
continue
if filename in seen:
raise BackupArchiveError(f"Duplicate archive member path: {filename}")
seen.add(filename)
if member.file_size > BACKUP_MAX_MEMBER_BYTES:
raise BackupArchiveError(f"Archive member is too large: {filename}")
total_size += int(member.file_size)
if total_size > BACKUP_MAX_UNCOMPRESSED_BYTES:
raise BackupArchiveError("Archive uncompressed size is too large")
compressed = max(1, int(member.compress_size or 1))
ratio = int(member.file_size) / compressed
if (
member.file_size >= BACKUP_ZIP_BOMB_MIN_BYTES
and ratio > BACKUP_MAX_COMPRESSION_RATIO
):
raise BackupArchiveError(
f"Archive member compression ratio is too high: {filename}"
)
def _validate_compose_members(self, members: list[zipfile.ZipInfo]) -> None:
total_size = 0
for member in members:
if member.file_size > BACKUP_MAX_COMPOSE_MEMBER_BYTES:
raise BackupArchiveError(f"Compose archive member is too large: {member.filename}")
total_size += int(member.file_size)
if total_size > BACKUP_MAX_COMPOSE_BYTES:
raise BackupArchiveError("Compose archive contents are too large")
def _read_manifest(self, archive: zipfile.ZipFile) -> dict[str, Any]:
if BACKUP_MANIFEST_NAME not in archive.namelist():
raise BackupArchiveError("Archive does not contain manifest.json")
try:
manifest = json.loads(archive.read(BACKUP_MANIFEST_NAME).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise BackupArchiveError("manifest.json is not valid JSON") from exc
if not isinstance(manifest, dict):
raise BackupArchiveError("manifest.json must contain an object")
if manifest.get("app") != BACKUP_APP_ID:
raise BackupArchiveError("Archive manifest belongs to another application")
try:
format_version = int(manifest.get("format_version") or 0)
except (TypeError, ValueError) as exc:
raise BackupArchiveError("Archive manifest format is not supported") from exc
if format_version != BACKUP_FORMAT_VERSION:
raise BackupArchiveError("Archive manifest format is not supported")
return manifest
def _validate_archive_for_restore(self, archive_path: Path) -> None:
if not zipfile.is_zipfile(archive_path):
raise BackupArchiveError("Archive is not a valid ZIP file")
with zipfile.ZipFile(archive_path) as archive:
self._validate_zip_members(archive.infolist())
manifest = self._read_manifest(archive)
self._validate_archive_integrity(archive, manifest)
def _validate_archive_integrity(
self,
archive: zipfile.ZipFile,
manifest: dict[str, Any],
) -> None:
archive_manifest = (
manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
)
file_records = archive_manifest.get("files")
if not isinstance(file_records, list):
raise BackupArchiveError("Archive manifest does not contain file checksums")
expected: dict[str, dict[str, Any]] = {}
for record in file_records:
if not isinstance(record, dict):
raise BackupArchiveError("Archive manifest contains invalid file record")
filename = str(record.get("path") or "")
if not filename:
raise BackupArchiveError("Archive manifest contains empty file path")
if filename in expected:
raise BackupArchiveError(
f"Archive manifest contains duplicate file path: {filename}"
)
expected[filename] = record
actual = {
item.filename
for item in archive.infolist()
if not item.is_dir() and item.filename != BACKUP_MANIFEST_NAME
}
if actual != set(expected):
raise BackupArchiveError("Archive contents do not match manifest")
for info in archive.infolist():
if info.is_dir() or info.filename == BACKUP_MANIFEST_NAME:
continue
record = expected[info.filename]
try:
expected_size = int(record.get("size_bytes") or -1)
except (TypeError, ValueError) as exc:
raise BackupArchiveError(
f"Archive manifest size is invalid: {info.filename}"
) from exc
expected_hash = str(record.get("sha256") or "")
if expected_size != int(info.file_size):
raise BackupArchiveError(
f"Archive member size does not match manifest: {info.filename}"
)
if not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
raise BackupArchiveError(f"Archive manifest checksum is invalid: {info.filename}")
digest = hashlib.sha256()
with archive.open(info) as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
if not hmac.compare_digest(digest.hexdigest(), expected_hash):
raise BackupArchiveError(
f"Archive member checksum does not match manifest: {info.filename}"
)
def _compose_excluded_dirs(self) -> set[str]:
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
@staticmethod
def _split_csv(value: Optional[str]) -> list[str]:
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
@staticmethod
def _file_digest(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()[:16]
@staticmethod
def _safe_original_stem(filename: str) -> str:
stem = Path(str(filename or "backup")).stem
safe = re.sub(r"[^A-Za-z0-9_.+-]+", "-", stem).strip(".-")
return (safe or "backup")[:72]
def _unique_archive_path(self, archive_name: str) -> Path:
backup_dir = self.backup_dir()
stem = Path(archive_name).stem
suffix = Path(archive_name).suffix
candidate = backup_dir / archive_name
counter = 2
while candidate.exists():
candidate = backup_dir / f"{stem}-{counter}{suffix}"
counter += 1
return candidate
+479
View File
@@ -0,0 +1,479 @@
import asyncio
import logging
import os
import shutil
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable, Optional
from aiogram import Bot
from aiogram.types import FSInputFile
from bot.infra.redis import redis_lock
from bot.services.backup_archive import (
BACKUP_APP_ID,
BACKUP_FILENAME_PREFIX,
BACKUP_FORMAT_VERSION,
attach_archive_integrity,
backup_filename_timestamp,
build_file_records,
write_manifest,
write_zip_from_directory,
)
from config.settings import Settings
COMPOSE_MARKER_FILES = {
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
}
DEFAULT_COMPOSE_EXCLUDED_DIRS = {
".git",
".pytest_cache",
".ruff_cache",
"__pycache__",
"backups",
"node_modules",
"postgres-data",
"redis-data",
"shop-data",
}
BACKUP_RUNTIME_SETTING_KEYS = {
"BACKUP_ENABLED",
"BACKUP_CHAT_ID",
"BACKUP_THREAD_ID",
"BACKUP_INTERVAL_SECONDS",
"BACKUP_LOCAL_RETENTION",
"BACKUP_POSTGRES_DUMP_ENABLED",
"BACKUP_PG_DUMP_PATH",
"BACKUP_PG_DUMP_TIMEOUT_SECONDS",
"BACKUP_COMPOSE_ENABLED",
"BACKUP_COMPOSE_SOURCE_DIR",
"BACKUP_COMPOSE_EXCLUDE_DIRS",
}
TELEGRAM_DOCUMENT_CAPTION_LIMIT = 1024
TELEGRAM_WARNING_DETAIL_LIMIT = 6
TELEGRAM_WARNING_LINE_LIMIT = 220
@dataclass
class BackupResult:
archive_path: Path
started_at: datetime
completed_at: datetime
db_dump_included: bool
compose_files_count: int
size_bytes: int
warnings: list[str] = field(default_factory=list)
def to_payload(self) -> dict[str, object]:
return {
"archive_name": self.archive_path.name,
"archive_path": str(self.archive_path),
"started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat(),
"db_dump_included": self.db_dump_included,
"compose_files_count": self.compose_files_count,
"size_bytes": self.size_bytes,
"warnings": self.warnings,
}
class BackupWorker:
SETTINGS_REFRESH_SECONDS = 60
def __init__(self, settings: Settings, bot: Bot, session_factory=None):
self.settings = settings
self.bot = bot
self.session_factory = session_factory
async def run(self) -> None:
while True:
await self._refresh_settings()
if not self.settings.BACKUP_ENABLED:
await asyncio.sleep(self.SETTINGS_REFRESH_SECONDS)
continue
interval = self._interval_seconds()
delay_seconds = self._seconds_until_next_slot(interval)
if delay_seconds > 0:
should_run = await self._sleep_until_next_slot(delay_seconds, interval)
if not should_run:
continue
await self._refresh_settings()
if not self.settings.BACKUP_ENABLED:
continue
try:
ttl_seconds = max(
60,
int(getattr(self.settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200),
)
async with redis_lock(
self.settings,
"backup-worker",
ttl_seconds=ttl_seconds,
) as acquired:
if acquired:
started = time.monotonic()
result = await self.create_and_send_backup()
logging.info(
"metric worker_tick_duration_seconds=%.3f worker=backup size_bytes=%s",
time.monotonic() - started,
result.size_bytes,
)
else:
logging.info(
"Backup worker tick skipped because another worker holds the lock"
)
except Exception as exc:
logging.exception("Backup worker tick failed")
await self._notify_failure(exc)
async def create_and_send_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
result = await self.create_backup(backup_type=backup_type)
try:
await self.send_backup(result)
finally:
self.prune_old_backups()
return result
async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
started_at = datetime.now(timezone.utc)
stamp = backup_filename_timestamp()
archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
backup_dir.mkdir(parents=True, exist_ok=True)
archive_path = self._unique_archive_path(backup_dir / archive_name)
with tempfile.TemporaryDirectory(
prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-",
dir=backup_dir,
) as tmp:
staging_dir = Path(tmp)
warnings: list[str] = []
db_dump_included = False
compose_files_count = 0
if self.settings.BACKUP_POSTGRES_DUMP_ENABLED:
dump_dir = staging_dir / "database"
dump_dir.mkdir(parents=True, exist_ok=True)
dump_path = dump_dir / f"{self.settings.POSTGRES_DB}.dump"
await self._dump_database(dump_path)
db_dump_included = True
if self.settings.BACKUP_COMPOSE_ENABLED:
compose_files_count = self._stage_compose_source(staging_dir / "compose", warnings)
completed_at = datetime.now(timezone.utc)
manifest = {
"app": BACKUP_APP_ID,
"format_version": BACKUP_FORMAT_VERSION,
"type": str(backup_type or "scheduled"),
"created_at": completed_at.isoformat(),
"created_at_local": completed_at.astimezone().isoformat(),
"postgres": {
"host": self.settings.POSTGRES_HOST,
"port": self.settings.POSTGRES_PORT,
"database": self.settings.POSTGRES_DB,
"user": self.settings.POSTGRES_USER,
"dump_format": "pg_dump custom",
"included": db_dump_included,
},
"compose": {
"source_dir": self.settings.BACKUP_COMPOSE_SOURCE_DIR,
"included": compose_files_count > 0,
"files_count": compose_files_count,
},
"warnings": warnings,
}
attach_archive_integrity(
manifest,
file_records=build_file_records(staging_dir),
)
write_manifest(staging_dir, manifest)
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
write_zip_from_directory(staging_dir, tmp_archive)
tmp_archive.replace(archive_path)
return BackupResult(
archive_path=archive_path,
started_at=started_at,
completed_at=completed_at,
db_dump_included=db_dump_included,
compose_files_count=compose_files_count,
size_bytes=archive_path.stat().st_size,
warnings=warnings,
)
def _unique_archive_path(self, archive_path: Path) -> Path:
if not archive_path.exists():
return archive_path
for index in range(2, 1000):
candidate = archive_path.with_name(f"{archive_path.stem}-{index}{archive_path.suffix}")
if not candidate.exists():
return candidate
raise RuntimeError("Could not allocate a unique backup archive filename")
async def _dump_database(self, dump_path: Path) -> None:
await asyncio.to_thread(self._run_pg_dump, dump_path)
def _run_pg_dump(self, dump_path: Path) -> None:
pg_dump_path = str(self.settings.BACKUP_PG_DUMP_PATH or "pg_dump")
if shutil.which(pg_dump_path) is None and Path(pg_dump_path).name == pg_dump_path:
raise RuntimeError(
"pg_dump executable was not found. Rebuild the worker image with "
"PostgreSQL client tools."
)
env = os.environ.copy()
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
command = [
pg_dump_path,
"-h",
self.settings.POSTGRES_HOST,
"-p",
str(self.settings.POSTGRES_PORT),
"-U",
self.settings.POSTGRES_USER,
"-d",
self.settings.POSTGRES_DB,
"--format=custom",
"--no-owner",
"--no-privileges",
"--file",
str(dump_path),
]
timeout = max(30, int(self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS or 1800))
result = subprocess.run(
command,
check=False,
capture_output=True,
env=env,
text=True,
timeout=timeout,
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
raise RuntimeError(f"pg_dump failed with exit code {result.returncode}: {stderr[:500]}")
def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int:
source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip()
if not source_raw:
warnings.append(
"Compose source directory is not configured. Set "
"BACKUP_COMPOSE_SOURCE_DIR or mount the compose folder into the backup container."
)
return 0
source_dir = Path(source_raw).expanduser()
if not source_dir.exists() or not source_dir.is_dir():
warnings.append(
"If manual backup includes compose but scheduled backup does not, recreate "
"the worker service with the compose-source mount. Compose source directory "
f"is unavailable in this container: {source_dir}"
)
return 0
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES):
warnings.append(
"Check that COMPOSE_BACKUP_SOURCE points to the folder with docker-compose.yml. "
f"Compose source directory has no compose file marker: {source_dir}"
)
excluded_dirs = self._compose_excluded_dirs()
files_count = 0
target_dir.mkdir(parents=True, exist_ok=True)
for path in source_dir.rglob("*"):
relative = path.relative_to(source_dir)
if any(part in excluded_dirs for part in relative.parts):
continue
if path.is_dir() or path.is_symlink():
continue
if path.name.startswith(f"{BACKUP_FILENAME_PREFIX}") and path.suffix == ".zip":
continue
destination = target_dir / relative
destination.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(path, destination)
files_count += 1
except OSError as exc:
warnings.append(f"Skipped compose file {relative.as_posix()}: {exc}")
return files_count
def _compose_excluded_dirs(self) -> set[str]:
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
@staticmethod
def _split_csv(value: Optional[str]) -> list[str]:
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
async def send_backup(self, result: BackupResult) -> None:
chat_id = self._target_chat_id()
if chat_id is None:
logging.warning(
"Backup archive created at %s but BACKUP_CHAT_ID/LOG_CHAT_ID is not configured",
result.archive_path,
)
return
kwargs = {
"chat_id": chat_id,
"document": FSInputFile(result.archive_path),
"caption": self._caption(result),
}
thread_id = self._target_thread_id()
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
await self.bot.send_document(**kwargs)
def prune_old_backups(self) -> None:
retention = int(getattr(self.settings, "BACKUP_LOCAL_RETENTION", 3) or 0)
if retention <= 0:
return
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
archives = sorted(
backup_dir.glob(f"{BACKUP_FILENAME_PREFIX}*.zip"),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
for archive in archives[retention:]:
try:
archive.unlink()
except OSError:
logging.exception("Failed to delete old backup archive %s", archive)
def _target_chat_id(self) -> Optional[int]:
return self.settings.BACKUP_CHAT_ID or self.settings.LOG_CHAT_ID
def _target_thread_id(self) -> Optional[int]:
return self.settings.BACKUP_THREAD_ID or self.settings.LOG_THREAD_ID
def _caption(self, result: BackupResult) -> str:
completed_at = result.completed_at.astimezone()
lines = [
"Remnawave Minishop backup",
f"Created: {completed_at.strftime('%Y-%m-%d %H:%M:%S %Z')}",
f"Database dump: {'yes' if result.db_dump_included else 'no'}",
f"Compose files: {result.compose_files_count}",
f"Archive size: {self._human_size(result.size_bytes)}",
]
if result.warnings:
lines.append(f"Warnings ({len(result.warnings)}):")
for index, warning in enumerate(
result.warnings[:TELEGRAM_WARNING_DETAIL_LIMIT],
start=1,
):
lines.append(f"{index}. {self._caption_warning(warning)}")
hidden_count = len(result.warnings) - TELEGRAM_WARNING_DETAIL_LIMIT
if hidden_count > 0:
lines.append(f"... and {hidden_count} more warning(s)")
return self._fit_caption(lines)
@staticmethod
def _caption_warning(warning: str) -> str:
text = " ".join(str(warning or "").split())
if len(text) <= TELEGRAM_WARNING_LINE_LIMIT:
return text
return f"{text[: TELEGRAM_WARNING_LINE_LIMIT - 1].rstrip()}..."
@staticmethod
def _fit_caption(lines: list[str]) -> str:
caption = "\n".join(lines)
if len(caption) <= TELEGRAM_DOCUMENT_CAPTION_LIMIT:
return caption
suffix = "\n... caption truncated"
return f"{caption[: TELEGRAM_DOCUMENT_CAPTION_LIMIT - len(suffix)].rstrip()}{suffix}"
@staticmethod
def _human_size(size_bytes: int) -> str:
units: Iterable[str] = ("B", "KB", "MB", "GB")
size = float(size_bytes)
unit = "B"
for unit in units:
if size < 1024 or unit == "GB":
break
size /= 1024
if unit == "B":
return f"{int(size)} {unit}"
return f"{size:.1f} {unit}"
async def refresh_settings(self) -> None:
await self._refresh_settings()
async def _refresh_settings(self) -> None:
if self.session_factory is None:
return
try:
from bot.services.settings_override_service import refresh_overrides_from_db
await refresh_overrides_from_db(
self.settings,
self.session_factory,
keys=BACKUP_RUNTIME_SETTING_KEYS,
)
except Exception:
logging.exception("Failed to refresh backup settings from DB")
def _interval_seconds(self) -> int:
try:
interval = int(self.settings.BACKUP_INTERVAL_SECONDS or 0)
except (TypeError, ValueError):
interval = 0
return max(60, interval)
def _seconds_until_next_slot(self, interval_seconds: int) -> float:
now = datetime.now().astimezone()
if interval_seconds <= 0:
return 0.0
if interval_seconds <= 24 * 60 * 60:
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
elapsed = (now - midnight).total_seconds()
remainder = elapsed % interval_seconds
else:
remainder = time.time() % interval_seconds
if remainder < 0.5:
return 0.0
return max(0.0, interval_seconds - remainder)
async def _sleep_until_next_slot(self, delay_seconds: float, interval_seconds: int) -> bool:
deadline = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
while True:
remaining = (deadline - datetime.now(timezone.utc)).total_seconds()
if remaining <= 0:
return True
await asyncio.sleep(min(remaining, self.SETTINGS_REFRESH_SECONDS))
await self._refresh_settings()
if not self.settings.BACKUP_ENABLED:
return False
if self._interval_seconds() != interval_seconds:
return False
async def _notify_failure(self, exc: Exception) -> None:
chat_id = self._target_chat_id()
if chat_id is None:
return
kwargs = {
"chat_id": chat_id,
"text": f"Remnawave Minishop backup failed: {type(exc).__name__}. Check worker logs.",
}
thread_id = self._target_thread_id()
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
try:
await self.bot.send_message(**kwargs)
except Exception:
logging.exception("Failed to send backup failure notification")
+4 -1
View File
@@ -15,6 +15,7 @@ from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_templates import EmailContent, render_login_code
from config.settings import Settings
from db.dal import security_dal
@@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona
class EmailAuthService:
def __init__(self, settings: Settings):
def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None):
self.settings = settings
self.i18n = i18n
def _smtp_attempts(self) -> list[SmtpAttempt]:
attempts: list[SmtpAttempt] = []
@@ -465,6 +467,7 @@ class EmailAuthService:
language_code=language_code,
magic_link=magic_link,
purpose=purpose,
i18n=self.i18n,
)
message = EmailMessage()
+5 -2
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
from config.settings import Settings
_BG = "#05070a"
@@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str:
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
return normalize_locale_language_code(
language_code or settings.DEFAULT_LANGUAGE or "ru",
prefer_known_base=False,
)
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
@@ -0,0 +1,723 @@
"""Load, persist and apply runtime overrides for localization strings."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import (
JsonI18n,
LocaleOverrides,
is_valid_locale_language_code,
normalize_locale_language_code,
normalize_locale_overrides_payload,
resolve_locale_key,
)
from db.dal import locale_overrides_dal
logger = logging.getLogger(__name__)
APP_ROOT = Path(__file__).resolve().parents[3]
LOCALE_OVERRIDES_PATH = APP_ROOT / "data" / "locales-overrides.json"
LOCALE_GROUPS = [
{
"id": "admin_navigation",
"title": "Admin navigation and shared UI",
"description": "Sidebar, section headers, toolbar actions, filters, and shared controls.",
"audience": "internal",
"prefixes": (
"admin_nav_",
"admin_section_",
"admin_panel_title",
"admin_back_to_panel",
"admin_sidebar_",
"admin_exit",
"admin_menu",
"admin_language",
"admin_page_",
"admin_close",
"admin_collapse",
"admin_expand",
"admin_show",
"admin_hide",
"admin_loading",
"admin_btn_",
"admin_filter_",
"admin_sort_",
"admin_status_",
"admin_badge_",
"admin_backups_",
"admin_aria_",
"admin_search",
"admin_clear",
"admin_save",
"admin_saving",
"admin_add",
"admin_apply",
"admin_reset",
"admin_copy",
"admin_copied",
"admin_error",
"admin_unknown_action",
"back_to_admin_panel_button",
"back_to_ads_list_button",
"back_to_stats_monitoring_button",
"back_to_user_management_button",
"prev_page_button",
"next_page_button",
),
},
{
"id": "admin_dashboard",
"title": "Admin dashboard and stats",
"description": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.",
"audience": "internal",
"prefixes": (
"admin_stats_",
"admin_financial_",
"admin_enhanced_",
"admin_panel_stats_",
"admin_panel_traffic_",
"admin_queue_",
"admin_sync_status_",
"admin_stats_button",
"admin_sync_panel_button",
"admin_sync_initiated_from_panel",
"admin_total",
"error_displaying_statistics",
"inline_admin_",
"inline_user_stats_",
"inline_financial_",
"inline_system_",
),
},
{
"id": "admin_users",
"title": "Admin users",
"description": (
"User lists, user cards, bans, grants, premium overrides, and direct messages."
),
"audience": "internal",
"prefixes": (
"admin_user_",
"admin_users_",
"admin_ban_",
"admin_unban_",
"admin_banned_",
"admin_premium_override_",
"admin_traffic_grant_",
"admin_view_banned_",
"user_card_",
"user_premium_",
"user_regular_",
"user_traffic_",
"user_override_",
"premium_override_",
"regular_override_",
"traffic_grant_",
),
},
{
"id": "admin_payments",
"title": "Admin payments",
"description": (
"Payment tables, payment details, exports, provider labels, and payment stats."
),
"audience": "internal",
"prefixes": (
"admin_payment_",
"admin_payments_",
"admin_no_payments",
"admin_view_payments",
"admin_refresh_payments",
"admin_export_payments",
"admin_export_sent",
"admin_amount",
"admin_provider",
"admin_description",
"admin_date",
"admin_csv_payment_",
"admin_csv_amount",
"admin_csv_currency",
"admin_csv_provider",
"admin_csv_status",
"admin_csv_description",
"admin_csv_units",
"admin_csv_months",
"admin_csv_created_at",
),
},
{
"id": "admin_promos_marketing",
"title": "Admin promos, ads, and broadcasts",
"description": "Promo management, ad campaigns, marketing tools, and broadcast workflows.",
"audience": "internal",
"prefixes": (
"admin_promo_",
"admin_promos_",
"admin_bulk_promo_",
"admin_ads_",
"admin_ad_",
"admin_broadcast_",
"admin_create_promo_",
"admin_create_bulk_promo_",
"admin_active_promos_",
"broadcast_",
"confirm_broadcast_",
"cancel_broadcast_",
),
},
{
"id": "admin_tariffs",
"title": "Admin tariffs",
"description": (
"Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets."
),
"audience": "internal",
"prefixes": ("admin_tariff_", "admin_tariffs_", "admin_trial"),
},
{
"id": "admin_support",
"title": "Admin support inbox",
"description": "Support ticket inbox, ticket filters, admin replies, and support statuses.",
"audience": "internal",
"prefixes": ("admin_support_",),
},
{
"id": "admin_appearance",
"title": "Admin appearance",
"description": "Theme catalog, branding, logo, favicon, and public page links.",
"audience": "internal",
"prefixes": (
"admin_themes_",
"admin_appearance",
"admin_settings_icon_",
"admin_settings_field_webapp_",
"admin_settings_field_subscription_mini_app_url",
"admin_settings_field_support_link",
"admin_settings_field_server_status_url",
"admin_settings_field_terms_",
"admin_settings_field_privacy_",
"admin_settings_field_user_agreement_",
"appearance_",
),
},
{
"id": "admin_settings_payments",
"title": "Admin payment settings",
"description": (
"Payment method toggles, prices, provider credentials, and webhook settings."
),
"audience": "internal",
"prefixes": (
"admin_settings_field_default_currency_",
"admin_settings_field_month_",
"admin_settings_field_rub_",
"admin_settings_field_stars_",
"admin_settings_field_traffic_packages_",
"admin_settings_field_payment_methods_",
"admin_settings_field_subscription_purchase_",
"admin_settings_field_yookassa_",
"admin_settings_field_freekassa_",
"admin_settings_field_platega_",
"admin_settings_field_severpay_",
"admin_settings_field_cryptopay_",
"admin_settings_field_wata_",
"admin_settings_field_heleket_",
),
},
{
"id": "admin_settings_subscriptions",
"title": "Admin subscription settings",
"description": (
"Panel connection, default squads, trials, referrals, device limits, and guides."
),
"audience": "internal",
"prefixes": (
"admin_settings_field_panel_",
"admin_settings_field_user_",
"admin_settings_field_trial_",
"admin_settings_field_referral_",
"admin_settings_field_legacy_refs",
"admin_settings_field_my_devices_",
"admin_settings_field_subscription_guides_",
"admin_settings_field_subscription_page_",
),
},
{
"id": "admin_settings_notifications",
"title": "Admin notifications and logs",
"description": "Logging, required channel, subscription notifications, and support limits.",
"audience": "internal",
"prefixes": (
"admin_settings_field_log_",
"admin_settings_field_backup_",
"admin_settings_field_support_",
"admin_settings_field_subscription_notifications_",
"admin_settings_field_subscription_notify_",
"admin_settings_field_required_",
"admin_settings_field_disable_welcome_",
"admin_settings_field_start_command_",
"admin_settings_field_default_language_",
),
},
{
"id": "admin_settings",
"title": "Admin settings",
"description": (
"Settings screen groups, subsections, helper text, and uncategorized settings."
),
"audience": "internal",
"prefixes": ("admin_settings_",),
},
{
"id": "admin_translations",
"title": "Admin translations",
"description": "Translation override screen, language controls, and locale group labels.",
"audience": "internal",
"prefixes": ("admin_translations_",),
},
{
"id": "admin_logs",
"title": "Admin logs and exports",
"description": "Activity logs, log exports, CSV headers, and event detail labels.",
"audience": "internal",
"prefixes": (
"admin_logs_",
"admin_log_",
"admin_all_logs_",
"admin_view_logs_",
"admin_export_logs_",
"admin_no_logs",
"admin_csv_header_",
"admin_event",
"admin_content",
"csv_yes",
"csv_no",
"error_displaying_logs_",
),
},
{
"id": "admin_misc",
"title": "Admin miscellaneous",
"description": (
"Older bot-admin labels and admin-only strings that do not fit another section."
),
"audience": "internal",
"prefixes": ("admin_",),
},
{
"id": "webapp",
"title": "Mini App",
"description": "User-facing Mini App screens, navigation, settings, and toasts.",
"audience": "user",
"prefixes": ("wa_",),
},
{
"id": "bot_menu",
"title": "Telegram bot menu",
"description": "Start menu, inline buttons, language selector, and bot-only flows.",
"audience": "user",
"prefixes": (
"main_menu_",
"menu_",
"bot_interface_",
"choose_language",
"language_",
"back_",
"cancel_",
"connect_",
),
},
{
"id": "subscriptions",
"title": "Subscriptions and devices",
"description": (
"Subscription status, install guides, traffic packages, trials, and devices."
),
"audience": "user",
"prefixes": (
"subscription_",
"trial_",
"tariff_",
"traffic_",
"device_",
"devices_",
"my_devices_",
"install_",
"config_",
),
},
{
"id": "payments",
"title": "Payments",
"description": "Payment provider flows, invoices, payment methods, and checkout messages.",
"audience": "user",
"prefixes": (
"payment_",
"pay_",
"yookassa_",
"free_kassa_",
"freekassa_",
"wata_",
"heleket_",
"cryptopay_",
"platega_",
"stars_",
"autorenew_",
),
},
{
"id": "support",
"title": "Support",
"description": "Support links, ticket inbox copy, ticket statuses, and notifications.",
"audience": "user",
"prefixes": ("support_", "ticket_"),
},
{
"id": "referrals_promos",
"title": "Referrals and promos",
"description": "Referral program, invite copy, promo codes, and bonuses.",
"audience": "user",
"prefixes": ("referral_", "promo_", "invite_", "inline_referral_"),
},
{
"id": "auth_security",
"title": "Auth and security",
"description": "Login, email verification, account linking, and security messages.",
"audience": "user",
"prefixes": (
"auth_",
"login_",
"password_",
"security_",
"webapp_auth_",
"channel_subscription_",
),
},
{
"id": "emails",
"title": "Emails",
"description": "Transactional emails sent to users: login codes, payments, and reminders.",
"audience": "user",
"prefixes": ("email_",),
},
{
"id": "notifications_sync",
"title": "Notifications and sync",
"description": "Admin notifications, panel sync, logs, and background status messages.",
"audience": "internal",
"prefixes": ("notification_", "notifications_", "sync_", "log_", "panel_"),
},
]
DEFAULT_LOCALE_GROUP = {
"id": "common",
"title": "Common",
"description": "Shared buttons, statuses, validation errors, and uncategorized strings.",
"audience": "user",
"prefixes": (),
}
INTERNAL_LOCALE_KEY_PREFIXES = (
"admin_",
"log_",
"notification_",
"notifications_",
"panel_",
"sync_",
)
@dataclass(frozen=True)
class LocaleOverridesFileState:
exists: bool
readable: bool
overrides: LocaleOverrides
def _valid_languages(i18n: JsonI18n) -> set[str]:
return set((i18n.base_locales_data or i18n.locales_data or {}).keys())
def _valid_keys_by_language(i18n: JsonI18n) -> Dict[str, set[str]]:
source = i18n.base_locales_data or i18n.locales_data or {}
return {
lang: {str(key) for key in messages}
for lang, messages in source.items()
if isinstance(messages, dict)
}
def _normalize_for_i18n(i18n: JsonI18n, payload: object) -> tuple[LocaleOverrides, Dict[str, str]]:
return normalize_locale_overrides_payload(
payload,
valid_languages=_valid_languages(i18n),
valid_keys_by_language=_valid_keys_by_language(i18n),
allow_extra_languages=True,
)
def _flatten(overrides: LocaleOverrides) -> Iterable[Tuple[str, str, str]]:
for lang, messages in overrides.items():
for key, value in messages.items():
yield lang, key, value
def _flat_map(overrides: LocaleOverrides) -> Dict[Tuple[str, str], str]:
return {(lang, key): value for lang, key, value in _flatten(overrides)}
def _count_overrides(overrides: LocaleOverrides) -> int:
return sum(len(messages) for messages in overrides.values())
def _read_locale_overrides_file_state(
i18n: JsonI18n,
*,
path: Path = LOCALE_OVERRIDES_PATH,
) -> LocaleOverridesFileState:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return LocaleOverridesFileState(exists=False, readable=False, overrides={})
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to read locale overrides from %s: %s", path, exc)
return LocaleOverridesFileState(exists=True, readable=False, overrides={})
overrides, errors = _normalize_for_i18n(i18n, payload)
if errors:
logger.warning("Skipping invalid locale override entries from %s: %s", path, errors)
return LocaleOverridesFileState(exists=True, readable=True, overrides=overrides)
def read_locale_overrides_file(
i18n: JsonI18n,
*,
path: Path = LOCALE_OVERRIDES_PATH,
) -> LocaleOverrides:
return _read_locale_overrides_file_state(i18n, path=path).overrides
def write_locale_overrides_file(
overrides: LocaleOverrides,
*,
path: Path = LOCALE_OVERRIDES_PATH,
) -> bool:
payload = {
lang: dict(sorted(messages.items()))
for lang, messages in sorted(overrides.items())
if messages
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return True
except OSError as exc:
logger.warning("Failed to write locale overrides to %s: %s", path, exc)
return False
async def _replace_db_overrides(
session: AsyncSession,
desired_overrides: LocaleOverrides,
*,
updated_by: Optional[int] = None,
) -> int:
current = _flat_map(await locale_overrides_dal.get_all_overrides(session))
desired = _flat_map(desired_overrides)
changes: Dict[Tuple[str, str], Tuple[bool, str]] = {}
for identity, value in desired.items():
if current.get(identity) != value:
changes[identity] = (True, value)
for identity in current:
if identity not in desired:
changes[identity] = (False, "")
if changes:
await locale_overrides_dal.bulk_apply(session, updates=changes, updated_by=updated_by)
return len(changes)
async def load_locale_overrides(
i18n: JsonI18n,
async_session_factory: sessionmaker,
*,
overrides_path: Path = LOCALE_OVERRIDES_PATH,
) -> int:
"""Load locale overrides and keep the DB mirror in sync.
A valid JSON file is the source of truth. The DB is used as a fallback only
when the file is missing or cannot be read/parsed.
"""
i18n.configure_overrides_file(overrides_path)
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
try:
async with async_session_factory() as session:
if file_state.readable:
async with session.begin():
changed = await _replace_db_overrides(
session,
file_state.overrides,
updated_by=None,
)
i18n.set_locale_overrides(file_state.overrides)
i18n.configure_overrides_file(overrides_path)
logger.info(
"Applied %s locale overrides from %s and synced %s DB rows",
_count_overrides(file_state.overrides),
overrides_path,
changed,
)
return _count_overrides(file_state.overrides)
db_overrides = await locale_overrides_dal.get_all_overrides(session)
except Exception as exc:
logger.warning("Could not load locale overrides from DB: %s", exc)
if file_state.readable:
i18n.set_locale_overrides(file_state.overrides)
return _count_overrides(file_state.overrides)
i18n.set_locale_overrides({})
return 0
normalized, errors = _normalize_for_i18n(i18n, db_overrides)
if errors:
logger.warning("Skipping invalid DB locale override entries: %s", errors)
try:
async with async_session_factory() as session:
async with session.begin():
changed = await _replace_db_overrides(session, normalized, updated_by=None)
if changed:
logger.info("Canonicalized %s DB locale override rows", changed)
except Exception as exc:
logger.warning("Could not canonicalize DB locale overrides: %s", exc)
i18n.set_locale_overrides(normalized)
if not file_state.exists:
logger.info(
"Locale overrides file %s is missing; trying to bootstrap it from DB state",
overrides_path,
)
file_written = write_locale_overrides_file(normalized, path=overrides_path)
if file_written:
i18n.configure_overrides_file(overrides_path)
logger.info("Created locale overrides file %s from DB state", overrides_path)
logger.info("Applied %s locale overrides from DB fallback", _count_overrides(normalized))
return _count_overrides(normalized)
async def update_locale_overrides(
i18n: JsonI18n,
async_session_factory: sessionmaker,
*,
updates: Dict[str, Dict[str, Any]],
deletes: Optional[List[Dict[str, str]]] = None,
actor_id: Optional[int] = None,
overrides_path: Path = LOCALE_OVERRIDES_PATH,
) -> Dict[str, Any]:
deletes = list(deletes or [])
normalized_updates, errors = _normalize_for_i18n(i18n, updates)
normalized_deletes: List[Tuple[str, str]] = []
valid_languages = _valid_languages(i18n)
valid_keys = {key for keys in _valid_keys_by_language(i18n).values() for key in keys}
for item in deletes:
if not isinstance(item, dict):
errors.setdefault("_deletes", "invalid_delete")
continue
lang = normalize_locale_language_code(
item.get("lang"),
valid_languages=None,
prefer_known_base=False,
)
key = resolve_locale_key(item.get("key"))
error_key = f"{lang or '_language'}.{key or '_key'}"
if lang not in valid_languages and not is_valid_locale_language_code(lang):
errors.setdefault(error_key, "invalid_language")
continue
if key not in valid_keys:
errors.setdefault(error_key, "unknown_key")
continue
normalized_deletes.append((lang, key))
if errors:
return {"ok": False, "errors": errors}
async with async_session_factory() as session:
db_overrides = await locale_overrides_dal.get_all_overrides(session)
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
source_overrides = file_state.overrides if file_state.readable else db_overrides
desired, source_errors = _normalize_for_i18n(i18n, source_overrides)
if source_errors:
logger.warning("Skipping invalid locale override entries before update: %s", source_errors)
for lang, messages in normalized_updates.items():
desired.setdefault(lang, {}).update(messages)
for lang, key in normalized_deletes:
if lang in desired:
desired[lang].pop(key, None)
if not desired[lang]:
desired.pop(lang, None)
desired = {
lang: dict(sorted(messages.items()))
for lang, messages in sorted(desired.items())
if messages
}
file_written = write_locale_overrides_file(desired, path=overrides_path)
if not file_written and file_state.exists and file_state.readable:
return {"ok": False, "errors": {"_file": "write_failed"}}
async with async_session_factory() as session:
async with session.begin():
await _replace_db_overrides(session, desired, updated_by=actor_id)
i18n.set_locale_overrides(desired)
if file_written:
i18n.configure_overrides_file(overrides_path)
return {
"ok": True,
"applied": sum(len(messages) for messages in normalized_updates.values()),
"reverted": len(normalized_deletes),
"file_written": file_written,
}
def group_id_for_locale_key(key: str) -> str:
for group in LOCALE_GROUPS:
if any(key.startswith(prefix) or key == prefix for prefix in group["prefixes"]):
return str(group["id"])
return str(DEFAULT_LOCALE_GROUP["id"])
def audience_for_locale_key(key: str) -> str:
if key.startswith(INTERNAL_LOCALE_KEY_PREFIXES):
return "internal"
group_id = group_id_for_locale_key(key)
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
if group["id"] == group_id:
return str(group.get("audience") or "user")
return "user"
def locale_group_catalog() -> List[Dict[str, Any]]:
catalog: List[Dict[str, Any]] = []
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
item = {key: value for key, value in group.items() if key != "prefixes"}
item["title_key"] = f"translations_group_{item['id']}"
item["description_key"] = f"translations_group_{item['id']}_hint"
catalog.append(item)
return catalog
+36 -6
View File
@@ -56,11 +56,16 @@ class NotificationService:
user_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
email: Optional[str] = None,
) -> str:
base_display = display_name_or_fallback(first_name, f"ID {user_id}")
if username:
base_display = f"{base_display} ({username_for_display(username)})"
return base_display
safe_display = hd.quote(base_display)
clean_email = str(email or "").strip()
if clean_email:
safe_display = f"{safe_display} · <code>{hd.quote(clean_email)}</code>"
return safe_display
@staticmethod
def _build_profile_keyboard(
@@ -254,8 +259,13 @@ class NotificationService:
part for part in [user.first_name, getattr(user, "last_name", None)] if part
)
if user.username:
return f"{name or user.username} (@{user.username})"
return name or getattr(user, "email", None) or f"ID {user.user_id}"
display = f"{name or user.username} (@{user.username})"
else:
display = name or f"ID {user.user_id}"
email = str(getattr(user, "email", None) or "").strip()
if email:
return f"{display} · {email}" if display and display != email else email
return display
@staticmethod
def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]:
@@ -540,6 +550,7 @@ class NotificationService:
user_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
email: Optional[str] = None,
referred_by_id: Optional[int] = None,
):
"""Send notification about new user registration"""
@@ -553,6 +564,7 @@ class NotificationService:
user_id=user_id,
username=username,
first_name=first_name,
email=email,
)
referral_text = ""
@@ -640,6 +652,7 @@ class NotificationService:
user_id=telegram_id or user_id,
username=username,
first_name=first_name,
email=email,
)
message = _(
@@ -676,6 +689,7 @@ class NotificationService:
user_id=telegram_id,
username=username,
first_name=first_name,
email=email,
)
message = _(
@@ -715,6 +729,7 @@ class NotificationService:
user_id=display_user_id,
username=username,
first_name=first_name,
email=email,
)
message = _(
@@ -761,6 +776,7 @@ class NotificationService:
months: int,
payment_provider: str,
username: Optional[str] = None,
email: Optional[str] = None,
traffic_gb: Optional[float] = None,
*,
traffic_is_premium: bool = False,
@@ -776,6 +792,7 @@ class NotificationService:
user_display = self._format_user_display(
user_id=user_id,
username=username,
email=email,
)
try:
@@ -827,7 +844,12 @@ class NotificationService:
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_promo_activation(
self, user_id: int, promo_code: str, bonus_days: int, username: Optional[str] = None
self,
user_id: int,
promo_code: str,
bonus_days: int,
username: Optional[str] = None,
email: Optional[str] = None,
):
"""Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
@@ -839,6 +861,7 @@ class NotificationService:
user_display = self._format_user_display(
user_id=user_id,
username=username,
email=email,
)
message = _(
@@ -854,7 +877,11 @@ class NotificationService:
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_trial_activation(
self, user_id: int, end_date: datetime, username: Optional[str] = None
self,
user_id: int,
end_date: datetime,
username: Optional[str] = None,
email: Optional[str] = None,
):
"""Send notification about trial activation"""
if not self.settings.LOG_TRIAL_ACTIVATIONS:
@@ -866,6 +893,7 @@ class NotificationService:
user_display = self._format_user_display(
user_id=user_id,
username=username,
email=email,
)
message = _(
@@ -918,6 +946,7 @@ class NotificationService:
suspicious_input: str,
username: Optional[str] = None,
first_name: Optional[str] = None,
email: Optional[str] = None,
):
"""Send notification about a suspicious promo code attempt."""
if not self.settings.LOG_SUSPICIOUS_ACTIVITY:
@@ -930,11 +959,12 @@ class NotificationService:
user_id=user_id,
username=username,
first_name=first_name,
email=email,
)
message = _(
"log_suspicious_promo",
user_display=hd.quote(user_display),
user_display=user_display,
user_id=user_id,
suspicious_input=hd.quote(suspicious_input),
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
+48
View File
@@ -597,6 +597,54 @@ class PanelApiService:
return f"{base_sub_url}/{client_type.lower()}"
return base_sub_url
async def get_subscription_page_config_by_short_uuid(
self,
short_uuid: str,
request_headers: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, Any]]:
if not short_uuid:
return None
endpoint = f"/subscriptions/subpage-config/{short_uuid}"
payload = {"requestHeaders": request_headers or {}}
response_data = await self._request(
"GET",
endpoint,
json=payload,
log_full_response=False,
)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config for short UUID {short_uuid}. Response: {response_data}" # noqa: E501
)
return None
async def get_subscription_page_config_list(self) -> Optional[Dict[str, Any]]:
endpoint = "/subscription-page-configs"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config list from panel. Response: {response_data}"
)
return None
async def get_subscription_page_config_by_uuid(
self,
config_uuid: str,
) -> Optional[Dict[str, Any]]:
config_uuid = str(config_uuid or "").strip()
if not config_uuid:
return None
endpoint = f"/subscription-page-configs/{config_uuid}"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config {config_uuid} from panel. Response: {response_data}" # noqa: E501
)
return None
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
if self._devices_cache.ttl_seconds <= 0:
return await self._get_user_devices_uncached(user_uuid)
+40 -5
View File
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
)
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import user_dal
from db.dal import tariff_dal, user_dal
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
@@ -63,13 +63,44 @@ class PanelWebhookService:
**kwargs,
):
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
extra_text = str(kwargs.pop("extra_text", "") or "").strip()
try:
await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup
)
text = _(message_key, **kwargs)
if extra_text:
text = f"{text}\n\n{extra_text}"
await self.bot.send_message(user_id, text, reply_markup=reply_markup)
except Exception:
logging.exception("Failed to send notification to %s", user_id)
async def _hwid_renewal_note(self, internal_user_id: int, lang: str) -> str:
try:
from db.dal import subscription_dal
async with self.async_session_factory() as session:
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
if not sub:
return ""
summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
)
count = int(summary.get("active_devices") or sub.extra_hwid_devices or 0)
if count <= 0:
return ""
active_until = summary.get("active_until") or sub.end_date
date_text = active_until.strftime("%Y-%m-%d") if active_until else ""
except Exception:
logging.exception("Failed to build HWID renewal note for user %s", internal_user_id)
return ""
return self.i18n.gettext(
lang,
"subscription_hwid_renewal_reminder",
count=count,
date=date_text,
)
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
@@ -97,6 +128,7 @@ class PanelWebhookService:
if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name]
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
@@ -148,6 +180,7 @@ class PanelWebhookService:
"autorenew_48h_charge_tomorrow_notice",
reply_markup=cancel_kb,
user_name=first_name,
extra_text=hwid_renewal_note,
)
return
await self._send_message(
@@ -157,6 +190,7 @@ class PanelWebhookService:
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
extra_text=hwid_renewal_note,
)
if days_left == 3 and user_email:
await self._send_subscription_expiring_email(
@@ -206,8 +240,9 @@ class PanelWebhookService:
days_left=days_left,
end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
i18n=self.i18n,
)
email_service = EmailAuthService(self.settings)
email_service = EmailAuthService(self.settings, self.i18n)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient)
@@ -112,6 +112,7 @@ class PromoCodeService:
promo_code=code_input_upper,
bonus_days=bonus_days,
username=user.username if user else None,
email=getattr(user, "email", None) if user else None,
)
except Exception as e:
logging.error(f"Failed to send promo activation notification: {e}")
@@ -127,6 +127,33 @@ def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int:
return applied
def _normalize_exclusive_provider_toggles(
updates: Dict[str, Any],
deletes: list,
) -> tuple[Dict[str, Any], list]:
"""When a provider is enabled for admins only, turn off its public toggle."""
from bot.payment_providers import provider_admin_only_pairs
exclusive_map = {
key: opposite
for public_key, admin_key in provider_admin_only_pairs()
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
}
if not exclusive_map:
return updates, deletes
normalized = dict(updates)
normalized_deletes = list(deletes)
for key, value in updates.items():
if value is not True or key not in exclusive_map:
continue
opposite = exclusive_map[key]
normalized[opposite] = False
normalized_deletes = [item for item in normalized_deletes if item != opposite]
return normalized, normalized_deletes
def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
snapshot: Dict[str, Any] = {}
logo_url = getattr(settings, "WEBAPP_LOGO_URL", None)
@@ -236,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
return applied
async def refresh_overrides_from_db(
settings: Settings,
async_session_factory: sessionmaker,
*,
keys: Optional[set[str]] = None,
) -> int:
"""Refresh already-known runtime overrides without startup restore side effects."""
try:
async with async_session_factory() as session:
overrides = await app_settings_dal.get_all_overrides(session)
except Exception as exc:
logger.warning("Could not refresh setting overrides from DB: %s", exc)
return 0
if keys is not None:
try:
env_only = Settings()
for key in keys:
if key in overrides:
continue
attr_name = _resolve_attribute_name(env_only, key)
if attr_name and hasattr(env_only, attr_name):
setattr(settings, attr_name, getattr(env_only, attr_name))
except Exception as exc:
logger.warning("Failed to restore env defaults while refreshing overrides: %s", exc)
overrides = {key: value for key, value in overrides.items() if key in keys}
return apply_overrides(settings, overrides)
async def update_overrides(
settings: Settings,
async_session_factory: sessionmaker,
@@ -270,6 +326,11 @@ async def update_overrides(
if errors:
return {"ok": False, "errors": errors}
coerced_updates, valid_deletes = _normalize_exclusive_provider_toggles(
coerced_updates,
valid_deletes,
)
async with async_session_factory() as session: # type: AsyncSession
async with session.begin():
for key, value in coerced_updates.items():
@@ -0,0 +1,346 @@
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import Bot
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, sessionmaker
from bot.infra.redis import redis_lock
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import subscription_dal
from db.models import Subscription
SUBSCRIPTION_NOTIFICATION_LOCK = "subscription-notification-worker"
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS = 300
EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
@dataclass(frozen=True)
class SubscriptionNotificationStage:
key: str
message_key: str
hours_before: Optional[int] = None
class SubscriptionNotificationWorker:
def __init__(
self,
settings: Settings,
session_factory: sessionmaker,
bot: Bot,
i18n: JsonI18n,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
) -> None:
self.settings = settings
self.session_factory = session_factory
self.bot = bot
self.i18n = i18n
self.panel_service = panel_service
self.subscription_service = subscription_service
self._stopped = asyncio.Event()
async def run(self) -> None:
while not self._stopped.is_set():
try:
async with redis_lock(
self.settings,
SUBSCRIPTION_NOTIFICATION_LOCK,
ttl_seconds=max(60, self._tick_seconds() - 10),
) as acquired:
if not acquired:
logging.info(
"SubscriptionNotificationWorker tick skipped: Redis lock is held"
)
else:
started = time.monotonic()
async with self.session_factory() as session:
await self.expiry_tick(session)
await self.trial_traffic_tick(session)
await session.commit()
logging.info(
"metric worker_tick_duration_seconds=%.3f "
"worker=subscription_notification",
time.monotonic() - started,
)
except Exception:
logging.exception("SubscriptionNotificationWorker tick failed")
try:
await asyncio.wait_for(self._stopped.wait(), timeout=self._tick_seconds())
except asyncio.TimeoutError:
pass
def stop(self) -> None:
self._stopped.set()
def _tick_seconds(self) -> int:
return int(
getattr(
self.settings,
"SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS",
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS,
)
or DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS
)
async def expiry_tick(self, session: AsyncSession) -> None:
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
return
now = datetime.now(timezone.utc)
lower = now - EXPIRED_AFTER_NOTIFICATION_WINDOW
upper = now + self._max_before_window()
result = await session.execute(
select(Subscription)
.where(
Subscription.skip_notifications == False,
Subscription.end_date >= lower,
Subscription.end_date <= upper,
)
.options(selectinload(Subscription.user))
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
stage = self.stage_for_subscription(sub, now)
if stage is None:
continue
if await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
stage.key,
):
continue
if not await self._send_expiry_notification(sub, stage):
continue
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
stage.key,
sent_at=now,
)
def stage_for_subscription(
self,
sub: Subscription,
now: datetime,
) -> Optional[SubscriptionNotificationStage]:
end_date = self._as_utc(getattr(sub, "end_date", None))
if end_date is None:
return None
seconds_left = (end_date - now).total_seconds()
if seconds_left > 0:
hours_before = int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0)
if 0 < hours_before <= 23 and seconds_left <= hours_before * 3600:
return SubscriptionNotificationStage(
key=f"before_{hours_before}h",
message_key="subscription_hours_notification",
hours_before=hours_before,
)
days_before_limit = max(
0,
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
)
day_stages = (
(1, "subscription_24h_notification"),
(2, "subscription_48h_notification"),
(3, "subscription_72h_notification"),
)
for days_before, message_key in day_stages:
if days_before > days_before_limit:
continue
if seconds_left <= days_before * 24 * 3600:
return SubscriptionNotificationStage(
key=f"before_{days_before}d",
message_key=message_key,
)
return None
expired_for = now - end_date
if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_ON_EXPIRE", True)
and expired_for <= EXPIRED_NOTIFICATION_WINDOW
):
return SubscriptionNotificationStage(
key="expired",
message_key="subscription_expired_notification",
)
if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
and EXPIRED_NOTIFICATION_WINDOW < expired_for <= EXPIRED_AFTER_NOTIFICATION_WINDOW
):
return SubscriptionNotificationStage(
key="expired_24h_after",
message_key="subscription_expired_yesterday_notification",
)
return None
async def trial_traffic_tick(self, session: AsyncSession) -> None:
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
return
now = datetime.now(timezone.utc)
result = await session.execute(
select(Subscription)
.where(
Subscription.skip_notifications == False,
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.traffic_limit_bytes.is_not(None),
Subscription.traffic_limit_bytes > 0,
or_(
Subscription.provider == "trial",
Subscription.status_from_panel == "TRIAL",
Subscription.duration_months == 0,
),
)
.options(selectinload(Subscription.user))
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
if await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted",
):
continue
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
limit = int(getattr(sub, "traffic_limit_bytes", 0) or 0)
panel_data = await self._panel_user(sub)
if panel_data:
panel_used, panel_limit, _ = (
self.subscription_service._extract_panel_traffic_details(panel_data)
)
if panel_used is not None:
used = int(panel_used)
sub.traffic_used_bytes = used
if panel_limit is not None:
limit = int(panel_limit)
sub.traffic_limit_bytes = limit
panel_status = str(panel_data.get("status") or "").upper()
if panel_status:
sub.status_from_panel = panel_status
if limit <= 0 or used < limit:
continue
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
continue
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted",
sent_at=now,
)
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
if not panel_uuid:
return None
try:
data = await self.panel_service.get_user_by_uuid(panel_uuid, log_response=False)
except Exception:
logging.exception(
"SubscriptionNotificationWorker: failed to fetch panel user %s",
panel_uuid,
)
return None
return data if isinstance(data, dict) else None
async def _send_expiry_notification(
self,
sub: Subscription,
stage: SubscriptionNotificationStage,
) -> bool:
user_id = int(getattr(sub, "user_id", 0) or 0)
if user_id <= 0:
return False
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
user_name = getattr(user, "first_name", None) or f"User {user_id}"
end_date = self._as_utc(getattr(sub, "end_date", None))
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
kwargs = {"user_name": user_name, "end_date": end_date_text}
if stage.hours_before is not None:
kwargs["hours"] = stage.hours_before
try:
await self.bot.send_message(
user_id,
translate(stage.message_key, **kwargs),
reply_markup=get_subscribe_only_markup(lang, self.i18n),
)
return True
except Exception:
logging.exception(
"Failed to send subscription notification %s to user %s",
stage.key,
user_id,
)
return False
async def _send_trial_traffic_depleted(
self,
sub: Subscription,
*,
used: int,
limit: int,
) -> bool:
user_id = int(getattr(sub, "user_id", 0) or 0)
if user_id <= 0:
return False
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
remaining = max(0, limit - used)
try:
await self.bot.send_message(
user_id,
translate(
"trial_traffic_depleted_notification",
used=hd.quote(self._fmt_bytes(used)),
remaining=hd.quote(self._fmt_bytes(remaining)),
limit_total=hd.quote(self._fmt_bytes(limit)),
),
reply_markup=get_subscribe_only_markup(lang, self.i18n),
parse_mode="HTML",
)
return True
except Exception:
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
return False
def _max_before_window(self) -> timedelta:
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
hours_before = max(
0,
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0),
)
return max(timedelta(days=min(days_before, 3)), timedelta(hours=hours_before))
@staticmethod
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@staticmethod
def _fmt_bytes(value: int) -> str:
size = float(max(0, int(value or 0)))
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024 or unit == "TB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} TB"
@@ -25,5 +25,6 @@ from db.models import Subscription, User
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_payment_success
from bot.services.panel_api_service import PanelApiService
from bot.utils.text_sanitizer import panel_description_from_profile
__all__ = [name for name in globals() if not name.startswith("__")]
@@ -3,6 +3,190 @@ from ._runtime import * # noqa: F403,F405
class HwidDeviceMixin:
@staticmethod
def _as_aware_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
async def _active_hwid_extra_devices_for_sub(
self,
session: AsyncSession,
sub: Subscription,
*,
at: Optional[datetime] = None,
) -> int:
try:
return await tariff_dal.sum_active_hwid_devices(
session,
subscription_id=sub.subscription_id,
at=at or datetime.now(timezone.utc),
)
except Exception:
logging.exception(
"Failed to recalculate active HWID devices for subscription %s",
getattr(sub, "subscription_id", None),
)
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
async def _hwid_topup_validity_window(
self,
session: AsyncSession,
sub: Subscription,
*,
renewal: bool,
now: datetime,
) -> Optional[Tuple[datetime, datetime, Dict[str, Any]]]:
valid_until = self._as_aware_utc(getattr(sub, "end_date", None))
if not valid_until or valid_until <= now:
return None
summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
at=now,
)
valid_from = now
if renewal:
active_until = self._as_aware_utc(summary.get("active_until"))
if active_until and now < active_until < valid_until:
valid_from = active_until
elif active_until and active_until >= valid_until:
return None
return valid_from, valid_until, summary
@staticmethod
def _round_hwid_price(value: float, *, currency: str) -> float:
if value <= 0:
return 0.0
if currency == "stars":
return float(math.ceil(value))
return math.ceil(float(value) * 100) / 100
@staticmethod
def _find_hwid_package(tariff: Tariff, device_count: int, currency: str) -> Optional[Any]:
package_set = tariff.hwid_device_packages
if not package_set:
return None
packages = package_set.for_currency("stars" if currency == "stars" else "rub")
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
def _quote_hwid_package_price(
self,
*,
sub: Subscription,
package: Any,
valid_from: datetime,
valid_until: datetime,
now: datetime,
currency: str,
) -> Dict[str, Any]:
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
full_price = float(package.price_for_period(period_months))
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
inferred_period_start = add_months(period_end, -period_months)
if not period_start or period_start >= period_end or period_start < inferred_period_start:
period_start = inferred_period_start
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
billable_start = max(now, valid_from)
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
ratio = billable_seconds / basis_seconds
raw_price = full_price * ratio
price = self._round_hwid_price(raw_price, currency=currency)
min_price = getattr(package, "min_price", None)
if raw_price > 0 and min_price is not None:
price = max(price, self._round_hwid_price(float(min_price), currency=currency))
if currency == "stars":
price = float(int(math.ceil(price)))
return {
"price": price,
"full_price": full_price,
"pricing_period_months": period_months,
"proration_ratio": ratio,
"valid_from": valid_from,
"valid_until": valid_until,
"billable_seconds": billable_seconds,
"period_seconds": basis_seconds,
"currency": currency,
}
async def quote_hwid_device_topup(
self,
session: AsyncSession,
*,
user_id: int,
device_count: int,
tariff_key: Optional[str] = None,
renewal: bool = False,
currency: str = "rub",
now: Optional[datetime] = None,
) -> Optional[Dict[str, Any]]:
try:
purchased_devices = int(device_count)
except (TypeError, ValueError):
return None
if purchased_devices <= 0:
return None
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
if not tariff or tariff.billing_model != "period":
return None
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
if base_hwid_limit == 0:
return None
package = self._find_hwid_package(tariff, purchased_devices, currency)
if not package:
return None
now = now or datetime.now(timezone.utc)
window = await self._hwid_topup_validity_window(
session,
sub,
renewal=renewal,
now=now,
)
if not window:
return None
valid_from, valid_until, entitlement_summary = window
quote = self._quote_hwid_package_price(
sub=sub,
package=package,
valid_from=valid_from,
valid_until=valid_until,
now=now,
currency="stars" if currency == "stars" else "rub",
)
quote.update(
{
"subscription_id": sub.subscription_id,
"tariff_key": tariff.key,
"device_count": purchased_devices,
"renewal": renewal,
"active_extra_devices": int(entitlement_summary.get("active_devices") or 0),
"active_until": entitlement_summary.get("active_until"),
}
)
return quote
async def activate_hwid_device_topup(
self,
session: AsyncSession,
@@ -12,6 +196,7 @@ class HwidDeviceMixin:
payment_db_id: int,
provider: str = "yookassa",
tariff_key: Optional[str] = None,
renewal: bool = False,
) -> Optional[Dict[str, Any]]:
try:
purchased_devices = int(device_count)
@@ -33,6 +218,14 @@ class HwidDeviceMixin:
tariff = None
if self._tariffs_config():
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
if tariff.billing_model != "period":
logging.info(
"Skipping HWID top-up for user %s because tariff %s is %s",
user_id,
tariff.key,
tariff.billing_model,
)
return None
packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
if tariff.hwid_device_packages
@@ -57,19 +250,69 @@ class HwidDeviceMixin:
)
return {
"subscription_id": sub.subscription_id,
"end_date": sub.end_date,
"is_active": True,
"panel_user_uuid": db_user.panel_user_uuid,
"panel_short_uuid": getattr(sub, "panel_subscription_uuid", None),
"hwid_device_limit": 0,
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
"purchased_hwid_devices": 0,
}
new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices
now = datetime.now(timezone.utc)
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
at=now,
)
valid_from = self._as_aware_utc(getattr(payment, "hwid_valid_from", None))
valid_until = self._as_aware_utc(getattr(payment, "hwid_valid_until", None))
if valid_from and valid_until:
if valid_until <= now or valid_from >= valid_until:
logging.error(
"Frozen HWID quote is no longer valid for user %s "
"(payment_id=%s, valid_from=%s, valid_until=%s)",
user_id,
payment_db_id,
valid_from,
valid_until,
)
return None
else:
window = await self._hwid_topup_validity_window(
session,
sub,
renewal=renewal,
now=now,
)
if window:
valid_from, valid_until, entitlement_summary = window
if not valid_from or not valid_until:
logging.error(
"HWID top-up has no valid subscription window for user %s "
"(subscription_id=%s, renewal=%s)",
user_id,
sub.subscription_id,
renewal,
)
return None
active_extra_devices = int(entitlement_summary.get("active_devices") or 0)
starts_now = valid_from <= now < valid_until
new_extra_devices = active_extra_devices + (purchased_devices if starts_now else 0)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices)
await self._record_payment_context(
session,
payment_db_id,
sale_mode="hwid_devices",
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
tariff_key=tariff.key if tariff else sub.tariff_key,
purchased_hwid_devices=purchased_devices,
hwid_valid_from=valid_from,
hwid_valid_until=valid_until,
hwid_pricing_period_months=getattr(payment, "hwid_pricing_period_months", None),
hwid_proration_ratio=getattr(payment, "hwid_proration_ratio", None),
hwid_full_price=getattr(payment, "hwid_full_price", None),
)
updated_sub = await subscription_dal.update_subscription(
session,
@@ -102,16 +345,30 @@ class HwidDeviceMixin:
)
return None
final_subscription_url = updated_panel.get("subscriptionUrl")
final_panel_short_uuid = updated_panel.get(
"shortUuid", getattr(updated_sub, "panel_subscription_uuid", None)
)
await tariff_dal.create_hwid_device_purchase(
session,
subscription_id=updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_devices=purchased_devices,
valid_from=valid_from,
valid_until=valid_until,
)
return {
"subscription_id": updated_sub.subscription_id,
"end_date": updated_sub.end_date,
"is_active": True,
"panel_user_uuid": db_user.panel_user_uuid,
"panel_short_uuid": final_panel_short_uuid,
"subscription_url": final_subscription_url,
"hwid_device_limit": effective_hwid_limit,
"extra_hwid_devices": new_extra_devices,
"purchased_hwid_devices": purchased_devices,
"tariff_key": tariff.key if tariff else sub.tariff_key,
"hwid_devices_valid_from": valid_from,
"hwid_devices_valid_until": valid_until,
"hwid_devices_renewal": renewal,
}
@@ -23,8 +23,15 @@ class SubscriptionLifecycleMixin:
if not sub:
return None
before_tariff_key = sub.tariff_key
options = self.calculate_tariff_switch_options(sub, target)
now = datetime.now(timezone.utc)
options = await self.calculate_tariff_switch_options_with_hwid(session, sub, target)
converted_hwid_purchase_ids = list(options.get("convertible_hwid_purchase_ids") or [])
if converted_hwid_purchase_ids:
await tariff_dal.expire_hwid_device_purchases(
session,
purchase_ids=converted_hwid_purchase_ids,
at=now,
)
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
premium_baseline = target.premium_monthly_bytes
@@ -44,8 +51,20 @@ class SubscriptionLifecycleMixin:
}
converted_bytes = None
base_hwid_limit = self._base_hwid_limit_for_tariff(target)
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
try:
extra_hwid_devices = await tariff_dal.sum_active_hwid_devices(
session,
subscription_id=sub.subscription_id,
at=now,
)
except Exception:
logging.exception(
"Failed to recalculate HWID devices during tariff switch for user %s",
user_id,
)
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
update_data["hwid_device_limit"] = base_hwid_limit
update_data["extra_hwid_devices"] = extra_hwid_devices
if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes
@@ -154,6 +173,8 @@ class SubscriptionLifecycleMixin:
if updated.end_date and target.billing_model == "period"
else None,
"converted_bytes": converted_bytes,
"converted_hwid_value_rub": options.get("converted_hwid_value_rub"),
"converted_hwid_days": options.get("converted_hwid_days"),
"eff_price_before": sub.effective_monthly_price_rub,
"eff_price_after": updated.effective_monthly_price_rub,
},
@@ -236,7 +257,7 @@ class SubscriptionLifecycleMixin:
payment_db_id=payment_db_id,
provider=provider,
)
if sale_mode_base in {"hwid_device", "hwid_devices"}:
if sale_mode_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
target_devices = int(traffic_gb if traffic_gb is not None else months)
return await self.activate_hwid_device_topup(
session=session,
@@ -246,6 +267,7 @@ class SubscriptionLifecycleMixin:
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff_key,
renewal=sale_mode_base == "hwid_devices_renewal",
)
if sale_mode_base == "tariff_upgrade":
if not tariff_key:
@@ -378,7 +400,23 @@ class SubscriptionLifecycleMixin:
)
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
extra_hwid_devices = 0
hwid_devices_valid_until = None
if current_active_sub:
try:
hwid_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=current_active_sub.subscription_id,
at=datetime.now(timezone.utc),
)
extra_hwid_devices = int(hwid_summary.get("active_devices") or 0)
hwid_devices_valid_until = hwid_summary.get("active_until")
except Exception:
logging.exception(
"Failed to recalculate active HWID devices for renewal of user %s",
user_id,
)
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
premium_topup_balance_bytes = int(
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
)
@@ -497,6 +535,8 @@ class SubscriptionLifecycleMixin:
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None,
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
"hwid_devices_valid_until": hwid_devices_valid_until,
}
async def extend_active_subscription_days(
@@ -760,9 +800,48 @@ class SubscriptionLifecycleMixin:
if local_active_sub
else False
)
hwid_entitlement_summary: Dict[str, Any] = {}
active_extra_hwid_devices = (
int(local_active_sub.extra_hwid_devices or 0) if local_active_sub else 0
)
if local_active_sub:
try:
hwid_entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=local_active_sub.subscription_id,
at=datetime.now(timezone.utc),
)
active_extra_hwid_devices = int(hwid_entitlement_summary.get("active_devices") or 0)
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
await subscription_dal.update_subscription(
session,
local_active_sub.subscription_id,
{"extra_hwid_devices": active_extra_hwid_devices},
)
local_active_sub.extra_hwid_devices = active_extra_hwid_devices
except Exception:
logging.exception(
"Failed to load HWID entitlement summary for subscription %s",
local_active_sub.subscription_id,
)
base_hwid_limit_for_payload = (
local_active_sub.hwid_device_limit
if local_active_sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
expected_hwid_limit = self._effective_hwid_limit(
base_hwid_limit_for_payload,
active_extra_hwid_devices,
)
if expected_hwid_limit is not None:
hwid_limit = expected_hwid_limit
return {
"user_id": panel_user_data.get("uuid"),
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
or panel_user_data.get("shortUuid")
or (local_active_sub.panel_subscription_uuid if local_active_sub else None),
"panel_short_uuid": panel_user_data.get("shortUuid"),
"end_date": panel_end_date,
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link,
@@ -816,9 +895,9 @@ class SubscriptionLifecycleMixin:
"base_hwid_device_limit": local_active_sub.hwid_device_limit
if local_active_sub
else None,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0)
if local_active_sub
else 0,
"extra_hwid_devices": active_extra_hwid_devices,
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"),
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
@@ -57,18 +57,14 @@ class PanelIdentityMixin:
return f"em_{referral_code}"
def _panel_description_for_user(self, db_user: User) -> str:
lines = [
db_user.email or "",
db_user.username or "",
db_user.first_name or "",
db_user.last_name or "",
]
return "\n".join(line for line in lines if line).strip()
return panel_description_from_profile(
db_user.username,
db_user.first_name,
db_user.last_name,
)
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"description": self._panel_description_for_user(db_user),
}
payload: Dict[str, Any] = {}
telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id:
payload["telegramId"] = telegram_id
@@ -26,6 +26,11 @@ class PaymentContextMixin:
tariff_key: Optional[str],
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
hwid_valid_from: Optional[datetime] = None,
hwid_valid_until: Optional[datetime] = None,
hwid_pricing_period_months: Optional[int] = None,
hwid_proration_ratio: Optional[float] = None,
hwid_full_price: Optional[float] = None,
) -> None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
@@ -34,6 +39,16 @@ class PaymentContextMixin:
payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb
payment.purchased_hwid_devices = purchased_hwid_devices
if hwid_valid_from is not None:
payment.hwid_valid_from = hwid_valid_from
if hwid_valid_until is not None:
payment.hwid_valid_until = hwid_valid_until
if hwid_pricing_period_months is not None:
payment.hwid_pricing_period_months = hwid_pricing_period_months
if hwid_proration_ratio is not None:
payment.hwid_proration_ratio = hwid_proration_ratio
if hwid_full_price is not None:
payment.hwid_full_price = hwid_full_price
await session.flush()
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
@@ -95,6 +110,7 @@ class PaymentContextMixin:
except Exception:
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
i18n = getattr(self, "i18n", None)
try:
content = render_payment_success(
@@ -108,8 +124,9 @@ class PaymentContextMixin:
end_date_text=end_date_text,
dashboard_url=dashboard_url,
provider_label=provider_label,
i18n=i18n,
)
email_service = EmailAuthService(self.settings)
email_service = EmailAuthService(self.settings, i18n)
await email_service.send_rendered_email(email=recipient, content=content)
except Exception:
logging.exception("Failed to send payment success email to user %s", db_user.user_id)
@@ -370,3 +370,89 @@ class TariffMixin:
}
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
@staticmethod
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
async def _hwid_conversion_credit(
self,
session: AsyncSession,
sub: Subscription,
*,
at: datetime,
) -> Dict[str, Any]:
entries = await tariff_dal.get_hwid_device_value_entries(
session,
subscription_id=sub.subscription_id,
at=at,
)
value_rub = 0.0
purchase_ids: List[int] = []
skipped_devices = 0
for entry in entries:
currency = str(entry.get("currency") or "").upper()
if currency in {"XTR", "STARS", "STAR"}:
skipped_devices += int(entry.get("purchased_devices") or 0)
continue
amount = float(entry.get("amount") or 0)
if amount <= 0:
continue
valid_from = (
self._aware_utc(entry.get("valid_from"))
or self._aware_utc(entry.get("created_at"))
or at
)
valid_until = self._aware_utc(entry.get("valid_until"))
if not valid_until or valid_until <= at or valid_from >= valid_until:
continue
total_seconds = max(1.0, (valid_until - valid_from).total_seconds())
remaining_start = max(at, valid_from)
remaining_seconds = max(0.0, (valid_until - remaining_start).total_seconds())
if remaining_seconds <= 0:
continue
value_rub += amount * (remaining_seconds / total_seconds)
purchase_ids.append(int(entry["purchase_id"]))
return {
"value_rub": value_rub,
"purchase_ids": purchase_ids,
"skipped_devices": skipped_devices,
}
async def calculate_tariff_switch_options_with_hwid(
self,
session: AsyncSession,
sub: Subscription,
target_tariff: Tariff,
) -> Dict[str, Any]:
options = dict(self.calculate_tariff_switch_options(sub, target_tariff))
now = datetime.now(timezone.utc)
credit = await self._hwid_conversion_credit(session, sub, at=now)
value_rub = float(credit.get("value_rub") or 0)
options["converted_hwid_value_rub"] = round(value_rub, 2)
options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
if value_rub <= 0:
return options
if options.get("mode") == "period_to_period":
target_monthly = float(options.get("target_monthly_rub") or 0)
hwid_days = math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
options["converted_hwid_days"] = max(0, hwid_days)
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
options["paid_diff_rub"] = max(
0,
math.ceil(float(options.get("paid_diff_rub") or 0) - value_rub),
)
return options
if options.get("mode") == "period_to_traffic":
rub_per_gb = float(options.get("rub_per_gb") or 0)
hwid_gb = math.floor(value_rub / rub_per_gb) if rub_per_gb > 0 else 0
options["converted_hwid_gb"] = max(0, hwid_gb)
options["converted_gb"] = int(options.get("converted_gb") or 0) + max(0, hwid_gb)
return options
@@ -53,7 +53,9 @@ class TrafficMixin:
current_used = active_sub.traffic_used_bytes
purchase_bytes = self.gb_to_bytes(traffic_gb)
extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0)
extra_hwid_devices = (
await self._active_hwid_extra_devices_for_sub(session, active_sub) if active_sub else 0
)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0))
@@ -222,10 +224,8 @@ class TrafficMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
@@ -235,6 +235,7 @@ class TrafficMixin:
"is_throttled": False,
"tariff_key": tariff.key,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
},
)
panel_payload = self._build_panel_update_payload(
@@ -349,24 +350,23 @@ class TrafficMixin:
},
)
panel_payload = {
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
),
}
updated_panel = await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
desired_squads = self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
)
if not updated_panel or updated_panel.get("error"):
panel_updated = await self._sync_panel_squads_if_needed(
db_user.panel_user_uuid,
desired_squads,
user_id=user_id,
source="premium_topup",
)
if not panel_updated:
# Otherwise the user pays for premium top-up but the panel never
# re-grants premium squad access (the most common case here is
# transitioning from premium_is_limited=True back to False).
logging.warning(
"Panel user details update FAILED for premium top-up user %s. Response: %s",
"Panel user details update FAILED for premium top-up user %s.",
user_id,
updated_panel,
)
return None
await tariff_dal.create_traffic_topup(
@@ -432,11 +432,17 @@ class TrafficMixin:
squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited)
try:
await self.panel_service.update_user_details_on_panel(
panel_updated = await self._sync_panel_squads_if_needed(
db_user.panel_user_uuid,
{"uuid": db_user.panel_user_uuid, "activeInternalSquads": squads},
log_response=False,
squads,
user_id=user_id,
source="admin_premium_override",
)
if not panel_updated:
logging.warning(
"sync_premium_squad_access_to_panel: panel update failed for user %s",
user_id,
)
except Exception:
logging.exception(
"sync_premium_squad_access_to_panel: failed to push squads for user %s", user_id
@@ -494,10 +500,8 @@ class TrafficMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
@@ -506,6 +510,7 @@ class TrafficMixin:
"traffic_limit_bytes": new_limit,
"is_throttled": False,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
},
)
panel_payload = self._build_panel_update_payload(
@@ -575,10 +580,9 @@ class TrafficMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
sub.extra_hwid_devices = extra_hwid_devices
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=sub.end_date,
@@ -679,17 +683,22 @@ class TrafficMixin:
"premium_period_start_at": premium_period_start,
},
)
panel_payload = {
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
),
}
desired_squads = self._panel_squads_for_tariff(
tariff,
include_premium=not premium_is_limited,
)
try:
await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
panel_updated = await self._sync_panel_squads_if_needed(
db_user.panel_user_uuid,
desired_squads,
user_id=user_id,
source="admin_premium_topup",
)
if not panel_updated:
logging.warning(
"admin_grant_premium_topup: panel update failed for user %s",
user_id,
)
except Exception:
logging.exception(
"admin_grant_premium_topup: failed to push panel update for user %s",
@@ -710,3 +719,128 @@ class TrafficMixin:
"premium_is_limited": premium_is_limited,
"granted_bytes": purchase_bytes,
}
async def _sync_panel_squads_if_needed(
self,
panel_user_uuid: str,
desired_squads: List[str],
*,
user_id: int,
source: str,
) -> bool:
match, current_set = await self._panel_squads_match(panel_user_uuid, desired_squads)
if match is True:
return True
desired_set = self._panel_squad_uuid_set(desired_squads)
self._log_panel_squad_patch(
source=source,
user_id=user_id,
panel_uuid=panel_user_uuid,
current_set=current_set,
desired_set=desired_set,
)
updated_panel = await self.panel_service.update_user_details_on_panel(
panel_user_uuid,
{"uuid": panel_user_uuid, "activeInternalSquads": desired_squads},
log_response=False,
)
if not updated_panel:
return False
return not (isinstance(updated_panel, dict) and updated_panel.get("error"))
async def _panel_squads_match(
self,
panel_user_uuid: str,
desired_squads: List[str],
) -> tuple[Optional[bool], Optional[set[str]]]:
try:
panel_user = await self.panel_service.get_user_by_uuid(
panel_user_uuid,
log_response=False,
)
except Exception:
logging.exception(
"Failed to fetch panel user %s before premium squad update",
panel_user_uuid,
)
return None, None
current_known, current_set = self._panel_active_squad_uuid_set(panel_user)
if not current_known:
return None, current_set
return current_set == self._panel_squad_uuid_set(desired_squads), current_set
@classmethod
def _panel_active_squad_uuid_set(
cls,
panel_user: Optional[dict],
) -> tuple[bool, set[str]]:
if not isinstance(panel_user, dict):
return False, set()
for key in (
"activeInternalSquads",
"active_internal_squads",
"activeInternalSquadUuids",
"active_internal_squad_uuids",
):
if key in panel_user:
return True, cls._panel_squad_uuid_set(panel_user.get(key))
return False, set()
@staticmethod
def _panel_squad_uuid_set(raw) -> set[str]:
if not isinstance(raw, (list, tuple, set)):
return set()
out: set[str] = set()
for item in raw:
if isinstance(item, dict):
nested_squad = item.get("internalSquad") or item.get("squad")
if not isinstance(nested_squad, dict):
nested_squad = {}
squad_uuid = (
item.get("uuid")
or item.get("internalSquadUuid")
or item.get("squadUuid")
or nested_squad.get("uuid")
)
if squad_uuid:
out.add(str(squad_uuid))
elif item:
out.add(str(item))
return out
def _log_panel_squad_patch(
self,
*,
source: str,
user_id: int,
panel_uuid: str,
current_set: Optional[set[str]],
desired_set: set[str],
) -> None:
logging.info(
"Sync panel PATCH: source=%s user_id=%s telegram_id=%s panel_uuid=%s "
"panel_view=full_fetch reasons=activeInternalSquads_mismatch "
"fields=activeInternalSquads payload_fields=activeInternalSquads changes=%s",
source,
user_id,
user_id,
panel_uuid,
"activeInternalSquads:%s->%s"
% (
self._format_panel_squad_set(current_set),
self._format_panel_squad_set(desired_set),
),
)
@staticmethod
def _format_panel_squad_set(value: Optional[set[str]]) -> str:
if value is None:
return "missing"
values = sorted(str(item) for item in value)
preview = ",".join(values[:4])
suffix = ",..." if len(values) > 4 else ""
text = f"[{len(values)}:{preview}{suffix}]"
if len(text) > 96:
return f"{text[:93]}..."
return text
@@ -62,6 +62,7 @@ class TrialSubscriptionMixin:
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"auto_renew_enabled": False,
"provider": "trial",
}
try:
await subscription_dal.upsert_subscription(session, trial_sub_data)
@@ -83,7 +84,15 @@ class TrialSubscriptionMixin:
status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
include_default_squads=False,
)
trial_squads = self.settings.parsed_trial_squad_uuids
if trial_squads:
panel_update_payload["activeInternalSquads"] = trial_squads
if self.settings.parsed_user_external_squad_uuid:
panel_update_payload["externalSquadUuid"] = (
self.settings.parsed_user_external_squad_uuid
)
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
+23 -10
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -125,7 +126,7 @@ class SupportService:
self.settings = settings
self.bot = bot
self.i18n = i18n
self.email_auth_service = email_auth_service or EmailAuthService(settings)
self.email_auth_service = email_auth_service or EmailAuthService(settings, i18n)
self.notification_service = notification_service or NotificationService(
bot,
settings,
@@ -134,6 +135,16 @@ class SupportService:
email_auth_service=self.email_auth_service,
)
@staticmethod
def _schedule_notification(coro, error_message: str, *error_args: Any) -> None:
async def _runner():
try:
await coro
except Exception:
logger.exception(error_message, *error_args)
asyncio.create_task(_runner(), name="support-notification")
async def _ensure_user_allowed(self, session, user_id: int) -> User:
user = await user_dal.get_user_by_id(session, user_id)
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
@@ -218,8 +229,8 @@ class SupportService:
await session.commit()
if notification_decision.send_telegram or notification_decision.send_email:
try:
await self.notification_service.notify_support_user_reply(
self._schedule_notification(
self.notification_service.notify_support_user_reply(
ticket,
message,
user,
@@ -227,9 +238,10 @@ class SupportService:
unread_count=int(ticket.unread_admin_count or 0),
send_telegram=notification_decision.send_telegram,
send_email=notification_decision.send_email,
)
except Exception:
logger.exception("Failed to notify about support user reply %s", ticket_id)
),
"Failed to notify about support user reply %s",
ticket_id,
)
return ticket, message
async def reply_as_admin(
@@ -275,10 +287,11 @@ class SupportService:
await session.commit()
if user and not is_internal_note:
try:
await self.notification_service.notify_support_admin_reply(ticket, message, user)
except Exception:
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
self._schedule_notification(
self.notification_service.notify_support_admin_reply(ticket, message, user),
"Failed to notify user about support admin reply %s",
ticket_id,
)
return ticket, message
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
+310 -17
View File
@@ -2,7 +2,7 @@ import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import Optional
from typing import Any, Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
@@ -30,6 +30,7 @@ PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100
TARIFF_WORKER_BATCH_SIZE = 50
TARIFF_WORKER_PANEL_CONCURRENCY = 10
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
class TariffTrafficWorker:
@@ -51,6 +52,7 @@ class TariffTrafficWorker:
self._stopped = asyncio.Event()
self._premium_nodes_cache = {}
self._premium_node_usage_tick_cache = {}
self._premium_squad_match_cache = {}
async def _user_lang(self, session: AsyncSession, user_id: int) -> str:
try:
@@ -148,6 +150,7 @@ class TariffTrafficWorker:
return
panel_users_by_uuid = await self._prefetch_panel_users_by_uuid(subs)
panel_view = "list" if panel_users_by_uuid is not None else "full_fetch"
semaphore = asyncio.Semaphore(TARIFF_WORKER_PANEL_CONCURRENCY)
async def _fetch_panel(sub: Subscription) -> dict:
@@ -212,6 +215,7 @@ class TariffTrafficWorker:
if tariff.billing_model == "period":
await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy)
await self._sync_hwid_device_limit(session, sub, tariff, panel_data)
await self._maybe_warn_or_throttle(
session,
sub,
@@ -230,6 +234,7 @@ class TariffTrafficWorker:
now,
panel_username=panel_username,
panel_user_dict=panel_data,
panel_view=panel_view,
)
async def _prefetch_panel_users_by_uuid(
@@ -321,8 +326,7 @@ class TariffTrafficWorker:
sub.skip_notifications = True
sub.status_from_panel = "PANEL_USER_NOT_FOUND"
logging.warning(
"TariffTrafficWorker: deactivated subscription %s because panel user %s "
"is missing",
"TariffTrafficWorker: deactivated subscription %s because panel user %s is missing",
sub.subscription_id,
current_uuid,
)
@@ -374,6 +378,64 @@ class TariffTrafficWorker:
sub.panel_user_uuid, payload, log_response=False
)
async def _sync_hwid_device_limit(
self,
session: AsyncSession,
sub: Subscription,
tariff,
panel_data: dict,
) -> None:
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self.subscription_service._base_hwid_limit_for_tariff(tariff)
)
active_extra = await tariff_dal.sum_active_hwid_devices(
session,
subscription_id=sub.subscription_id,
at=datetime.now(timezone.utc),
)
update_data = {}
if sub.hwid_device_limit != base_hwid_limit:
update_data["hwid_device_limit"] = base_hwid_limit
if int(sub.extra_hwid_devices or 0) != active_extra:
update_data["extra_hwid_devices"] = active_extra
if update_data:
for key, value in update_data.items():
setattr(sub, key, value)
effective_limit = self.subscription_service._effective_hwid_limit(
base_hwid_limit,
active_extra,
)
if effective_limit is None:
return
try:
panel_limit = panel_data.get("hwidDeviceLimit")
panel_limit_int = int(panel_limit) if panel_limit is not None else None
except (TypeError, ValueError):
panel_limit_int = None
if panel_limit_int == effective_limit:
return
payload = self.subscription_service._build_panel_update_payload(
panel_user_uuid=sub.panel_user_uuid,
expire_at=sub.end_date,
hwid_device_limit=effective_limit,
include_default_squads=False,
)
updated_panel = await self.panel_service.update_user_details_on_panel(
sub.panel_user_uuid,
payload,
log_response=False,
)
if not updated_panel or updated_panel.get("error"):
logging.warning(
"TariffTrafficWorker: failed to sync HWID limit for subscription %s: %s",
sub.subscription_id,
updated_panel,
)
async def _maybe_warn_or_throttle(
self,
session: AsyncSession,
@@ -462,6 +524,7 @@ class TariffTrafficWorker:
*,
panel_username: Optional[str] = None,
panel_user_dict: Optional[dict] = None,
panel_view: str = "unknown",
) -> None:
if not getattr(tariff, "premium_squad_uuids", None):
if (
@@ -482,12 +545,22 @@ class TariffTrafficWorker:
return
premium_period_start = month_start(now)
same_period = bool(getattr(sub, "premium_period_start_at", None) == premium_period_start)
same_period = self._same_premium_period(
getattr(sub, "premium_period_start_at", None),
premium_period_start,
)
premium_baseline = int(tariff.premium_monthly_bytes or 0)
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = (
int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0
)
premium_topup_balance = await self._repair_premium_topup_balance_from_ledger(
session,
sub,
premium_period_start,
premium_topup_balance,
premium_topup_used,
)
# Admin-side overrides for free gifted premium traffic.
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
@@ -532,22 +605,59 @@ class TariffTrafficWorker:
should_limit = False
else:
should_limit = premium_used >= premium_limit
panel_needs_update = bool(sub.premium_is_limited) != should_limit
access_state_changed = bool(sub.premium_is_limited) != should_limit
desired_squads = self.subscription_service._panel_squads_for_tariff(
tariff,
include_premium=not should_limit,
)
desired_set = self._internal_squad_uuid_set(desired_squads)
if isinstance(panel_user_dict, dict):
current_known = False
current_raw = None
for key in ("activeInternalSquads", "active_internal_squads"):
if key in panel_user_dict:
current_raw = panel_user_dict.get(key)
current_known = True
break
if current_known and desired_set != self._internal_squad_uuid_set(current_raw):
squad_match_cache_key = self._premium_squad_match_cache_key(
sub.panel_user_uuid,
desired_set,
)
panel_needs_update = access_state_changed
panel_user_for_report = panel_user_dict
panel_view_for_report = panel_view
panel_update_reasons: list[str] = []
if access_state_changed:
panel_update_reasons.append(
"premium_access_limited" if should_limit else "premium_access_restored"
)
current_known, current_set = self._panel_active_squad_uuid_set(panel_user_dict)
if current_known:
current_mismatch = desired_set != current_set
if not current_mismatch:
panel_needs_update = False
elif panel_view == "list":
if self._premium_squad_match_cache_is_fresh(squad_match_cache_key):
panel_needs_update = False
else:
full_panel_user = await self._get_full_panel_user_for_squad_confirmation(
sub.panel_user_uuid,
)
full_known, full_set = self._panel_active_squad_uuid_set(full_panel_user)
if full_known:
panel_user_for_report = full_panel_user
panel_view_for_report = "full_fetch"
if desired_set != full_set:
panel_needs_update = True
panel_update_reasons.append("activeInternalSquads_mismatch")
else:
self._remember_premium_squad_match(squad_match_cache_key)
panel_needs_update = False
elif not access_state_changed:
panel_needs_update = False
else:
panel_needs_update = True
panel_update_reasons.append("activeInternalSquads_mismatch")
if (
not panel_needs_update
and current_known
and desired_set == current_set
and panel_view != "list"
):
self._remember_premium_squad_match(squad_match_cache_key)
sub.premium_baseline_bytes = premium_baseline
sub.premium_topup_balance_bytes = premium_topup_balance
sub.premium_topup_used_bytes = premium_topup_used
@@ -567,11 +677,21 @@ class TariffTrafficWorker:
return
squads = desired_squads
await self.panel_service.update_user_details_on_panel(
self._log_premium_squad_panel_patch(
sub=sub,
panel_uuid=sub.panel_user_uuid,
update_payload={"uuid": sub.panel_user_uuid, "activeInternalSquads": squads},
current_panel_user=panel_user_for_report,
reasons=panel_update_reasons or ["premium_squad_sync"],
panel_view=panel_view_for_report,
)
updated_panel_user = await self.panel_service.update_user_details_on_panel(
sub.panel_user_uuid,
{"uuid": sub.panel_user_uuid, "activeInternalSquads": squads},
log_response=False,
)
if updated_panel_user:
self._remember_premium_squad_match(squad_match_cache_key)
logging.info(
"Premium squad access %s for user %s tariff %s: %s/%s bytes",
"limited" if should_limit else "restored",
@@ -581,20 +701,193 @@ class TariffTrafficWorker:
premium_limit,
)
async def _get_full_panel_user_for_squad_confirmation(
self,
panel_user_uuid: str,
) -> Optional[dict]:
try:
return await self.panel_service.get_user_by_uuid(
panel_user_uuid,
log_response=False,
)
except Exception:
logging.exception(
"TariffTrafficWorker: failed to confirm panel squads for user %s",
panel_user_uuid,
)
return None
@staticmethod
def _same_premium_period(value: Optional[datetime], premium_period_start: datetime) -> bool:
if value is None:
return False
try:
return month_start(value) == premium_period_start
except Exception:
return False
async def _repair_premium_topup_balance_from_ledger(
self,
session: AsyncSession,
sub: Subscription,
premium_period_start: datetime,
premium_topup_balance: int,
premium_topup_used: int,
) -> int:
ledger_total = await self._premium_topup_ledger_total(
session,
int(getattr(sub, "subscription_id", 0) or 0),
premium_period_start,
)
if ledger_total is None:
return premium_topup_balance
tracked_total = max(0, int(premium_topup_balance or 0)) + max(
0,
int(premium_topup_used or 0),
)
if ledger_total <= tracked_total:
return premium_topup_balance
repaired_bytes = ledger_total - tracked_total
logging.warning(
"Premium top-up balance repaired from ledger for user %s subscription %s: "
"tracked=%s ledger=%s repaired=%s",
getattr(sub, "user_id", None),
getattr(sub, "subscription_id", None),
tracked_total,
ledger_total,
repaired_bytes,
)
return premium_topup_balance + repaired_bytes
async def _premium_topup_ledger_total(
self,
session: AsyncSession,
subscription_id: int,
premium_period_start: datetime,
) -> Optional[int]:
if not subscription_id or not isinstance(session, AsyncSession):
return None
try:
return await tariff_dal.sum_traffic_topups(
session,
subscription_id=subscription_id,
kinds=["premium_topup", "admin_premium_topup"],
created_at_gte=premium_period_start,
)
except Exception:
logging.exception(
"TariffTrafficWorker: failed to read premium top-up ledger for subscription %s",
subscription_id,
)
return None
@staticmethod
def _premium_squad_match_cache_key(
panel_user_uuid: str,
desired_set: set[str],
) -> tuple[str, tuple[str, ...]]:
return str(panel_user_uuid), tuple(sorted(desired_set))
def _premium_squad_match_cache_is_fresh(self, cache_key: tuple[str, tuple[str, ...]]) -> bool:
cached_at = self._premium_squad_match_cache.get(cache_key)
if not cached_at:
return False
return (
time.monotonic() - float(cached_at) < TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
)
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
self._premium_squad_match_cache[cache_key] = time.monotonic()
@classmethod
def _panel_active_squad_uuid_set(
cls,
panel_user_dict: Optional[dict],
) -> tuple[bool, set[str]]:
current_known, current_raw = cls._panel_active_squads_raw(panel_user_dict)
return current_known, cls._internal_squad_uuid_set(current_raw)
@staticmethod
def _panel_active_squads_raw(panel_user_dict: Optional[dict]) -> tuple[bool, Any]:
if not isinstance(panel_user_dict, dict):
return False, None
for key in (
"activeInternalSquads",
"active_internal_squads",
"activeInternalSquadUuids",
"active_internal_squad_uuids",
):
if key in panel_user_dict:
return True, panel_user_dict.get(key)
return False, None
def _log_premium_squad_panel_patch(
self,
*,
sub: Subscription,
panel_uuid: str,
update_payload: dict[str, Any],
current_panel_user: Optional[dict],
reasons: list[str],
panel_view: str,
) -> None:
current_known, current_set = self._panel_active_squad_uuid_set(current_panel_user)
desired_set = self._internal_squad_uuid_set(update_payload.get("activeInternalSquads"))
fields = "none" if current_known and current_set == desired_set else "activeInternalSquads"
logging.info(
"Sync panel PATCH: source=%s user_id=%s telegram_id=%s panel_uuid=%s "
"panel_view=%s reasons=%s fields=%s payload_fields=%s changes=%s",
"premium_squad_limit",
getattr(sub, "user_id", None),
getattr(sub, "user_id", None),
panel_uuid,
panel_view,
",".join(reasons),
fields,
"activeInternalSquads",
"activeInternalSquads:%s->%s"
% (
self._format_squad_uuid_set(current_set if current_known else None),
self._format_squad_uuid_set(desired_set),
),
)
@staticmethod
def _internal_squad_uuid_set(raw) -> set[str]:
if not isinstance(raw, list):
if not isinstance(raw, (list, tuple, set)):
return set()
out: set[str] = set()
for item in raw:
if isinstance(item, dict):
u = item.get("uuid") or item.get("internalSquadUuid") or item.get("squadUuid")
nested_squad = item.get("internalSquad") or item.get("squad")
if not isinstance(nested_squad, dict):
nested_squad = {}
u = (
item.get("uuid")
or item.get("internalSquadUuid")
or item.get("squadUuid")
or nested_squad.get("uuid")
)
if u:
out.add(str(u))
elif item:
out.add(str(item))
return out
@staticmethod
def _format_squad_uuid_set(value: Optional[set[str]]) -> str:
if value is None:
return "missing"
values = sorted(str(item) for item in value)
preview = ",".join(values[:4])
suffix = ",..." if len(values) > 4 else ""
text = f"[{len(values)}:{preview}{suffix}]"
if len(text) > 96:
return f"{text[:93]}..."
return text
@staticmethod
def _fmt_bytes(value: int) -> str:
size = float(max(0, int(value or 0)))
+84
View File
@@ -0,0 +1,84 @@
"""Helpers for Telegram bot install-guide links."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from bot.utils.mini_app_url import (
subscription_mini_app_install_url,
subscription_public_install_url,
)
from config.subscription_guides_config import subscription_guides_available
from db.dal import subscription_dal
@dataclass(frozen=True)
class InstallGuideLinks:
personal_url: Optional[str] = None
public_share_url: Optional[str] = None
def bot_install_guides_enabled(settings: Any) -> bool:
return bool(
getattr(settings, "SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED", False)
and subscription_guides_available(settings)
and subscription_mini_app_install_url(settings)
)
def bot_install_guide_url(settings: Any) -> Optional[str]:
if not bot_install_guides_enabled(settings):
return None
return subscription_mini_app_install_url(settings)
async def ensure_user_install_guide_links(
session: AsyncSession,
settings: Any,
user_id: int,
panel_user_uuid: Optional[str] = None,
local_subscription: Optional[Any] = None,
) -> InstallGuideLinks:
personal_url = bot_install_guide_url(settings)
if not personal_url:
return InstallGuideLinks()
public_share_url = None
try:
local_sub = (
local_subscription
if local_subscription is not None
else await subscription_dal.get_active_subscription_by_user_id(
session,
user_id,
panel_user_uuid,
)
)
if local_sub is not None:
share_token = await subscription_dal.ensure_install_share_token(session, local_sub)
public_share_url = subscription_public_install_url(settings, share_token)
except Exception:
logging.exception("Failed to resolve install guide share link for user %s.", user_id)
return InstallGuideLinks(personal_url=personal_url, public_share_url=public_share_url)
def append_install_share_link_text(
text: str,
translator: Any,
public_share_url: Optional[str],
) -> str:
if not public_share_url:
return text
try:
share_line = translator(
"install_guide_share_link_line",
install_share_link=public_share_url,
)
except Exception:
share_line = f"\n\nInstall guide:\n<code>{public_share_url}</code>"
return f"{text}{share_line}"
+35 -1
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from config.settings import Settings
from db.dal.subscription_dal import normalize_install_share_token
def append_query_params(base_url: str, params: dict[str, str]) -> str:
@@ -31,3 +32,36 @@ def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[s
return None
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
return append_query_params(base, {"topup": normalized})
def subscription_mini_app_path_url(settings: Settings, path: str) -> Optional[str]:
"""Return a Mini App URL with ``path`` appended to the configured app base."""
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
if not base:
return None
normalized_path = f"/{str(path or '').lstrip('/')}"
return f"{base.rstrip('/')}{normalized_path}"
def subscription_mini_app_install_url(settings: Settings) -> Optional[str]:
"""Return the personal embedded install guide URL."""
return subscription_mini_app_path_url(settings, "/install")
def subscription_mini_app_trial_url(settings: Settings) -> Optional[str]:
"""Return the trial activation URL inside the Mini App."""
return subscription_mini_app_path_url(settings, "/trial")
def subscription_public_install_url(settings: Settings, share_token: str) -> Optional[str]:
"""Return the public install guide URL for a normalized share token."""
token = normalize_install_share_token(share_token)
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
if not token or not base:
return None
parts = urlsplit(base)
if parts.scheme and parts.netloc:
public_base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
public_base = base.rstrip("/")
return f"{public_base.rstrip('/')}/s/{quote(token)}"

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