Compare commits

..
86 Commits
Author SHA1 Message Date
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
3252a8andGitHub 4b7c58a6c9 Merge pull request #1 from 3252a8/dev
Stabilize account linking, auth, and admin settings
2026-05-22 22:24:07 +03:00
3252a8 19aa2ec9c9 fix: log account merge notifications 2026-05-22 22:02:28 +03:00
3252a8 c4c5b8e3a0 fix: merge active sub email account with expired sub telegram account 2026-05-22 18:23:38 +03:00
3252a8 33a7dbc0e6 fix: app version display pattern in admin panel sidebar 2026-05-22 18:23:11 +03:00
3252a8 72921b9a8f fix: telegram oauth in logged in email account 2026-05-22 17:48:17 +03:00
3252a8 835436fa1a fix: ensure web app pay button is spawning when enable payment provider 2026-05-22 16:29:59 +03:00
3252a8 648f4ba4bc feat: show payment provider webhook urls 2026-05-22 15:57:49 +03:00
3252a8 f5023dc46b fix: repair missing panel user references 2026-05-22 15:39:51 +03:00
3252a8 4c0a798050 fix: load admin assets from stable paths 2026-05-22 15:39:45 +03:00
3252a8 cfe7c4ec5f fix: style mobile admin button 2026-05-22 15:11:28 +03:00
3252a8 c82779e15b fix: restore telegram mini app auth 2026-05-22 14:47:01 +03:00
3252a8 9e56715e77 fix: cover email telegram account linking 2026-05-22 14:41:56 +03:00
3252a8 2254b9ad19 fix: avoid repeated panel identity syncs 2026-05-22 14:11:38 +03:00
3252a8 81707d9c7c fix: clean merged panel identities 2026-05-22 14:11:31 +03:00
3252a8 9e61a3d8a8 feat: install instruction inside web app 2026-05-22 13:58:34 +03:00
3252a8 8a38524774 docs: add url to remnawave 2026-05-21 23:09:22 +03:00
3252a8 87d3f1b410 docs: add nginx example 2026-05-21 22:40:53 +03:00
228 changed files with 28714 additions and 1508 deletions
+1
View File
@@ -16,6 +16,7 @@ frontend/node_modules/
deploy/compose/docker-compose-dev.yml deploy/compose/docker-compose-dev.yml
data/* data/*
!data/tariffs.example.json !data/tariffs.example.json
!data/locales-overrides.example.json
# CI # CI
+1
View File
@@ -63,3 +63,4 @@ locales/en_backup.json
db/models_old.py db/models_old.py
data/* data/*
!data/tariffs.example.json !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.
+15 -7
View File
@@ -2,7 +2,7 @@
![Remnawave Minishop](docs/remnawave-minishop.webp) ![Remnawave Minishop](docs/remnawave-minishop.webp)
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду. 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/migration-to-minishop.md).
@@ -14,6 +14,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика; - просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов; - покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email; - Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа; - пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars; - оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку; - тикеты поддержки в Web App и внешняя ссылка на поддержку;
@@ -26,6 +27,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- список пользователей с поиском, фильтрами и колонкой premium-трафика; - список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`; - блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами; - редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью. - ручная синхронизация пользователей и подписок с панелью.
## Документация ## Документация
@@ -34,7 +36,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам. - [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика. - [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога. - [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки. - [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход, инструкции установки и реферальные ссылки.
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки. - [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. - [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`). - [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
@@ -86,7 +88,7 @@ docker compose logs -f backend worker frontend
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave; - `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- остальные настройки удобнее задать в Web App админке. - остальные настройки удобнее задать в Web App админке.
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку и уведомления через UI. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md). После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/env-vars.md](docs/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/tariffs.md](docs/tariffs.md).
@@ -94,6 +96,7 @@ docker compose logs -f backend worker frontend
```bash ```bash
mkdir -p data/themes data/webapp-logo data/webapp-emoji mkdir -p data/themes data/webapp-logo data/webapp-emoji
touch data/locales-overrides.json
chown -R 10001:10001 data chown -R 10001:10001 data
chmod -R u+rwX data chmod -R u+rwX data
``` ```
@@ -107,13 +110,18 @@ docker compose up -d --build
# Логи приложения # Логи приложения
docker compose logs -f backend worker frontend docker compose logs -f backend worker frontend
# Запуск с Caddy # Готовые production-примеры
docker compose -f deploy/compose/docker-compose-caddy.yml up -d 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
``` ```
Для production-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples): там отдельно собраны варианты для Caddy, Nginx, Newt/Pangolin и прямой публикации портов без reverse proxy. В каждой папке рядом лежат `docker-compose.yml`, `.env.example`, README и нужный proxy-конфиг.
GHCR image names for releases: GHCR image names for releases:
- `ghcr.io/3252a8/remnawave-minishop-backend` - `ghcr.io/3252a8/remnawave-minishop-backend`
+1 -1
View File
@@ -30,7 +30,7 @@ def build_core_services(
subscription_service = SubscriptionService(settings, panel_service, bot, i18n) subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n) referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(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( notification_service = NotificationService(
bot, bot,
settings, settings,
+2
View File
@@ -19,6 +19,7 @@ from bot.app.web.admin_api_impl import (
sync as _sync, sync as _sync,
tariffs as _tariffs, tariffs as _tariffs,
themes as _themes, themes as _themes,
translations as _translations,
users as _users, users as _users,
) )
@@ -38,6 +39,7 @@ _MODULES = (
_settings, _settings,
_tariffs, _tariffs,
_themes, _themes,
_translations,
_panel, _panel,
_routes, _routes,
) )
@@ -41,6 +41,7 @@ from config.tariffs_config import TariffsConfig
from db.dal import ( from db.dal import (
ad_dal, ad_dal,
app_settings_dal, app_settings_dal,
locale_overrides_dal,
message_log_dal, message_log_dal,
panel_sync_dal, panel_sync_dal,
payment_dal, payment_dal,
@@ -234,6 +234,7 @@ def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
"user_id": int(entry.user_id) if entry.user_id else None, "user_id": int(entry.user_id) if entry.user_id else None,
"telegram_username": entry.telegram_username, "telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name, "telegram_first_name": entry.telegram_first_name,
"email": getattr(getattr(entry, "author_user", None), "email", None),
"event_type": entry.event_type, "event_type": entry.event_type,
"content": entry.content, "content": entry.content,
"is_admin_event": bool(entry.is_admin_event), "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: async def admin_payments_export_route(request: web.Request) -> web.Response:
_require_admin_user_id(request) _require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"] 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_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", 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/payments/export.csv", admin_payments_export_route)
router.add_get("/api/admin/promos", admin_promos_list_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_get("/api/admin/settings", admin_settings_get_route)
router.add_patch("/api/admin/settings", admin_settings_patch_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_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
+37 -22
View File
@@ -1,5 +1,11 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 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: async def admin_settings_get_route(request: web.Request) -> web.Response:
@@ -13,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
overrides_by_key = {entry["key"]: entry for entry in overrides} overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload() fields = manifest_payload()
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
sections: Dict[str, Dict[str, Any]] = {} sections: Dict[str, Dict[str, Any]] = {}
for field in fields: for field in fields:
key = field["key"] key = field["key"]
@@ -26,14 +33,35 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
override = overrides_by_key.get(key) override = overrides_by_key.get(key)
value = current_value(settings, key) value = current_value(settings, key)
is_secret = bool(field.get("secret")) 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 = { response_field = {
**field, **field,
"value": "" if is_secret else value, "value": "" if is_secret else value,
"overridden": bool(override), "overridden": overridden,
"updated_at": override.get("updated_at") if override else None, "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: if is_secret:
response_field["has_value"] = bool(value) response_field["has_value"] = bool(value)
webhook_path = str(response_field.get("webhook_path") or "").strip()
if webhook_path:
if not webhook_path.startswith("/"):
webhook_path = f"/{webhook_path}"
response_field["webhook_path"] = webhook_path
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
if webhook_base_url:
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
sections[section_id]["fields"].append(response_field) sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
@@ -51,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
return _error(400, "invalid_updates") return _error(400, "invalid_updates")
if not isinstance(deletes, list): if not isinstance(deletes, list):
return _error(400, "invalid_deletes") 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( result = await update_overrides(
settings, settings,
@@ -65,26 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
status=400, status=400,
) )
# Bust the public webapp settings cache so users see new values immediately. await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
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)
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 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: 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) logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc)) return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache") await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) 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 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
import asyncio import asyncio
import hashlib import hashlib
@@ -65,11 +66,9 @@ def _bump_theme_asset_versions(
previous_theme = previous_by_key.get(key) previous_theme = previous_by_key.get(key)
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0) previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
current_version = int(theme.get("assets_version") or 1) current_version = int(theme.get("assets_version") or 1)
theme_changed = ( theme_changed = previous_theme is None or _theme_payload_for_version_compare(
previous_theme is None theme
or _theme_payload_for_version_compare(theme) ) != _theme_payload_for_version_compare(previous_theme)
!= _theme_payload_for_version_compare(previous_theme)
)
if theme_changed or (default_changed and key == config.default_theme): if theme_changed or (default_changed and key == config.default_theme):
theme["assets_version"] = max(previous_version + 1, current_version, 1) theme["assets_version"] = max(previous_version + 1, current_version, 1)
elif previous_version > current_version: 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) logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False return False
cache = request.app.get("webapp_settings_cache") await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
request.app["webapp_logo_cache"] = None
prune_unused_appearance_assets(settings)
return True 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) logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc)) return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache") await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok( return _ok(
{ {
@@ -0,0 +1,145 @@
# 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 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 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 import hashlib
from html import escape as html_escape 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"]) target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"] 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_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session: 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) ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok: if not ok:
await session.rollback() await session.rollback()
return _error(404, "not_found") return _error(404, "not_found")
await message_log_dal.create_message_log( await message_log_dal.create_message_log_no_commit(
session, session,
{ {
"user_id": actor_id, "user_id": actor_id if actor_id != target_id else None,
"event_type": "admin_delete_user_webapp", "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, "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)
+143 -20
View File
@@ -16,7 +16,7 @@ from typing import Any, List, Optional, Tuple
@dataclass(frozen=True) @dataclass(frozen=True)
class SettingField: class SettingField:
key: str 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 section: str
label: str label: str
description: str = "" description: str = ""
@@ -34,6 +34,13 @@ class SettingField:
SETTINGS_MANIFEST: List[SettingField] = [ SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ──────────────────────────────────────────────────── # ─── General ────────────────────────────────────────────────────
SettingField(
"WEBAPP_TITLE",
"string",
"general",
"Web App title",
placeholder="My subscription",
),
SettingField( SettingField(
"DEFAULT_LANGUAGE", "DEFAULT_LANGUAGE",
"string", "string",
@@ -117,9 +124,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
subsection="Remnawave", subsection="Remnawave",
), ),
# ─── Web app appearance ──────────────────────────────────────── # ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
),
SettingField( SettingField(
"SUBSCRIPTION_MINI_APP_URL", "SUBSCRIPTION_MINI_APP_URL",
"url", "url",
@@ -160,6 +164,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"), SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"), SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), 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 ──────────────────────────── # ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"), SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"), SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
@@ -177,37 +234,46 @@ SETTINGS_MANIFEST: List[SettingField] = [
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)" "TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
), ),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"), SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"pricing",
"Порядок методов оплаты",
"Через запятую, например: severpay,freekassa,yookassa,heleket",
),
SettingField( SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED", "SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"bool", "bool",
"pricing", "payments",
"Показывать описание подписки", "Показывать описание подписки",
"Текст появится перед выбором срока покупки или продления.", "Текст появится перед выбором срока покупки или продления.",
subsection="checkout",
), ),
SettingField( SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU", "SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"text", "text",
"pricing", "payments",
"Описание подписки (RU)", "Описание подписки (RU)",
"Русская версия текста на этапе оплаты.", "Русская версия текста на этапе оплаты.",
subsection="checkout",
), ),
SettingField( SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN", "SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"text", "text",
"pricing", "payments",
"Описание подписки (EN)", "Описание подписки (EN)",
"Английская версия текста на этапе оплаты.", "Английская версия текста на этапе оплаты.",
subsection="checkout",
), ),
# ─── Payment providers (toggles) ─────────────────────────────── # ─── Payment providers (toggles) ───────────────────────────────
# Common # Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="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( SettingField(
"PAYMENT_METHODS_ORDER", "PAYMENT_METHODS_ORDER",
"string", "string",
@@ -217,10 +283,38 @@ SETTINGS_MANIFEST: List[SettingField] = [
subsection="common", subsection="common",
), ),
# ─── Trial ───────────────────────────────────────────────────── # ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"), SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0), SettingField(
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0), "TRIAL_DURATION_DAYS",
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"), "int",
"pricing",
"Длительность триала (дней)",
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_LIMIT_GB",
"float",
"pricing",
"Лимит трафика триала (ГБ)",
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_STRATEGY",
"string",
"pricing",
"Стратегия сброса трафика триала",
subsection="trial",
),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
"pricing",
"Internal Squads для триала",
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
subsection="trial",
),
# ─── Referral program ────────────────────────────────────────── # ─── Referral program ──────────────────────────────────────────
SettingField( SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого" "REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
@@ -454,6 +548,18 @@ def manifest_keys() -> List[str]:
def coerce_value(field: SettingField, raw: Any) -> Any: def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field.""" """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 raw is None or (isinstance(raw, str) and raw.strip() == ""):
return None return None
@@ -507,18 +613,29 @@ def manifest_payload() -> List[dict]:
same value so existing UIs that only read ``placeholder`` also show the same value so existing UIs that only read ``placeholder`` also show the
hint inside the empty input. hint inside the empty input.
""" """
from bot.payment_providers import find_manifest_owner, manifest_field_default from bot.payment_providers import (
find_manifest_owner,
manifest_field_default,
provider_admin_only_pairs,
provider_webhook_metadata,
)
sections_order = { sections_order = {
"general": 1, "general": 1,
"appearance": 2, "appearance": 2,
"pricing": 3, "pricing": 11,
"payments": 4, "payments": 4,
"trial": 5, "trial": 5,
"referral": 6, "referral": 6,
"notifications": 7, "notifications": 7,
"support": 8, "support": 8,
"devices": 9, "devices": 9,
"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] = [] items: List[dict] = []
for field in aggregated_manifest(): for field in aggregated_manifest():
@@ -531,10 +648,12 @@ def manifest_payload() -> List[dict]:
) )
default_value: Optional[str] = None default_value: Optional[str] = None
webhook_metadata: Optional[dict] = None
owner = find_manifest_owner(field.key) owner = find_manifest_owner(field.key)
if owner is not None: if owner is not None:
spec, manifest_field = owner spec, manifest_field = owner
default_value = manifest_field_default(spec, manifest_field) default_value = manifest_field_default(spec, manifest_field)
webhook_metadata = provider_webhook_metadata(spec)
placeholder = field.placeholder placeholder = field.placeholder
if not placeholder and default_value: if not placeholder and default_value:
@@ -559,8 +678,12 @@ def manifest_payload() -> List[dict]:
"optional": field.optional, "optional": field.optional,
"secret": field.secret, "secret": field.secret,
} }
if field.key in exclusive_map:
item["mutually_exclusive_key"] = exclusive_map[field.key]
if default_value is not None: if default_value is not None:
item["default"] = default_value item["default"] = default_value
if webhook_metadata:
item.update(webhook_metadata)
if field.choices: if field.choices:
item["choices"] = [ item["choices"] = [
{ {
@@ -11,6 +11,7 @@ from bot.app.web.webapp import (
billing as _billing, billing as _billing,
common as _common, common as _common,
devices as _devices, devices as _devices,
guides as _guides,
payloads as _payloads, payloads as _payloads,
routes as _routes, routes as _routes,
serializers as _serializers, serializers as _serializers,
@@ -27,6 +28,7 @@ _MODULES = (
_serializers, _serializers,
_billing, _billing,
_devices, _devices,
_guides,
_support, _support,
_routes, _routes,
_application, _application,
@@ -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="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" /> <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> <title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css" /> <link rel="stylesheet" href="/subscription_webapp.css" />
<style> <style>
+108 -4
View File
@@ -45,6 +45,7 @@
--surface-sheen-soft: transparent; --surface-sheen-soft: transparent;
--surface-hover: rgba(255, 255, 255, 0.08); --surface-hover: rgba(255, 255, 255, 0.08);
--surface-muted: #0a0a0a; --surface-muted: #0a0a0a;
--surface-subtle: #000000;
--surface-subtle-border: #ffffff; --surface-subtle-border: #ffffff;
--overlay-scrim: rgba(0, 0, 0, 0.85); --overlay-scrim: rgba(0, 0, 0, 0.85);
--nav-bg: #000000; --nav-bg: #000000;
@@ -119,6 +120,8 @@
/* ---------- Panels / cards ---------- */ /* ---------- Panels / cards ---------- */
.theme-key-ascii .card, .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 .period-card,
.theme-key-ascii .method-card, .theme-key-ascii .method-card,
.theme-key-ascii .settings-row, .theme-key-ascii .settings-row,
@@ -149,6 +152,12 @@
.theme-key-ascii .ticket-message-avatar, .theme-key-ascii .ticket-message-avatar,
.theme-key-ascii .ticket-message-bubble, .theme-key-ascii .ticket-message-bubble,
.theme-key-ascii .ticket-composer, .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-sidebar,
.theme-key-ascii .admin-header, .theme-key-ascii .admin-header,
.theme-key-ascii .admin-card, .theme-key-ascii .admin-card,
@@ -183,6 +192,8 @@
.theme-key-ascii .support-new-ticket-button, .theme-key-ascii .support-new-ticket-button,
.theme-key-ascii .support-select-trigger, .theme-key-ascii .support-select-trigger,
.theme-key-ascii .support-status-tabs-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-btn,
.theme-key-ascii .admin-chip, .theme-key-ascii .admin-chip,
.theme-key-ascii .admin-tabs-trigger, .theme-key-ascii .admin-tabs-trigger,
@@ -230,6 +241,7 @@
.theme-key-ascii .support-status-tabs-trigger[data-state="active"], .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-highlighted],
.theme-key-ascii .support-select-item[data-selected], .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-nav-item.active,
.theme-key-ascii .admin-tabs-trigger[data-state="active"], .theme-key-ascii .admin-tabs-trigger[data-state="active"],
.theme-key-ascii .admin-revenue-period-btn.is-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-revenue-period-btn:focus-visible,
.theme-key-ascii .admin-mobile-toggle:focus-visible, .theme-key-ascii .admin-mobile-toggle:focus-visible,
.theme-key-ascii .language-select-trigger: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 { .theme-key-ascii .bottom-nav button:focus-visible {
outline: 2px solid #ffffff; outline: 2px solid #ffffff;
outline-offset: 1px; outline-offset: 1px;
@@ -305,12 +320,22 @@
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */ /* ---------- 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 .subscription-purchase-description,
.theme-key-ascii .support-create-panel, .theme-key-ascii .support-create-panel,
.theme-key-ascii .ticket-composer { .theme-key-ascii .ticket-composer {
background: #000000; 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-heading-icon,
.theme-key-ascii .support-new-ticket-icon, .theme-key-ascii .support-new-ticket-icon,
.theme-key-ascii .support-empty-state svg, .theme-key-ascii .support-empty-state svg,
@@ -412,6 +437,7 @@
} }
body:has(.theme-key-ascii) .support-select-content, body:has(.theme-key-ascii) .support-select-content,
body:has(.theme-key-ascii) .install-platform-content,
body:has(.theme-key-ascii) .field-error-tooltip { body:has(.theme-key-ascii) .field-error-tooltip {
border: 1px solid #ffffff; border: 1px solid #ffffff;
border-radius: 0; border-radius: 0;
@@ -420,19 +446,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
box-shadow: 0 0 0 1px #ffffff; 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; border-radius: 0;
color: #ffffff; color: #ffffff;
} }
body:has(.theme-key-ascii) .support-select-item[data-highlighted], 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; background: #ffffff;
color: #000000 !important; color: #000000 !important;
} }
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg, 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; color: #000000 !important;
stroke: #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-btn-primary svg.lucide,
.theme-key-ascii .admin-nav-item.active 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-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; color: #000000 !important;
stroke: #000000 !important; stroke: #000000 !important;
} }
@@ -1044,12 +1076,16 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.theme-key-ascii :is( .theme-key-ascii :is(
.card, .dialog-card, .toast, .card, .dialog-card, .toast,
.btn, .input, .btn, .input,
.trial-card-facts span, .trial-activation-facts div,
.period-card, .method-card, .settings-row, .option-row, .period-card, .method-card, .settings-row, .option-row,
.tariff-selected-card, .tariff-action-card, .tariff-warning-card, .tariff-selected-card, .tariff-action-card, .tariff-warning-card,
.topup-carryover-note, .subscription-purchase-description, .topup-carryover-note, .subscription-purchase-description,
.language-select-content, .language-select-item, .language-select-content, .language-select-item,
.language-select-trigger, .bottom-nav, .bottom-nav button, .language-select-trigger, .bottom-nav, .bottom-nav button,
.link-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-overview-card, .support-list-card, .support-ticket-card,
.support-conversation-card, .support-new-ticket-button, .support-conversation-card, .support-new-ticket-button,
.support-create-panel, .support-select-trigger, .support-select-content, .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-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card, .admin-panel-dash-card,
.admin-select-trigger, .admin-select-content, .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-cn-card,
.admin-input, .admin-textarea, .admin-btn, .admin-input, .admin-textarea, .admin-btn,
.admin-nav-item, .admin-tabs-trigger .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; 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, * Console-style tables: cell borders, header underline,
* row separator using dashed line. * row separator using dashed line.
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false, "use_primary_accent": false,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 3, "assets_version": 4,
"tokens": { "tokens": {
"color_scheme": "dark", "color_scheme": "dark",
"style_preset": "ascii" "style_preset": "ascii"
@@ -33,6 +33,7 @@
--surface-sheen-soft: rgba(15, 23, 42, 0.012); --surface-sheen-soft: rgba(15, 23, 42, 0.012);
--surface-hover: rgba(15, 23, 42, 0.045); --surface-hover: rgba(15, 23, 42, 0.045);
--surface-muted: rgba(15, 23, 42, 0.035); --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); --surface-subtle-border: rgba(15, 23, 42, 0.1);
--overlay-scrim: rgba(15, 23, 42, 0.34); --overlay-scrim: rgba(15, 23, 42, 0.34);
--nav-bg: rgba(255, 255, 255, 0.88); --nav-bg: rgba(255, 255, 255, 0.88);
@@ -109,6 +110,26 @@
z-index: 1; 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 */ /* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
.theme-key-light .admin-revenue-svg-frame { .theme-key-light .admin-revenue-svg-frame {
background: #ffffff; background: #ffffff;
@@ -129,3 +150,57 @@
.theme-key-light .bonus-card-head > svg { .theme-key-light .bonus-card-head > svg {
color: color-mix(in srgb, var(--accent) 50%, #000000); 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_primary_accent": true,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 2, "assets_version": 3,
"tokens": { "tokens": {
"color_scheme": "light" "color_scheme": "light"
} }
+145 -6
View File
@@ -39,6 +39,7 @@
--surface-sheen-soft: transparent; --surface-sheen-soft: transparent;
--surface-hover: rgba(0, 0, 128, 0.14); --surface-hover: rgba(0, 0, 128, 0.14);
--surface-muted: #c0c0c0; --surface-muted: #c0c0c0;
--surface-subtle: #dfdfdf;
--surface-subtle-border: #808080; --surface-subtle-border: #808080;
--overlay-scrim: rgba(0, 0, 0, 0.35); --overlay-scrim: rgba(0, 0, 0, 0.35);
--nav-bg: #c0c0c0; --nav-bg: #c0c0c0;
@@ -138,12 +139,15 @@
.theme-key-windows95 svg.lucide-megaphone, .theme-key-windows95 svg.lucide-megaphone,
.theme-key-windows95 svg.lucide-message-square, .theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-message-square-plus, .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-paintbrush,
.theme-key-windows95 svg.lucide-plus, .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-refresh-cw,
.theme-key-windows95 svg.lucide-save, .theme-key-windows95 svg.lucide-save,
.theme-key-windows95 svg.lucide-search, .theme-key-windows95 svg.lucide-search,
.theme-key-windows95 svg.lucide-send, .theme-key-windows95 svg.lucide-send,
.theme-key-windows95 svg.lucide-share-2,
.theme-key-windows95 svg.lucide-settings, .theme-key-windows95 svg.lucide-settings,
.theme-key-windows95 svg.lucide-shield, .theme-key-windows95 svg.lucide-shield,
.theme-key-windows95 svg.lucide-sliders, .theme-key-windows95 svg.lucide-sliders,
@@ -273,6 +277,10 @@
--win95-button-icon: var(--win95-icon-send); --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 { .theme-key-windows95 svg.lucide-paintbrush {
--win95-button-icon: var(--win95-icon-paintbrush); --win95-button-icon: var(--win95-icon-paintbrush);
} }
@@ -281,6 +289,10 @@
--win95-button-icon: var(--win95-icon-folder); --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 { .theme-key-windows95 svg.lucide-refresh-cw {
--win95-button-icon: var(--win95-icon-refresh); --win95-button-icon: var(--win95-icon-refresh);
} }
@@ -293,6 +305,10 @@
--win95-button-icon: var(--win95-icon-search); --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 { .theme-key-windows95 svg.lucide-settings {
--win95-button-icon: var(--win95-icon-settings); --win95-button-icon: var(--win95-icon-settings);
} }
@@ -371,12 +387,15 @@
svg.lucide-megaphone, svg.lucide-megaphone,
svg.lucide-message-square, svg.lucide-message-square,
svg.lucide-message-square-plus, svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush, svg.lucide-paintbrush,
svg.lucide-plus, svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw, svg.lucide-refresh-cw,
svg.lucide-save, svg.lucide-save,
svg.lucide-search, svg.lucide-search,
svg.lucide-send, svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings, svg.lucide-settings,
svg.lucide-shield, svg.lucide-shield,
svg.lucide-sliders, svg.lucide-sliders,
@@ -425,12 +444,15 @@
svg.lucide-megaphone, svg.lucide-megaphone,
svg.lucide-message-square, svg.lucide-message-square,
svg.lucide-message-square-plus, svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush, svg.lucide-paintbrush,
svg.lucide-plus, svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw, svg.lucide-refresh-cw,
svg.lucide-save, svg.lucide-save,
svg.lucide-search, svg.lucide-search,
svg.lucide-send, svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings, svg.lucide-settings,
svg.lucide-shield, svg.lucide-shield,
svg.lucide-sliders, svg.lucide-sliders,
@@ -488,7 +510,13 @@
.theme-key-windows95 .support-message-scroll, .theme-key-windows95 .support-message-scroll,
.theme-key-windows95 .ticket-message-avatar, .theme-key-windows95 .ticket-message-avatar,
.theme-key-windows95 .ticket-message-bubble, .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-width: 2px;
border-style: solid; border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff; border-color: #ffffff #404040 #404040 #ffffff;
@@ -510,6 +538,7 @@
body:has(.theme-key-windows95) .language-select-content, body:has(.theme-key-windows95) .language-select-content,
body:has(.theme-key-windows95) .support-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) .field-error-tooltip,
body:has(.theme-key-windows95) .admin-select-content { body:has(.theme-key-windows95) .admin-select-content {
border-width: 2px; 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) .language-select-item,
body:has(.theme-key-windows95) .support-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 { body:has(.theme-key-windows95) .admin-select-item {
border-radius: 0 !important; border-radius: 0 !important;
} }
body:has(.theme-key-windows95) .support-select-item[data-highlighted], 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; background: #000080;
color: #ffffff !important; color: #ffffff !important;
} }
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg, 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); 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 .link-button,
.theme-key-windows95 .support-new-ticket-button, .theme-key-windows95 .support-new-ticket-button,
.theme-key-windows95 .support-select-trigger, .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; min-height: 34px;
border: 2px solid; border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff; 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-new-ticket-button.active,
.theme-key-windows95 .support-status-tabs-trigger[data-state="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-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); background: var(--accent);
color: #ffffff; color: #ffffff;
} }
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */ /* ---------- 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 .subscription-purchase-description,
.theme-key-windows95 .support-create-panel, .theme-key-windows95 .support-create-panel,
.theme-key-windows95 .ticket-composer, .theme-key-windows95 .ticket-composer,
@@ -639,6 +679,20 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
background: #c0c0c0; 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-heading-icon,
.theme-key-windows95 .support-new-ticket-icon, .theme-key-windows95 .support-new-ticket-icon,
.theme-key-windows95 .support-empty-state svg, .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 .ticket-composer:focus-within,
.theme-key-windows95 .support-select-trigger:focus-visible, .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 .ticket-card:focus-visible,
.theme-key-windows95 .support-status-tabs-trigger:focus-visible { .theme-key-windows95 .support-status-tabs-trigger:focus-visible {
outline: 1px dotted #000000; outline: 1px dotted #000000;
@@ -731,6 +788,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
transition: none; 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 .card-heading-accent,
.theme-key-windows95 .brand-row strong, .theme-key-windows95 .brand-row strong,
.theme-key-windows95 .login-brand h1, .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-revenue-period-btn:focus-visible,
.theme-key-windows95 .admin-mobile-toggle:focus-visible, .theme-key-windows95 .admin-mobile-toggle:focus-visible,
.theme-key-windows95 .language-select-trigger: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 { .theme-key-windows95 .bottom-nav button:focus-visible {
outline: 1px dotted #000000; outline: 1px dotted #000000;
outline-offset: -4px; 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-nav-item.active svg.lucide,
.theme-key-windows95 .admin-tabs-trigger[data-state="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 .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-windows95 .install-app-button.active svg.lucide,
.theme-key-windows95 .admin-header svg.lucide { .theme-key-windows95 .admin-header svg.lucide {
filter: brightness(0) invert(1); 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-map,
.theme-key-windows95 svg.lucide-menu, .theme-key-windows95 svg.lucide-menu,
.theme-key-windows95 svg.lucide-mouse-pointer-click, .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-radio,
.theme-key-windows95 svg.lucide-repeat-2, .theme-key-windows95 svg.lucide-repeat-2,
.theme-key-windows95 svg.lucide-server, .theme-key-windows95 svg.lucide-server,
@@ -9,7 +9,7 @@
"use_primary_accent": false, "use_primary_accent": false,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 9, "assets_version": 11,
"tokens": { "tokens": {
"color_scheme": "light", "color_scheme": "light",
"style_preset": "win95" "style_preset": "win95"
+6 -1
View File
@@ -49,7 +49,11 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip 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 config.settings import Settings
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError 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" TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent ASSET_DIR = TEMPLATE_PATH.parent
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
APP_ROOT = Path(__file__).resolve().parents[5] APP_ROOT = Path(__file__).resolve().parents[5]
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo" WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
+48 -37
View File
@@ -2,7 +2,11 @@
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
from .auth import _hash_email_password from .auth import (
_hash_email_password,
_notify_account_merged,
_sync_merged_panel_identity_for_user,
)
from .common import _invalidate_webapp_user_caches from .common import _invalidate_webapp_user_caches
@@ -109,6 +113,7 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
) )
current_user.email = email current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc) current_user.email_verified_at = datetime.now(timezone.utc)
if not merge_notice:
await _sync_panel_identity_for_user(request, current_user) await _sync_panel_identity_for_user(request, current_user)
await session.commit() await session.commit()
final_user_id = int(current_user.user_id) final_user_id = int(current_user.user_id)
@@ -122,28 +127,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
merge_end_date = ( merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
) )
await _sync_panel_identity_for_user( await _sync_merged_panel_identity_for_user(
request, request,
current_user, current_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date, expire_at=merge_end_date,
) )
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service") email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email: if email_service and final_email:
@@ -178,6 +168,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
return _json_error(500, "link_failed", "Link failed") return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_email_linked: if should_notify_email_linked:
try: try:
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
@@ -345,28 +345,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
merge_end_date = ( merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
) )
await _sync_panel_identity_for_user( await _sync_merged_panel_identity_for_user(
request, request,
db_user, db_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date, expire_at=merge_end_date,
) )
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service") email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email: if email_service and final_email:
@@ -401,6 +386,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
return _json_error(500, "link_failed", "Link failed") return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_telegram_linked and final_telegram_id: if should_notify_telegram_linked and final_telegram_id:
try: try:
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
@@ -435,6 +430,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
async def me_route(request: web.Request) -> web.Response: async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request) user_id = _require_user_id(request)
settings: Settings = request.app["settings"] 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( data = await webapp_cached_user_payload(
settings, settings,
"me", "me",
@@ -482,6 +488,11 @@ async def account_language_route(request: web.Request) -> web.Response:
return validation_error return validation_error
language = _normalize_language(str(language_payload.language or "")) 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_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session: async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id) 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 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .guides import warm_subscription_guides_config
def create_subscription_webapp_application( def create_subscription_webapp_application(
@@ -20,10 +21,12 @@ def create_subscription_webapp_application(
app["settings"] = settings app["settings"] = settings
app["async_session_factory"] = async_session_factory app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance") 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"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock() app["webapp_logo_cache_lock"] = asyncio.Lock()
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}} 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_buckets"] = {}
app["webapp_rate_limit_lock"] = asyncio.Lock() app["webapp_rate_limit_lock"] = asyncio.Lock()
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
await _ensure_shared_http_session() await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj) await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_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: async def _shutdown(app_obj: web.Application) -> None:
await _close_shared_http_session() await _close_shared_http_session()
+267 -33
View File
@@ -9,6 +9,7 @@ from config.webapp_themes_config import (
public_theme_payload, public_theme_payload,
public_themes_catalog_payload, public_themes_catalog_payload,
) )
from bot.middlewares.i18n import locale_language_options
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {} _TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {} _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 raise web.HTTPNotFound(text="theme_css_not_found") from None
query = getattr(request, "query", {}) or {} query = getattr(request, "query", {}) or {}
cache_control = ( cache_control = "public, max-age=31536000, immutable" if query.get("v") else "no-cache"
"public, max-age=31536000, immutable" if query.get("v") else "no-cache"
)
try: try:
stat = path.stat() stat = path.stat()
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES: if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
@@ -204,7 +203,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return "" 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: if not raw_logo_url:
return "" return ""
@@ -372,6 +371,78 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
digest = str(request.match_info.get("digest") or "").strip().lower() digest = str(request.match_info.get("digest") or "").strip().lower()
filename = str(request.match_info.get("filename") or "").strip() 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): if not re.fullmatch(r"[0-9a-f]{16}", digest):
raise web.HTTPNotFound(text="webapp_favicon_not_found") raise web.HTTPNotFound(text="webapp_favicon_not_found")
if not re.fullmatch( if not re.fullmatch(
@@ -834,6 +905,45 @@ def _run_git_command(*args: str) -> str:
return result.stdout.strip() return result.stdout.strip()
def _normalize_version_branch(raw_branch: str) -> str:
branch = str(raw_branch or "").strip()
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
if branch.startswith(prefix):
branch = branch[len(prefix) :]
break
if branch == "HEAD":
return ""
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
def _resolve_version_branch() -> str:
for env_name in (
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
"GITHUB_REF_NAME",
"CI_COMMIT_REF_NAME",
):
branch = _normalize_version_branch(os.getenv(env_name, ""))
if branch:
return branch
return _normalize_version_branch(
_run_git_command("branch", "--show-current")
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
)
def _format_app_version(tag: str, sha: str, branch: str) -> str:
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
if tag and sha:
return f"{tag}{branch_suffix}+g{sha}"
if sha:
return f"dev{branch_suffix}+g{sha}"
if tag:
return f"{tag}{branch_suffix}"
return f"dev{branch_suffix}+unknown"
def _resolve_app_version() -> str: def _resolve_app_version() -> str:
global _APP_VERSION_CACHE global _APP_VERSION_CACHE
if _APP_VERSION_CACHE: if _APP_VERSION_CACHE:
@@ -855,21 +965,8 @@ def _resolve_app_version() -> str:
tag = _run_git_command("describe", "--tags", "--abbrev=0") tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD") sha = _run_git_command("rev-parse", "--short", "HEAD")
dirty = bool(_run_git_command("status", "--porcelain")) branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
if tag and sha:
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
if commits_since_tag and commits_since_tag != "0":
version = f"{tag}+{commits_since_tag}.g{sha}"
else:
version = tag
elif sha:
version = f"dev+g{sha}"
else:
version = "dev+unknown"
if dirty:
version = f"{version}-dirty"
_APP_VERSION_CACHE = version _APP_VERSION_CACHE = version
return version return version
@@ -974,6 +1071,30 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",) WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"} WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
WEBAPP_I18N_SCOPES = {"webapp", "admin"} 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: def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
@@ -1037,7 +1158,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
preview_key = "" preview_key = ""
i18n_instance: Optional[object] = request.app.get("i18n") i18n_instance: Optional[object] = request.app.get("i18n")
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp") 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 {} 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 { return {
"config": { "config": {
"title": settings.WEBAPP_TITLE, "title": settings.WEBAPP_TITLE,
@@ -1068,6 +1192,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"userAgreementUrl": cached["user_agreement_url"], "userAgreementUrl": cached["user_agreement_url"],
"currency": cached["currency"], "currency": cached["currency"],
"language": cached["language"], "language": cached["language"],
"languages": locale_language_options(
locales_data.keys(),
base_languages=base_locales_data.keys(),
),
"emailAuthEnabled": cached["email_auth_enabled"], "emailAuthEnabled": cached["email_auth_enabled"],
"appVersion": _resolve_app_version(), "appVersion": _resolve_app_version(),
"appRepositoryUrl": APP_REPOSITORY_URL, "appRepositoryUrl": APP_REPOSITORY_URL,
@@ -1084,6 +1212,8 @@ async def bootstrap_route(request: web.Request) -> web.Response:
async def i18n_route(request: web.Request) -> web.Response: async def i18n_route(request: web.Request) -> web.Response:
i18n_instance: Optional[object] = request.app.get("i18n") 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") scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
response = web.json_response( response = web.json_response(
@@ -1097,6 +1227,72 @@ async def i18n_route(request: web.Request) -> web.Response:
return 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: async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED: if not settings.WEBAPP_ENABLED:
@@ -1118,6 +1314,7 @@ async def index_route(request: web.Request) -> web.Response:
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color) initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
if initial_theme_markup: if initial_theme_markup:
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1) 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"] i18n_payload = bootstrap["i18n"]
nonce = request.get("csp_nonce", "") nonce = request.get("csp_nonce", "")
html = html.replace( html = html.replace(
@@ -1140,12 +1337,6 @@ async def index_route(request: web.Request) -> web.Response:
WEBAPP_JS_PLACEHOLDER, WEBAPP_JS_PLACEHOLDER,
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>', 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"] brand_asset_url = cached["logo_url"]
if ( if (
not brand_asset_url not brand_asset_url
@@ -1167,6 +1358,50 @@ async def index_route(request: web.Request) -> web.Response:
return 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( async def _serve_template_asset(
request: web.Request, request: web.Request,
filename: str, filename: str,
@@ -1372,10 +1607,10 @@ def _resolve_webapp_js_asset_name() -> str:
def _resolve_webapp_admin_js_asset_name() -> str: def _resolve_webapp_admin_js_asset_name() -> str:
return _resolve_hashed_js_asset_name( # The admin bundle is lazy-loaded from the already running Mini App. In
kind="admin-js", # deployments where nginx serves static files in front of aiohttp, stale
base_name="subscription_webapp_admin", # hashed admin filenames can 404 even though the runtime build asset exists.
) return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str: def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
@@ -1405,10 +1640,9 @@ def _resolve_webapp_css_asset_name() -> str:
def _resolve_webapp_admin_css_asset_name() -> str: def _resolve_webapp_admin_css_asset_name() -> str:
return _resolve_hashed_css_asset_name( # Keep the lazy-loaded admin stylesheet on the stable build filename for
kind="admin-css", # the same reason as the JS bundle above.
base_name="subscription_webapp_admin", return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
)
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str: def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
+163 -22
View File
@@ -339,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
redirect_path = "/settings" if purpose == "link" else "/" redirect_path = "/settings" if purpose == "link" else "/"
async_session_factory: sessionmaker = request.app["async_session_factory"] async_session_factory: sessionmaker = request.app["async_session_factory"]
final_user_id: Optional[int] = None final_user_id: Optional[int] = None
source_user_id_for_cache: Optional[int] = None
linked_user_for_panel: Optional[User] = None
link_source_panel_uuid: Optional[str] = None
link_final_panel_uuid: Optional[str] = None
link_merge_notice: Optional[Dict[str, Any]] = None
async with async_session_factory() as session: async with async_session_factory() as session:
try: try:
if purpose == "link": if purpose == "link":
current_user_id = int(state.get("user_id") or 0) current_user_id = int(state.get("user_id") or 0)
source_user_id_for_cache = current_user_id
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
link_source_panel_uuid = (
current_user_before_link.panel_user_uuid if current_user_before_link else None
)
db_user = await _link_telegram_to_user( db_user = await _link_telegram_to_user(
request, request,
session, session,
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
telegram_user=telegram_user, telegram_user=telegram_user,
settings=settings, settings=settings,
) )
if int(db_user.user_id) != current_user_id:
link_final_panel_uuid = db_user.panel_user_uuid
link_merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=current_user_id,
source_panel_uuid=link_source_panel_uuid,
settings=settings,
)
linked_user_for_panel = db_user
else: else:
db_user = await _ensure_user_from_telegram( db_user = await _ensure_user_from_telegram(
session, session,
@@ -388,6 +408,34 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
raise redirect(redirect_path, "failed") raise redirect(redirect_path, "failed")
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True) await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
await _invalidate_webapp_user_caches(
settings,
source_user_id_for_cache,
final_user_id,
include_devices=True,
)
if purpose == "link" and link_merge_notice and linked_user_for_panel:
merge_end_date_raw = link_merge_notice.get("final_end_date")
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
await _sync_merged_panel_identity_for_user(
request,
linked_user_for_panel,
source_panel_uuid=link_source_panel_uuid,
final_panel_uuid=link_final_panel_uuid,
expire_at=merge_end_date,
)
await _notify_account_merged(
request,
settings,
merge_notice=link_merge_notice,
email=linked_user_for_panel.email,
telegram_id=_telegram_id_for_user(linked_user_for_panel),
username=linked_user_for_panel.username,
first_name=linked_user_for_panel.first_name,
)
token = create_webapp_session_token(settings, int(final_user_id)) token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response) _clear_telegram_oauth_state_cookie(response)
@@ -965,13 +1013,19 @@ def _telegram_id_for_user(user: User) -> Optional[int]:
def _panel_description_for_user(user: User) -> str: def _panel_description_for_user(user: User) -> str:
lines = [ return panel_description_from_profile(
user.email or "", user.username,
user.username or "", user.first_name,
user.first_name or "", user.last_name,
user.last_name or "", )
]
return "\n".join(line for line in lines if line).strip()
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
raw_value = telegram_user.get("photo_url")
if not raw_value:
return None
value = str(raw_value).strip()
return value or None
async def _sync_panel_identity_for_user( async def _sync_panel_identity_for_user(
@@ -986,23 +1040,33 @@ async def _sync_panel_identity_for_user(
if not subscription_service or not subscription_service.panel_service: if not subscription_service or not subscription_service.panel_service:
return False return False
payload: Dict[str, Any] = { payload: Dict[str, Any] = {}
"description": _panel_description_for_user(user),
}
telegram_id = _telegram_id_for_user(user) telegram_id = _telegram_id_for_user(user)
if telegram_id: if telegram_id:
payload["telegramId"] = telegram_id payload["telegramId"] = telegram_id
if user.email: if user.email:
payload["email"] = user.email payload["email"] = user.email
if expire_at is not None: if expire_at is not None:
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if expire_at > datetime.now(timezone.utc):
payload["status"] = "ACTIVE"
try: 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, user.panel_user_uuid,
payload, payload,
log_response=False, 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 return True
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -1013,6 +1077,53 @@ async def _sync_panel_identity_for_user(
return False return False
async def _delete_merged_source_panel_user(
request: web.Request,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
) -> bool:
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
return True
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return False
try:
return bool(
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
return False
async def _sync_merged_panel_identity_for_user(
request: web.Request,
user: User,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
expire_at: Optional[datetime] = None,
) -> bool:
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
# before patching the surviving one so merged accounts can accept both IDs.
await _delete_merged_source_panel_user(
request,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
)
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
async def _build_account_merge_notice( async def _build_account_merge_notice(
session: AsyncSession, session: AsyncSession,
*, *,
@@ -1050,16 +1161,50 @@ async def _build_account_merge_notice(
} }
async def _notify_account_merged(
request: web.Request,
settings: Settings,
*,
merge_notice: Optional[Dict[str, Any]],
email: Optional[str],
telegram_id: Optional[int],
username: Optional[str],
first_name: Optional[str],
) -> None:
if not merge_notice:
return
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_merged(
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
email=email,
telegram_id=telegram_id,
username=username,
first_name=first_name,
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
)
except Exception:
logger.exception("Failed to send account merged notification")
def _apply_telegram_profile_to_user( def _apply_telegram_profile_to_user(
user: User, user: User,
telegram_user: Dict[str, Any], telegram_user: Dict[str, Any],
settings: Settings, settings: Settings,
) -> None: ) -> None:
language_code = ( language_code = _normalize_language(
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE telegram_user.get("language_code") or user.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.telegram_id = int(telegram_user["id"])
user.username = sanitize_username(telegram_user.get("username")) user.username = sanitize_username(telegram_user.get("username"))
@@ -1102,17 +1247,14 @@ async def _link_telegram_to_user(
) )
_apply_telegram_profile_to_user(merged_user, telegram_user, settings) _apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush() await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0: if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = ( language_code = _normalize_language(
telegram_user.get("language_code") telegram_user.get("language_code")
or current_user.language_code or current_user.language_code
or settings.DEFAULT_LANGUAGE 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( target_user, _ = await user_dal.create_user(
session, session,
{ {
@@ -1134,7 +1276,6 @@ async def _link_telegram_to_user(
) )
_apply_telegram_profile_to_user(merged_user, telegram_user, settings) _apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush() await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id: if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
@@ -1259,9 +1400,9 @@ async def _ensure_user_from_telegram(
referral_param: Optional[str] = None, referral_param: Optional[str] = None,
) -> User: ) -> User:
user_id = int(telegram_user["id"]) user_id = int(telegram_user["id"])
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE language_code = _normalize_language(
if language_code not in {"ru", "en"}: telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
language_code = settings.DEFAULT_LANGUAGE )
update_data = { update_data = {
"telegram_id": user_id, "telegram_id": user_id,
+312 -30
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
async def apply_promo_route(request: web.Request) -> web.Response: async def apply_promo_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request) user_id = _require_user_id(request)
@@ -67,9 +69,14 @@ async def create_payment_route(request: web.Request) -> web.Response:
traffic_mode = bool(settings.traffic_sale_mode) traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription" sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None 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 "")) 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() tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key: if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected") return _json_error(400, "invalid_plan", "Tariff is not selected")
@@ -77,6 +84,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
tariff = tariffs_config.require(tariff_key) tariff = tariffs_config.require(tariff_key)
except Exception: except Exception:
return _json_error(400, "invalid_plan", "Tariff is not available") return _json_error(400, "invalid_plan", "Tariff is not available")
if tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
try: try:
device_count = int( device_count = int(
float( float(
@@ -87,23 +96,10 @@ async def create_payment_route(request: web.Request) -> web.Response:
) )
except (TypeError, ValueError): except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid device package") return _json_error(400, "invalid_plan", "Invalid device package")
packages = tariff.hwid_device_packages if not 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":
return _json_error(400, "invalid_plan", "Device package is not available") 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 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"}: elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}:
tariff_key = str(payment_payload.tariff_key or "").strip() tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key: if not tariff_key:
@@ -251,6 +247,42 @@ async def create_payment_route(request: web.Request) -> web.Response:
if not db_user or db_user.is_banned: if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied") return _json_error(403, "access_denied", "Access denied")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE 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( return await _create_subscription_payment(
request=request, request=request,
session=session, session=session,
@@ -262,6 +294,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
lang=lang, lang=lang,
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment, traffic_gb=traffic_gb_for_payment,
is_admin=is_admin,
hwid_quote=hwid_quote,
) )
@@ -311,7 +345,12 @@ async def activate_trial_route(request: web.Request) -> web.Response:
notification_service = NotificationService( notification_service = NotificationService(
request.app["bot"], settings, i18n_instance 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: except Exception:
logger.exception("Failed to send WebApp trial activation notification") logger.exception("Failed to send WebApp trial activation notification")
@@ -324,6 +363,8 @@ async def activate_trial_route(request: web.Request) -> web.Response:
await session.rollback() await session.rollback()
logger.exception("Failed to mark WebApp trial activation for ad attribution") logger.exception("Failed to mark WebApp trial activation for ad attribution")
await invalidate_webapp_user_caches(settings, user_id)
return web.json_response( return web.json_response(
{ {
"ok": True, "ok": True,
@@ -448,7 +489,9 @@ async def tariff_change_options_route(request: web.Request) -> web.Response:
for tariff in config.enabled_tariffs: for tariff in config.enabled_tariffs:
if tariff.key == current.key: if tariff.key == current.key:
continue 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)) targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang))
return web.json_response( return web.json_response(
{ {
@@ -525,7 +568,9 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required" 400, "subscription_required", "Active tariff subscription is required"
) )
target = config.require(tariff_key) 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) price = float(options.get("paid_diff_rub") or 0)
if price <= 0: if price <= 0:
return _json_error( return _json_error(
@@ -567,25 +612,243 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required" 400, "subscription_required", "Active tariff subscription is required"
) )
tariff = config.require(sub.tariff_key) tariff = config.require(sub.tariff_key)
active = await subscription_service.get_active_subscription_details(session, user_id) if tariff.billing_model != "period":
plans = _serialize_hwid_device_packages( return _json_error(
settings, 400, "device_topup_unavailable", "Device top-up is not available"
tariff,
tariff.hwid_device_packages,
db_user.language_code or settings.DEFAULT_LANGUAGE,
) )
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
active = await subscription_service.get_active_subscription_details(session, user_id)
renewal_available = bool(active and active.get("device_topup_renewal_available"))
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": (
(rub_quote or stars_quote)["valid_from"].isoformat()
if (rub_quote or stars_quote).get("valid_from")
else None
),
"valid_until": (
(rub_quote or stars_quote)["valid_until"].isoformat()
if (rub_quote or stars_quote).get("valid_until")
else None
),
"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( return web.json_response(
{ {
"ok": True, "ok": True,
"tariff_key": tariff.key, "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, "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": active.get("extra_hwid_devices_valid_until")
if active
else None,
"extra_hwid_devices_valid_until_text": active.get(
"extra_hwid_devices_valid_until_text"
)
if active
else None,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
else 0,
"plans": plans, "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: async def payment_status_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request) user_id = _require_user_id(request)
try: try:
@@ -598,6 +861,10 @@ async def payment_status_route(request: web.Request) -> web.Response:
payment = await payment_dal.get_payment_by_db_id(session, payment_id) payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment or payment.user_id != user_id: if not payment or payment.user_id != user_id:
return _json_error(404, "not_found", "Payment not found") 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( return web.json_response(
{ {
"ok": True, "ok": True,
@@ -623,7 +890,11 @@ def _sale_mode_is_traffic(sale_mode: str) -> bool:
def _sale_mode_is_hwid_devices(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( async def _create_subscription_payment(
@@ -638,6 +909,8 @@ async def _create_subscription_payment(
lang: str, lang: str,
sale_mode: str = "subscription", sale_mode: str = "subscription",
traffic_gb: Optional[float] = None, traffic_gb: Optional[float] = None,
is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response: ) -> web.Response:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
sale_mode = str(sale_mode or "subscription") sale_mode = str(sale_mode or "subscription")
@@ -655,11 +928,11 @@ async def _create_subscription_payment(
provider_spec = get_provider_spec(method) provider_spec = get_provider_spec(method)
if provider_spec and provider_spec.create_webapp_payment: 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( logger.warning(
"WebApp payment method unavailable: method=%s enabled=%s configured=%s", "WebApp payment method unavailable: method=%s enabled=%s configured=%s",
method, method,
provider_spec.is_enabled(settings), provider_spec.is_effectively_enabled(settings),
provider_spec.is_service_configured(request.app), provider_spec.is_service_configured(request.app),
) )
return _json_error(400, "payment_unavailable", "Payment method unavailable") return _json_error(400, "payment_unavailable", "Payment method unavailable")
@@ -675,6 +948,15 @@ async def _create_subscription_payment(
description=description, description=description,
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb, 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,
) )
) )
+63 -4
View File
@@ -2,13 +2,31 @@ from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional from typing import Any, Awaitable, Callable, Optional
from bot.infra.redis import cache_delete, redis_key from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
from bot.utils.ttl_cache import AsyncTTLCache from bot.utils.ttl_cache import AsyncTTLCache
from config.settings import Settings from config.settings import Settings
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {} _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( def _webapp_user_payload_cache(
settings: Settings, settings: Settings,
namespace: str, namespace: str,
@@ -48,13 +66,32 @@ def invalidate_local_webapp_user_payload(
user_id: int, user_id: int,
) -> None: ) -> None:
key = str(int(user_id)) key = str(int(user_id))
for (settings_id, cache_namespace, _ttl), cache in tuple( for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
_WEBAPP_USER_PAYLOAD_CACHES.items()
):
if settings_id == id(settings) and cache_namespace == namespace: if settings_id == id(settings) and cache_namespace == namespace:
cache.invalidate(key) cache.invalidate(key)
def invalidate_all_local_webapp_user_payloads(
settings: Settings,
namespace: Optional[str] = None,
*,
include_devices: Optional[bool] = None,
) -> None:
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 namespaces is not None and cache_namespace not in namespaces:
continue
cache.invalidate()
async def invalidate_webapp_user_caches( async def invalidate_webapp_user_caches(
settings: Settings, settings: Settings,
*user_ids: Optional[int], *user_ids: Optional[int],
@@ -79,3 +116,25 @@ async def invalidate_webapp_user_caches(
invalidate_local_webapp_user_payload(settings, "devices", user_id) invalidate_local_webapp_user_payload(settings, "devices", user_id)
if keys: if keys:
await cache_delete(settings, *keys) 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:
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 ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import ( 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], *user_ids: Optional[int],
include_devices: bool = False, include_devices: bool = False,
) -> None: ) -> None:
keys: List[str] = [] await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
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)
def _validation_error_response(exc: ValidationError) -> web.Response: def _validation_error_response(exc: ValidationError) -> web.Response:
@@ -91,8 +77,8 @@ def _validate_model_payload(
def _normalize_language(lang: Optional[str]) -> str: def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower() value = normalize_locale_language_code(lang, prefer_known_base=False)
return value if value in {"ru", "en"} else "ru" return value if is_valid_locale_language_code(value) else "ru"
def _format_remaining(seconds: int, lang: str) -> str: def _format_remaining(seconds: int, lang: str) -> str:
+1 -2
View File
@@ -86,8 +86,7 @@ async def _load_devices_payload(
"max_devices": max_devices, "max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices), "max_devices_label": _format_devices_limit(max_devices),
"devices": [ "devices": [
_serialize_device(device, index) _serialize_device(device, index) for index, device in enumerate(devices, start=1)
for index, device in enumerate(devices, start=1)
], ],
}, },
} }
+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",
}
+17 -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("/", index_route)
app.router.add_get("/login/password", index_route) app.router.add_get("/login/password", index_route)
app.router.add_get("/home", 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("/invite", index_route)
app.router.add_get("/devices", index_route) app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route) app.router.add_get("/settings", index_route)
@@ -15,15 +19,22 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get( app.router.add_get(
( (
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|" "/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings|support}" "appearance|settings|translations|support}"
), ),
index_route, index_route,
) )
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", 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("/admin/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_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("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_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_LOGO_PROXY_PATH, webapp_logo_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get( app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
@@ -60,6 +71,11 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/api/bootstrap", bootstrap_route) app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/i18n", i18n_route) app.router.add_get("/api/i18n", i18n_route)
app.router.add_get("/api/me", me_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_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route) app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route) app.router.add_post("/api/account/email/request", account_email_request_route)
+114 -10
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 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 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 if db_user.panel_user_uuid
else None 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( trial_available = bool(
settings.TRIAL_ENABLED settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0 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, "language_code": lang,
"is_admin": is_admin, "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": { "referral": {
"code": referral_code, "code": referral_code,
"bot_link": referral_link, "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"], traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_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( "themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog, settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", 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_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"subscription_purchase_description": settings.subscription_purchase_description(lang), "subscription_purchase_description": settings.subscription_purchase_description(lang),
"subscription_guides_enabled": subscription_guides_available(settings),
"email_auth_enabled": settings.email_auth_configured, "email_auth_enabled": settings.email_auth_configured,
}, },
} }
@@ -179,11 +198,26 @@ def _build_webapp_referral_link(
def _serialize_subscription( def _serialize_subscription(
settings: Settings, request_or_settings: Any,
active: Optional[Dict[str, Any]], settings_or_active: Any,
local_sub: Optional[Any], active_or_local_sub: Optional[Any] = None,
lang: str, local_sub_or_lang: Optional[Any] = None,
lang: Optional[str] = None,
*,
install_share_token: Optional[str] = None,
) -> Dict[str, Any]: ) -> 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: if not active:
return { return {
"active": False, "active": False,
@@ -192,6 +226,9 @@ def _serialize_subscription(
"days_left": 0, "days_left": 0,
"config_link": None, "config_link": None,
"connect_url": None, "connect_url": None,
"panel_short_uuid": None,
"install_share_token": None,
"install_share_url": None,
} }
end_date = active.get("end_date") 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) 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. # max_devices == 0 means unlimited — top-up is pointless in that case.
can_topup_devices = bool( 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 and _coerce_int_or_none(active.get("max_devices")) != 0
) )
except Exception: except Exception:
@@ -231,6 +269,23 @@ def _serialize_subscription(
can_topup_traffic = False can_topup_traffic = False
can_topup_devices = 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 { return {
"active": seconds_left > 0, "active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN", "status": active.get("status_from_panel") or "UNKNOWN",
@@ -240,6 +295,9 @@ def _serialize_subscription(
"remaining_text": _format_remaining(seconds_left, lang), "remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"), "config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or 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_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")), "traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_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")), "is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")), "max_devices": _coerce_int_or_none(active.get("max_devices")),
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")), "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)), "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None), "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( def _serialize_plans(
settings: Settings, settings: Settings,
lang: str, lang: str,
@@ -310,7 +402,9 @@ def _serialize_plans(
tariff, tariff,
tariff.hwid_device_packages, tariff.hwid_device_packages,
lang, lang,
), )
if tariff.billing_model == "period"
else [],
} }
if tariff.billing_model == "period": if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods): for months in sorted(tariff.enabled_periods):
@@ -519,6 +613,10 @@ def _serialize_tariff_change_target(
"title": "recalc_days", "title": "recalc_days",
"days_after": int(options.get("recalc_days") or 0), "days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_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) paid_diff = float(options.get("paid_diff_rub") or 0)
@@ -540,6 +638,10 @@ def _serialize_tariff_change_target(
"title": "convert_days_to_gb", "title": "convert_days_to_gb",
"converted_gb": float(options.get("converted_gb") or 0), "converted_gb": float(options.get("converted_gb") or 0),
"remaining_days": int(options.get("remaining_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_gb": float(options.get("converted_hwid_gb") or 0),
} }
) )
actions.extend( actions.extend(
@@ -582,6 +684,8 @@ def _serialize_payment_methods(
settings: Settings, settings: Settings,
app: web.Application, app: web.Application,
lang: str = "ru", lang: str = "ru",
*,
is_admin: bool = False,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
from bot.payment_providers import get_provider_spec, resolve_provider_presentation from bot.payment_providers import get_provider_spec, resolve_provider_presentation
@@ -589,7 +693,7 @@ def _serialize_payment_methods(
for method in settings.payment_methods_order: for method in settings.payment_methods_order:
method = method.lower() method = method.lower()
spec = get_provider_spec(method) 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) presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append( methods.append(
{ {
-1
View File
@@ -156,7 +156,6 @@ async def admin_panel_actions_callback_handler(
panel_service=panel_service, panel_service=panel_service,
session=session, session=session,
) )
await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status": elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data) await show_queue_status_handler(callback, i18n_data)
elif action == "view_payments": 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 import F, Router, types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import ( 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]+$") 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( async def display_logs_menu(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
): ):
@@ -94,19 +133,7 @@ async def _display_formatted_logs(
log_entries_text = [] log_entries_text = []
for log_entry_model in logs: for log_entry_model in logs:
user_display_parts = [] user_display = _format_log_entry_user(log_entry_model, _)
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_id_display = ( user_id_display = (
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A" 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 return
target_user_id = user_model_for_logs.user_id target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or ( user_display_name = _format_user_with_email(
f"@{user_model_for_logs.username}" first_name=user_model_for_logs.first_name,
if user_model_for_logs.username username=user_model_for_logs.username,
else (user_model_for_logs.email or f"ID {target_user_id}") email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
) )
logs_models = await message_log_dal.get_user_message_logs( logs_models = await message_log_dal.get_user_message_logs(
@@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler(
await callback.answer() await callback.answer()
return return
user_display_name = user_model_for_logs.first_name or ( user_display_name = _format_user_with_email(
f"@{user_model_for_logs.username}" first_name=user_model_for_logs.first_name,
if user_model_for_logs.username username=user_model_for_logs.username,
else (user_model_for_logs.email or f"ID {target_user_id}") email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
) )
logs_models = await message_log_dal.get_user_message_logs( 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_user_id"),
_("admin_csv_header_telegram_username"), _("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"), _("admin_csv_header_telegram_first_name"),
_("admin_csv_header_email"),
_("admin_csv_header_event_type"), _("admin_csv_header_event_type"),
_("admin_csv_header_content"), _("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"), _("admin_csv_header_is_admin_event"),
@@ -417,6 +447,7 @@ async def export_logs_csv_handler(
log.user_id or "", log.user_id or "",
log.telegram_username or "", log.telegram_username or "",
log.telegram_first_name or "", log.telegram_first_name or "",
_user_email(getattr(log, "author_user", None)),
log.event_type or "", log.event_type or "",
content_clean, content_clean,
"Yes" if log.is_admin_event else "No", "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 return
try: try:
if user_model.panel_user_uuid: panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid) 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: if not panel_deleted:
await message.answer( 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.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback 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 config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu from .start import send_main_menu
@@ -133,10 +138,12 @@ async def process_promo_code_input(
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n) 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( await notification_service.notify_suspicious_promo_attempt(
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
first_name=user.first_name, first_name=user.first_name,
email=getattr(db_user, "email", None) if db_user else None,
suspicious_input=code_input, suspicious_input=code_input,
) )
except Exception as e: 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"), end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text, 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( reply_markup = get_connect_and_main_keyboard(
current_lang, current_lang,
i18n, i18n,
settings, settings,
config_link_display, config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
) )
else: else:
await session.commit() await session.commit()
+36 -2
View File
@@ -17,12 +17,16 @@ from bot.keyboards.inline.user_keyboards import (
get_language_selection_keyboard, get_language_selection_keyboard,
get_main_menu_inline_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.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.callback_answer import safe_answer_callback 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 bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings from config.settings import Settings
from db.dal import user_dal from db.dal import user_dal
@@ -715,6 +719,23 @@ async def start_command_handler(
), ),
config_link=config_link_text, 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 from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
@@ -726,6 +747,7 @@ async def start_command_handler(
settings, settings,
config_link_display, config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
), ),
parse_mode="HTML", parse_mode="HTML",
) )
@@ -891,7 +913,12 @@ async def select_language_callback_handler(
try: try:
lang_payload = callback.data.split("_", 2)[2] 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: except IndexError:
await safe_answer_callback( await safe_answer_callback(
callback, callback,
@@ -899,6 +926,13 @@ async def select_language_callback_handler(
show_alert=True, show_alert=True,
) )
return 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 user_id = callback.from_user.id
try: try:
+110 -11
View File
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService 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 config.settings import Settings
from db.dal import subscription_dal, user_billing_dal from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription from db.models import Subscription
@@ -326,6 +330,7 @@ async def select_tariff_period_callback(
settings, settings,
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context), 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)}", 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.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer() await callback.answer()
@@ -378,6 +383,7 @@ async def select_tariff_package_callback(
settings, settings,
sale_mode=sale_mode, sale_mode=sale_mode,
back_callback=back_callback, 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.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer() await callback.answer()
@@ -490,6 +496,7 @@ async def select_tariff_premium_package_callback(
settings, settings,
sale_mode=f"premium_topup@{tariff.key}", sale_mode=f"premium_topup@{tariff.key}",
back_callback="tariff_topup:list", 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.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer() 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) await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
return return
tariff = config.require(active["tariff_key"]) 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 [] packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
if not packages: if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard( markup = get_hwid_device_packages_keyboard(
tariff, tariff,
packages, packages,
@@ -529,14 +540,31 @@ async def hwid_devices_list_callback(
i18n, i18n,
settings, settings,
back_callback="main_action:my_devices", 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() await callback.answer()
@router.callback_query(F.data.startswith("hwid_devices:package:")) @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( 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance") i18n: JsonI18n = i18n_data.get("i18n_instance")
@@ -545,8 +573,11 @@ async def hwid_devices_package_callback(
if not config or not callback.message: if not config or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True) await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return return
_, _, tariff_key, count_raw = callback.data.split(":", 3) _, action, tariff_key, count_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key) 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) count = int(count_raw)
package = next( package = next(
( (
@@ -559,16 +590,39 @@ async def hwid_devices_package_callback(
if not package: if not package:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return 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( markup = get_payment_method_keyboard(
count, count,
package.price, float(rub_quote.get("price") if rub_quote else 0),
None, int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0
else None,
settings.DEFAULT_CURRENCY_SYMBOL, settings.DEFAULT_CURRENCY_SYMBOL,
current_lang, current_lang,
i18n, i18n,
settings, settings,
sale_mode=f"hwid_devices@{tariff.key}", sale_mode=f"{sale_mode_base}@{tariff.key}",
back_callback="hwid_devices:list", back_callback="hwid_devices:list",
user_id=callback.from_user.id,
) )
await callback.message.edit_text( await callback.message.edit_text(
get_text("choose_payment_method_hwid_devices"), reply_markup=markup get_text("choose_payment_method_hwid_devices"), reply_markup=markup
@@ -646,7 +700,9 @@ async def tariff_change_select_callback(
if not db_sub: if not db_sub:
await callback.answer("Error", show_alert=True) await callback.answer("Error", show_alert=True)
return 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 = [] rows = []
if options["mode"] == "period_to_period": if options["mode"] == "period_to_period":
rows.append( rows.append(
@@ -733,7 +789,9 @@ async def tariff_change_confirm_apply_callback(
if not db_sub: if not db_sub:
await callback.answer("Error", show_alert=True) await callback.answer("Error", show_alert=True)
return 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": if mode == "recalc_days":
action_text = f"после перехода останется {options.get('recalc_days', 0)} дн." action_text = f"после перехода останется {options.get('recalc_days', 0)} дн."
elif mode == "convert_days_to_gb": elif mode == "convert_days_to_gb":
@@ -839,6 +897,7 @@ async def tariff_change_pay_callback(
settings, settings,
sale_mode=f"tariff_upgrade@{tariff_key}", sale_mode=f"tariff_upgrade@{tariff_key}",
back_callback=f"tariff_change:confirm_pay:{tariff_key}:{amount_raw}", 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.message.edit_text("Выберите способ оплаты", reply_markup=markup)
await callback.answer() await callback.answer()
@@ -1026,12 +1085,50 @@ async def my_subscription_command_handler(
local_sub = await subscription_dal.get_active_subscription_by_user_id( local_sub = await subscription_dal.get_active_subscription_by_user_id(
session, event.from_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 # Build rows to prepend above the base "back" markup
prepend_rows = [] prepend_rows = []
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app # 1) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display 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( prepend_rows.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
@@ -1115,7 +1212,8 @@ async def my_subscription_command_handler(
try: try:
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key) tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
if ( 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 and tariff_for_devices.hwid_device_packages.rub
): ):
prepend_rows.append( prepend_rows.append(
@@ -1331,7 +1429,8 @@ async def my_devices_command_handler(
try: try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"]) tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if ( 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 and tariff_for_devices.hwid_device_packages.rub
): ):
devices_kb.append( devices_kb.append(
@@ -62,7 +62,12 @@ async def select_subscription_period_callback_handler(
from bot.payment_providers import iter_provider_specs from bot.payment_providers import iter_provider_specs
currency_methods_enabled = any( 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() for spec in iter_provider_specs()
) )
if currency_methods_enabled: if currency_methods_enabled:
@@ -104,6 +109,7 @@ async def select_subscription_period_callback_handler(
"traffic" if traffic_mode else "subscription", callback_context "traffic" if traffic_mode else "subscription", callback_context
), ),
back_callback=subscription_options_callback(callback_context), back_callback=subscription_options_callback(callback_context),
user_id=callback.from_user.id,
) )
try: 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.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links 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 config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu from .start import send_main_menu
@@ -74,6 +79,7 @@ async def request_trial_confirmation_handler(
config_link_display_for_trial = None config_link_display_for_trial = None
config_link_for_trial = None config_link_for_trial = None
connect_button_url_for_trial = None connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"): if activation_result and activation_result.get("activated"):
try: try:
@@ -104,9 +110,23 @@ async def request_trial_confirmation_handler(
traffic_gb=traffic_display, 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 # Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n) 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 # Mark ad attribution trial if exists
try: try:
from db.dal import ad_dal as _ad_dal from db.dal import ad_dal as _ad_dal
@@ -139,6 +159,7 @@ async def request_trial_confirmation_handler(
settings, settings,
config_link_display_for_trial, config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial, connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
) )
if activation_result and activation_result.get("activated") if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard( else get_main_menu_inline_keyboard(
@@ -214,6 +235,7 @@ async def confirm_activate_trial_handler(
config_link_display_for_trial = None config_link_display_for_trial = None
config_link_for_trial = None config_link_for_trial = None
connect_button_url_for_trial = None connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"): if activation_result and activation_result.get("activated"):
try: try:
@@ -243,6 +265,13 @@ async def confirm_activate_trial_handler(
config_link=config_link_for_trial, config_link=config_link_for_trial,
traffic_gb=traffic_display, 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: else:
message_key_from_service = ( message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed") activation_result.get("message_key", "trial_activation_failed")
@@ -266,6 +295,7 @@ async def confirm_activate_trial_handler(
settings, settings,
config_link_display_for_trial, config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial, connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
) )
if activation_result and activation_result.get("activated") if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard( 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: if activation_result and activation_result.get("activated") and end_date_obj:
notification_service = NotificationService(callback.bot, settings, i18n) 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: try:
from db.dal import ad_dal as _ad_dal from db.dal import ad_dal as _ad_dal
+69 -17
View File
@@ -3,6 +3,9 @@ from typing import Any, Dict, List, Optional, Tuple
from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton 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 from config.settings import Settings
BOT_MENU_CONTEXT = "bot" BOT_MENU_CONTEXT = "bot"
@@ -54,8 +57,9 @@ def payment_methods_back_callback(
return f"tariff:package:{tariff_key}:{value}" return f"tariff:package:{tariff_key}:{value}"
if sale_base == "premium_topup" and tariff_key: if sale_base == "premium_topup" and tariff_key:
return f"tariff:premium_package:{tariff_key}:{value}" return f"tariff:premium_package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices"} and tariff_key: if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
return f"hwid_devices:package:{tariff_key}:{value}" 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: if sale_base == "tariff_upgrade" and tariff_key:
amount = str(price) if price is not None else value amount = str(price) if price is not None else value
return f"tariff_change:pay:{tariff_key}:{amount}" 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}" return f"tariff:select:{tariff_key}{context_suffix}"
if sale_base in {"topup", "premium_topup"}: if sale_base in {"topup", "premium_topup"}:
return "tariff_topup:list" 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 "hwid_devices:list"
return subscription_options_callback(context) 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( def get_main_menu_inline_keyboard(
lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() 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: if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
@@ -129,11 +150,7 @@ def get_bot_interface_inline_keyboard(
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED: if show_trial_button and settings.TRIAL_ENABLED:
builder.row( builder.row(_trial_activation_button(lang, i18n_instance, settings))
InlineKeyboardButton(
text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial"
)
)
if settings.SUBSCRIPTION_MINI_APP_URL: if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row( builder.row(
@@ -231,13 +248,17 @@ def get_language_selection_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else "" callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
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( builder.button(
text=f"🇬🇧 English {'' if current_lang == 'en' else ''}", text=f"{language['flag']} {language['label']}{checked}",
callback_data=f"set_lang_en{callback_suffix}", callback_data=f"set_lang_{lang_code}{callback_suffix}",
)
builder.button(
text=f"🇷🇺 Русский {'' if current_lang == 'ru' else ''}",
callback_data=f"set_lang_ru{callback_suffix}",
) )
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback) builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
builder.adjust(1) builder.adjust(1)
@@ -405,6 +426,7 @@ def get_hwid_device_packages_keyboard(
i18n_instance, i18n_instance,
settings: Settings, settings: Settings,
back_callback: str = "main_action:my_subscription", back_callback: str = "main_action:my_subscription",
renewal: bool = False,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -417,7 +439,10 @@ def get_hwid_device_packages_keyboard(
price=package.price, price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL, 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( builder.row(
@@ -436,6 +461,8 @@ def get_payment_method_keyboard(
settings: Settings, settings: Settings,
sale_mode: str = "subscription", sale_mode: str = "subscription",
back_callback: Optional[str] = None, back_callback: Optional[str] = None,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -454,7 +481,16 @@ def get_payment_method_keyboard(
for method in settings.payment_methods_order: for method in settings.payment_methods_order:
spec = get_provider_spec(method) 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 continue
callback_data = spec.callback_data( callback_data = spec.callback_data(
value=value_str, value=value_str,
@@ -689,13 +725,29 @@ def get_connect_and_main_keyboard(
config_link: Optional[str], config_link: Optional[str],
connect_button_url: Optional[str] = None, connect_button_url: Optional[str] = None,
preserve_message: bool = False, preserve_message: bool = False,
install_share_url: Optional[str] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button.""" """Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
install_url = bot_install_guide_url(settings)
button_target = connect_button_url or config_link 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)) builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
elif settings.SUBSCRIPTION_MINI_APP_URL: elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row( builder.row(
+7 -39
View File
@@ -5,20 +5,17 @@ from typing import Awaitable, Callable, Optional
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.exceptions import TelegramNetworkError from aiogram.exceptions import TelegramNetworkError
from aiogram.types import BotCommand, MenuButtonDefault, MenuButtonWebApp, WebAppInfo 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.controllers.dispatcher_controller import build_dispatcher
from bot.app.factories.build_services import build_core_services from bot.app.factories.build_services import build_core_services
from bot.app.web.web_server import build_and_start_web_app 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.infra.redis import close_redis
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.routers import build_root_router from bot.routers import build_root_router
from bot.services.panel_api_service import PanelApiService from bot.services.locale_override_service import load_locale_overrides
from bot.services.settings_override_service import load_overrides_from_db
from bot.utils.message_queue import init_queue_manager from bot.utils.message_queue import init_queue_manager
from config.settings import Settings 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 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.") raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
if settings.SUBSCRIPTION_MINI_APP_URL: if settings.SUBSCRIPTION_MINI_APP_URL:
async def _configure_mini_app_menu() -> None: async def _configure_mini_app_menu() -> None:
menu_text = i18n_instance.gettext( menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE, settings.DEFAULT_LANGUAGE,
@@ -169,6 +167,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
) )
await bot.set_chat_menu_button(menu_button=MenuButtonDefault()) await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
logging.info("STARTUP: Mini app domain registered and default menu button restored.") logging.info("STARTUP: Mini app domain registered and default menu button restored.")
await _run_telegram_startup_step( await _run_telegram_startup_step(
"registering mini app menu button", "registering mini app menu button",
_configure_mini_app_menu, _configure_mini_app_menu,
@@ -186,6 +185,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
) )
await bot.set_my_commands(bot_commands) await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.") logging.info("STARTUP: bot command descriptions set.")
await _run_telegram_startup_step( await _run_telegram_startup_step(
"setting bot commands", "setting bot commands",
_configure_bot_commands, _configure_bot_commands,
@@ -200,42 +200,9 @@ async def on_startup_configured(dispatcher: Dispatcher):
except Exception: except Exception:
logging.exception("STARTUP: Failed to initialize message queue manager.") 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.") 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): async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning("SHUTDOWN: on_shutdown_configured executing...") 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: if local_async_session_factory is None:
logging.critical("Failed to initialize database connection and session factory. Exiting.") logging.critical("Failed to initialize database connection and session factory. Exiting.")
return 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) dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
i18n_instance = extra["i18n_instance"] 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 # Get bot username for YooKassa default return URL if needed
actual_bot_username = "your_bot_username" actual_bot_username = "your_bot_username"
+439 -9
View File
@@ -1,7 +1,10 @@
import json import json
import logging import logging
import os 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 import BaseMiddleware
from aiogram.types import Update, User from aiogram.types import Update, User
@@ -10,14 +13,309 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings from config.settings import Settings
from db.dal import user_dal 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: 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.domain = domain
self.path = path self.path = path
self.default_lang = default 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.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() self._load_locales()
if overrides_path:
self.configure_overrides_file(overrides_path)
self.reload_overrides_from_file(force=True)
logging.info( logging.info(
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501 f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
) )
@@ -26,13 +324,26 @@ class JsonI18n:
if not os.path.isdir(self.path): if not os.path.isdir(self.path):
logging.error(f"Locales path not found or not a directory: {self.path}") logging.error(f"Locales path not found or not a directory: {self.path}")
return return
loaded: Dict[str, Dict[str, str]] = {}
for item in os.listdir(self.path): for item in os.listdir(self.path):
if item.endswith(".json"): if item.endswith(".json"):
lang_code = item.split(".")[0] lang_code = item.split(".")[0]
file_path = os.path.join(self.path, item) file_path = os.path.join(self.path, item)
try: try:
with open(file_path, "r", encoding="utf-8") as f: 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: except json.JSONDecodeError as e_json_load:
logging.error( logging.error(
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501 f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
@@ -42,24 +353,143 @@ class JsonI18n:
f"Error loading locale {lang_code} from {file_path}: {e_load}", f"Error loading locale {lang_code} from {file_path}: {e_load}",
exc_info=True, 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: 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 # Determine effective language with robust fallback
if lang_code and lang_code in self.locales_data: if requested_lang_code and requested_lang_code in self.locales_data:
effective_lang_code = lang_code 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: elif self.default_lang in self.locales_data:
effective_lang_code = self.default_lang effective_lang_code = self.default_lang
elif "en" in self.locales_data: elif "en" in self.locales_data:
effective_lang_code = "en" effective_lang_code = "en"
else: 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) lang_data = self.locales_data.get(effective_lang_code)
if lang_data is None: if lang_data is None:
# Try explicit fallback to English if available # Try explicit fallback to English if available
fallback_data = self.locales_data.get("en") fallback_data = self.locales_data.get("en")
if fallback_data is not None: if fallback_data is not None:
text = fallback_data.get(key) text = fallback_data.get(lookup_key)
if text is not None: if text is not None:
try: try:
return text.format(**kwargs) if kwargs else text return text.format(**kwargs) if kwargs else text
@@ -70,11 +500,11 @@ class JsonI18n:
) )
return key.format(**kwargs) if kwargs else key return key.format(**kwargs) if kwargs else key
text = lang_data.get(key) text = lang_data.get(lookup_key)
if text is None: if text is None:
if effective_lang_code != self.default_lang: if effective_lang_code != self.default_lang:
default_lang_data = self.locales_data.get(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: if text is None:
logging.warning( logging.warning(
+5 -14
View File
@@ -8,7 +8,7 @@ from aiogram.types import User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.infra.redis import cache_get_json, cache_set_json, redis_key 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 config.settings import Settings
from db.dal import user_dal 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 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: try:
panel_service = data.get("panel_service") panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid: 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 = { panel_payload = {
"description": description_text,
"telegramId": tg_user.id, "telegramId": tg_user.id,
} }
if db_user.email: if db_user.email:
@@ -81,7 +72,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
) )
except Exception as e_upd_desc: except Exception as e_upd_desc:
logging.warning( 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: except Exception as e:
logging.error( logging.error(
@@ -22,9 +22,11 @@ from .registry import (
iter_unique_provider_routers, iter_unique_provider_routers,
manifest_field_default, manifest_field_default,
pending_statuses, pending_statuses,
provider_admin_only_pairs,
provider_emoji_map, provider_emoji_map,
provider_label_map, provider_label_map,
provider_telegram_button_text, provider_telegram_button_text,
provider_webhook_metadata,
resolve_provider_presentation, resolve_provider_presentation,
) )
@@ -52,6 +54,8 @@ __all__ = [
"pending_statuses", "pending_statuses",
"provider_telegram_button_text", "provider_telegram_button_text",
"provider_emoji_map", "provider_emoji_map",
"provider_admin_only_pairs",
"provider_label_map", "provider_label_map",
"provider_webhook_metadata",
"resolve_provider_presentation", "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. env vars it consumes no edits in the global ``Settings`` required.
""" """
ADMIN_ONLY_ENABLED: bool = False
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", 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) @dataclass(frozen=True)
class ProviderConfigBundle: class ProviderConfigBundle:
"""Functional config + presentation overrides for a single provider.""" """Functional config + presentation overrides for a single provider."""
@@ -68,6 +79,9 @@ class ProviderManifestField:
attr: Optional[str] = ( attr: Optional[str] = (
None # attribute name on the target model; defaults to key without env_prefix 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) @dataclass(frozen=True)
@@ -99,6 +113,11 @@ class WebAppPaymentContext:
description: str description: str
sale_mode: str sale_mode: str
traffic_gb: Optional[float] = None 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] EnabledPredicate = Callable[[Any], bool]
@@ -135,11 +154,23 @@ class PaymentProviderSpec:
config_class: Optional[Type[ProviderEnvConfig]] = None config_class: Optional[Type[ProviderEnvConfig]] = None
presentation_class: Optional[Type[ProviderEnvConfig]] = None presentation_class: Optional[Type[ProviderEnvConfig]] = None
manifest_fields: Sequence[ProviderManifestField] = () 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 @property
def settings_key(self) -> str: def settings_key(self) -> str:
return self.id.upper() 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 @property
def default_telegram_emoji(self) -> str: def default_telegram_emoji(self) -> str:
return self.telegram_emoji or self.emoji return self.telegram_emoji or self.emoji
@@ -148,7 +179,7 @@ class PaymentProviderSpec:
def method_ids(self) -> tuple[str, ...]: def method_ids(self) -> tuple[str, ...]:
return (self.id, *tuple(self.aliases)) 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 # If this spec carries a provider-local config_class, prefer the live
# config bundle so callers can pass plain Settings without having to # config bundle so callers can pass plain Settings without having to
# know about provider-local env layouts. # know about provider-local env layouts.
@@ -157,8 +188,46 @@ class PaymentProviderSpec:
bundle = get_provider_bundle(self.service_key) bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None: if bundle and bundle.config is not None:
return bool(self.enabled(bundle.config)) return bool(predicate(bundle.config))
return bool(self.enabled(source)) 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: def is_service_configured(self, app: Any) -> bool:
if not self.requires_configured_service: if not self.requires_configured_service:
@@ -171,6 +240,43 @@ class PaymentProviderSpec:
def is_visible(self, source: Any, app: Any) -> bool: def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app) 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: def load_router(self) -> Any:
return self.router return self.router
+47 -4
View File
@@ -26,6 +26,7 @@ from .base import (
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
provider_env_file, provider_env_file,
provider_runtime_enabled,
) )
from .shared import ( from .shared import (
PaymentSuccessRequest, PaymentSuccessRequest,
@@ -37,9 +38,12 @@ from .shared import (
parse_payment_callback, parse_payment_callback,
payment_failed, payment_failed,
payment_link_response, payment_link_response,
payment_record_amounts,
payment_unavailable, payment_unavailable,
quote_hwid_callback_parts,
render_payment_link, render_payment_link,
sale_mode_base, sale_mode_base,
sale_mode_is_traffic,
sale_mode_tariff_key, sale_mode_tariff_key,
) )
@@ -119,7 +123,7 @@ class CryptoPayService:
@property @property
def configured(self) -> bool: 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 @property
def client(self): def client(self):
@@ -154,13 +158,14 @@ class CryptoPayService:
description: str, description: str,
sale_mode: str = "subscription", sale_mode: str = "subscription",
url_kind: str = "bot", url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
) -> Optional[str]: ) -> Optional[str]:
if not self.configured or not self.client: if not self.configured or not self.client:
logging.error("CryptoPayService not configured") logging.error("CryptoPayService not configured")
return None return None
sale_base = sale_mode_base(sale_mode) 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: try:
payment_record = await payment_dal.create_payment_record( payment_record = await payment_dal.create_payment_record(
session, session,
@@ -176,7 +181,17 @@ class CryptoPayService:
"provider": "cryptopay", "provider": "cryptopay",
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(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() await session.commit()
@@ -191,7 +206,7 @@ class CryptoPayService:
"subscription_months": str(months), "subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id), "payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode, "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: try:
@@ -349,11 +364,29 @@ async def pay_crypto_callback_handler(
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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): if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
await notify_service_unavailable(callback, translator) await notify_service_unavailable(callback, translator)
return return
parts = parse_payment_callback(callback.data or "") 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: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return return
@@ -366,6 +399,7 @@ async def pay_crypto_callback_handler(
amount=parts.price, amount=parts.price,
description=payment_description, description=payment_description,
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
) )
if invoice_url: if invoice_url:
@@ -415,6 +449,15 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description, description=ctx.description,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
url_kind="web", 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: if not url:
return payment_failed() return payment_failed()
+23 -2
View File
@@ -29,6 +29,7 @@ from .base import (
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
provider_env_file, provider_env_file,
provider_runtime_enabled,
) )
from .shared import ( from .shared import (
HttpClientMixin, HttpClientMixin,
@@ -49,6 +50,7 @@ from .shared import (
payment_failed, payment_failed,
payment_unavailable, payment_unavailable,
post_json_request, post_json_request,
quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
) )
@@ -152,14 +154,14 @@ class FreeKassaService(HttpClientMixin):
logging.warning( logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled." "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( logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501 "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
) )
@property @property
def configured(self) -> bool: 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 @property
def shop_id(self): def shop_id(self):
@@ -451,6 +453,14 @@ async def pay_fk_callback_handler(
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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: if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.") logging.error("FreeKassa service is not configured or unavailable.")
await notify_service_unavailable(callback, translator) await notify_service_unavailable(callback, translator)
@@ -461,6 +471,16 @@ async def pay_fk_callback_handler(
logging.error("Invalid pay_fk data in callback: %s", callback.data) logging.error("Invalid pay_fk data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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 = ( currency_code = (
getattr(freekassa_service, "default_currency", None) getattr(freekassa_service, "default_currency", None)
@@ -477,6 +497,7 @@ async def pay_fk_callback_handler(
months=parts.months, months=parts.months,
provider="freekassa", provider="freekassa",
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
+22 -1
View File
@@ -27,6 +27,7 @@ from .base import (
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
provider_env_file, provider_env_file,
provider_runtime_enabled,
) )
from .shared import ( from .shared import (
HttpClientMixin, HttpClientMixin,
@@ -48,6 +49,7 @@ from .shared import (
parse_payment_callback, parse_payment_callback,
payment_failed, payment_failed,
payment_unavailable, payment_unavailable,
quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
) )
@@ -241,7 +243,7 @@ class HeleketService(HttpClientMixin):
# ``False`` state from startup and the button would never appear. # ``False`` state from startup and the button would never appear.
@property @property
def configured(self) -> bool: 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 @property
def base_url(self) -> str: def base_url(self) -> str:
@@ -546,6 +548,14 @@ async def pay_heleket_callback_handler(
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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: if not heleket_service or not heleket_service.configured:
logging.error("Heleket service is not configured or unavailable.") logging.error("Heleket service is not configured or unavailable.")
await notify_service_unavailable(callback, translator) await notify_service_unavailable(callback, translator)
@@ -556,6 +566,16 @@ async def pay_heleket_callback_handler(
logging.error("Invalid pay_heleket data in callback: %s", callback.data) logging.error("Invalid pay_heleket data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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() currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
payment_description = describe_payment(translator, parts) payment_description = describe_payment(translator, parts)
@@ -568,6 +588,7 @@ async def pay_heleket_callback_handler(
months=parts.months, months=parts.months,
provider="heleket", provider="heleket",
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
+51 -7
View File
@@ -23,6 +23,7 @@ from .base import (
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
provider_env_file, provider_env_file,
provider_runtime_enabled,
) )
from .shared import ( from .shared import (
HttpClientMixin, HttpClientMixin,
@@ -45,6 +46,7 @@ from .shared import (
payment_record_amounts, payment_record_amounts,
payment_unavailable, payment_unavailable,
post_json_request, post_json_request,
quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
safe_callback_answer, safe_callback_answer,
) )
@@ -66,7 +68,9 @@ class PlategaConfig(ProviderEnvConfig):
SECRET: Optional[str] = None SECRET: Optional[str] = None
PAYMENT_METHOD: int = Field(default=2) PAYMENT_METHOD: int = Field(default=2)
SBP_ENABLED: bool = Field(default=False) SBP_ENABLED: bool = Field(default=False)
SBP_ADMIN_ONLY_ENABLED: bool = Field(default=False)
CRYPTO_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) SBP_METHOD: int = Field(default=2)
CRYPTO_METHOD: int = Field(default=13) CRYPTO_METHOD: int = Field(default=13)
RETURN_URL: Optional[str] = None RETURN_URL: Optional[str] = None
@@ -161,7 +165,15 @@ class PlategaService(HttpClientMixin):
@property @property
def configured(self) -> bool: 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 @property
def base_url(self) -> str: def base_url(self) -> str:
@@ -395,17 +407,23 @@ def _resolve_platega_variant(
) -> Optional[Tuple[str, int]]: ) -> Optional[Tuple[str, int]]:
"""Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled.""" """Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled."""
if callback_prefix == "pay_platega_crypto": 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 None
return "crypto", config.CRYPTO_METHOD return "crypto", config.CRYPTO_METHOD
if callback_prefix == "pay_platega_sbp": 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 None
return "sbp", config.sbp_method_resolved return "sbp", config.sbp_method_resolved
# Legacy "pay_platega:" callback — keep working as SBP. # Legacy "pay_platega:" callback — keep working as SBP.
return "sbp", config.sbp_method_resolved 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( @router.callback_query(
F.data.startswith("pay_platega_sbp:") F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:") | F.data.startswith("pay_platega_crypto:")
@@ -429,6 +447,15 @@ async def pay_platega_callback_handler(
return return
callback_prefix, _, _ = (callback.data or "").partition(":") 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 = ( variant = (
_resolve_platega_variant(callback_prefix, platega_service.config) _resolve_platega_variant(callback_prefix, platega_service.config)
if platega_service if platega_service
@@ -449,6 +476,16 @@ async def pay_platega_callback_handler(
logging.error("Invalid pay_platega data in callback: %s", callback.data) logging.error("Invalid pay_platega data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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" currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts) payment_description = describe_payment(translator, parts)
@@ -461,6 +498,7 @@ async def pay_platega_callback_handler(
months=parts.months, months=parts.months,
provider="platega", provider="platega",
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
@@ -538,11 +576,13 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
if not service or not service.configured: if not service or not service.configured:
return payment_unavailable() return payment_unavailable()
if variant == "platega_crypto": 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() return payment_unavailable()
platega_method_id = service.config.CRYPTO_METHOD platega_method_id = service.config.CRYPTO_METHOD
else: 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() return payment_unavailable()
platega_method_id = service.config.sbp_method_resolved platega_method_id = service.config.sbp_method_resolved
@@ -738,6 +778,8 @@ SBP_SPEC = PaymentProviderSpec(
enabled=lambda config: bool( enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "SBP_ENABLED", False) 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", service_key="platega_service",
callback_prefix="pay_platega_sbp", callback_prefix="pay_platega_sbp",
aliases=("platega",), aliases=("platega",),
@@ -749,7 +791,7 @@ SBP_SPEC = PaymentProviderSpec(
config_class=PlategaConfig, config_class=PlategaConfig,
presentation_class=PlategaSbpPresentation, presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST manifest_fields=_CONFIG_MANIFEST
+ _platega_presentation_manifest("Platega SBP", "CreditCard", "PLATEGA_SBP"), + _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
) )
CRYPTO_SPEC = PaymentProviderSpec( CRYPTO_SPEC = PaymentProviderSpec(
@@ -767,12 +809,14 @@ CRYPTO_SPEC = PaymentProviderSpec(
enabled=lambda config: bool( enabled=lambda config: bool(
getattr(config, "ENABLED", False) and getattr(config, "CRYPTO_ENABLED", False) 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", service_key="platega_service",
callback_prefix="pay_platega_crypto", callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment, create_webapp_payment=create_crypto_webapp_payment,
config_class=PlategaConfig, config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation, 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) SPECS = (SBP_SPEC, CRYPTO_SPEC)
+84
View File
@@ -321,8 +321,13 @@ def pending_statuses() -> List[str]:
def iter_provider_manifest_fields() -> Iterable[tuple[PaymentProviderSpec, ProviderManifestField]]: def iter_provider_manifest_fields() -> Iterable[tuple[PaymentProviderSpec, ProviderManifestField]]:
"""Yield (spec, manifest_field) for every fragment declared on a provider SPEC.""" """Yield (spec, manifest_field) for every fragment declared on a provider SPEC."""
for spec in PAYMENT_PROVIDER_SPECS: for spec in PAYMENT_PROVIDER_SPECS:
emitted_keys: set[str] = set()
for field in spec.manifest_fields: for field in spec.manifest_fields:
emitted_keys.add(field.key)
yield spec, field 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]]: def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, ProviderManifestField]]:
@@ -333,6 +338,85 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide
return None 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
if not spec.service_key:
return None
for candidate in PAYMENT_PROVIDER_SPECS:
if (
candidate.service_key == spec.service_key
and candidate.webhook_path
and candidate.webhook_route
):
return candidate
return None
def provider_webhook_metadata(spec: PaymentProviderSpec) -> Optional[Dict[str, Any]]:
"""Return admin-manifest webhook metadata for a provider SPEC.
Some visible payment buttons share one backing service and webhook route
(for example Platega SBP and Platega Crypto), so presentation-only specs
inherit the route from their service sibling.
"""
webhook_spec = _webhook_spec_for(spec)
if webhook_spec is None or not webhook_spec.webhook_path:
return None
try:
path = str(webhook_spec.webhook_path(None) or "").strip()
except Exception:
return None
if not path:
return None
return {
"provider_id": spec.id,
"provider_label": spec.label,
"webhook_provider_id": webhook_spec.id,
"webhook_path": path,
"webhook_requires_base_url": bool(webhook_spec.webhook_requires_base_url),
}
def manifest_field_default( def manifest_field_default(
spec: PaymentProviderSpec, spec: PaymentProviderSpec,
manifest_field: ProviderManifestField, manifest_field: ProviderManifestField,
+22 -1
View File
@@ -25,6 +25,7 @@ from .base import (
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
provider_env_file, provider_env_file,
provider_runtime_enabled,
) )
from .shared import ( from .shared import (
HttpClientMixin, HttpClientMixin,
@@ -46,6 +47,7 @@ from .shared import (
payment_failed, payment_failed,
payment_unavailable, payment_unavailable,
post_json_request, post_json_request,
quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
) )
@@ -135,7 +137,7 @@ class SeverPayService(HttpClientMixin):
@property @property
def configured(self) -> bool: 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 @property
def base_url(self) -> str: def base_url(self) -> str:
@@ -395,6 +397,14 @@ async def pay_severpay_callback_handler(
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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: if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.") logging.error("SeverPay service is not configured or unavailable.")
await notify_service_unavailable(callback, translator) await notify_service_unavailable(callback, translator)
@@ -405,6 +415,16 @@ async def pay_severpay_callback_handler(
logging.error("Invalid pay_severpay data in callback: %s", callback.data) logging.error("Invalid pay_severpay data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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" currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts) payment_description = describe_payment(translator, parts)
@@ -417,6 +437,7 @@ async def pay_severpay_callback_handler(
months=parts.months, months=parts.months,
provider="severpay", provider="severpay",
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
@@ -15,6 +15,7 @@ from .callbacks import (
notify_service_unavailable, notify_service_unavailable,
parse_payment_callback, parse_payment_callback,
payment_link_message_text, payment_link_message_text,
quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
render_payment_link, render_payment_link,
safe_callback_answer, safe_callback_answer,
@@ -55,6 +56,7 @@ from .success import (
PaymentSuccessOutcome, PaymentSuccessOutcome,
PaymentSuccessRequest, PaymentSuccessRequest,
SuccessMessage, SuccessMessage,
append_hwid_renewal_note,
build_success_message, build_success_message,
finalize_successful_payment, finalize_successful_payment,
is_traffic_sale_base, is_traffic_sale_base,
@@ -82,6 +84,7 @@ __all__ = [
"build_payment_description", "build_payment_description",
"build_payment_record_payload", "build_payment_record_payload",
"build_success_message", "build_success_message",
"append_hwid_renewal_note",
"coerce_payment_db_id", "coerce_payment_db_id",
"create_base_payment_record", "create_base_payment_record",
"create_webapp_payment_record", "create_webapp_payment_record",
@@ -113,6 +116,7 @@ __all__ = [
"payment_record_amounts", "payment_record_amounts",
"payment_unavailable", "payment_unavailable",
"post_json_request", "post_json_request",
"quote_hwid_callback_parts",
"render_link_or_fail", "render_link_or_fail",
"render_payment_link", "render_payment_link",
"resolve_inviter_name", "resolve_inviter_name",
@@ -21,6 +21,8 @@ from .common import (
format_human_units, format_human_units,
mark_payment_failed_creation, mark_payment_failed_creation,
sale_mode_base, sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_tariff_key,
) )
@@ -112,6 +114,34 @@ 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
quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(parts.months),
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=parts.months,
price=float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
def payment_link_message_text( def payment_link_message_text(
translator: Translator, translator: Translator,
parts: PaymentCallbackParts, parts: PaymentCallbackParts,
+30 -3
View File
@@ -60,7 +60,7 @@ def build_payment_description(
"payment_description_traffic", "payment_description_traffic",
traffic_gb=human_value if human_value is not None else format_human_units(months), 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_hwid_devices", count=int(float(months)))
return translator("payment_description_subscription", months=int(float(months))) return translator("payment_description_subscription", months=int(float(months)))
@@ -75,6 +75,7 @@ def build_payment_record_payload(
months: Any, months: Any,
provider: str, provider: str,
sale_mode: str, sale_mode: str,
hwid_quote: Optional[dict] = None,
) -> dict: ) -> dict:
"""Assemble the payment-record dict that every callback handler used to inline. """Assemble the payment-record dict that every callback handler used to inline.
@@ -85,7 +86,7 @@ def build_payment_record_payload(
base = sale_mode_base(sale_mode) base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode) is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode) is_hwid = sale_mode_is_hwid_devices(sale_mode)
return { payload = {
"user_id": user_id, "user_id": user_id,
"amount": amount, "amount": amount,
"currency": currency, "currency": currency,
@@ -98,6 +99,17 @@ def build_payment_record_payload(
"purchased_gb": float(months) if is_traffic else None, "purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid 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) @dataclass(frozen=True)
@@ -119,7 +131,7 @@ def sale_mode_is_traffic(sale_mode: str) -> bool:
def sale_mode_is_hwid_devices(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]: def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
@@ -194,6 +206,11 @@ async def create_base_payment_record(
tariff_key: Optional[str] = None, tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None, purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = 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:
payment = await payment_dal.create_payment_record( payment = await payment_dal.create_payment_record(
session, session,
@@ -209,6 +226,11 @@ async def create_base_payment_record(
"tariff_key": tariff_key, "tariff_key": tariff_key,
"purchased_gb": purchased_gb, "purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices, "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() await session.commit()
@@ -241,6 +263,11 @@ async def create_webapp_payment_record(
tariff_key=amounts.tariff_key, tariff_key=amounts.tariff_key,
purchased_gb=amounts.purchased_gb, purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices, 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.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links 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 bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal from db.dal import payment_dal, user_dal
from db.models import Payment, User 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 from .common import Translator, format_human_units, make_translator, sale_mode_base
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"} _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: def is_traffic_sale_base(sale_base: str) -> bool:
@@ -70,7 +72,6 @@ class SuccessMessage:
months: Any months: Any
base_end_date: Optional[datetime] base_end_date: Optional[datetime]
final_end_date: Optional[datetime] final_end_date: Optional[datetime]
config_link_text: str
applied_referee_bonus_days: int = 0 applied_referee_bonus_days: int = 0
applied_promo_bonus_days: int = 0 applied_promo_bonus_days: int = 0
inviter_name: Optional[str] = None inviter_name: Optional[str] = None
@@ -97,7 +98,11 @@ def build_success_message(payload: SuccessMessage) -> str:
"payment_successful_traffic_full", "payment_successful_traffic_full",
traffic_gb=format_human_units(payload.months), traffic_gb=format_human_units(payload.months),
end_date=end_text, 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: 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) 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, bonus_days=payload.applied_referee_bonus_days,
final_end_date=end_text, final_end_date=end_text,
inviter_name=payload.inviter_name or _("friend_placeholder"), 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: if payload.applied_promo_bonus_days and payload.final_end_date:
return _( return _(
@@ -116,16 +120,36 @@ def build_success_message(payload: SuccessMessage) -> str:
months=payload.months, months=payload.months,
bonus_days=payload.applied_promo_bonus_days, bonus_days=payload.applied_promo_bonus_days,
end_date=end_text, end_date=end_text,
config_link=payload.config_link_text,
) )
return _( return _(
"payment_successful_full", "payment_successful_full",
months=payload.months, months=payload.months,
end_date=end_text, 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( async def send_success_message_to_user(
*, *,
bot: Bot, bot: Bot,
@@ -136,6 +160,7 @@ async def send_success_message_to_user(
settings: Any, settings: Any,
config_link_display: Optional[str], config_link_display: Optional[str],
connect_button_url: Optional[str], connect_button_url: Optional[str],
install_share_url: Optional[str] = None,
include_keyboard: bool = True, include_keyboard: bool = True,
log_prefix: str = "payment_providers", log_prefix: str = "payment_providers",
) -> None: ) -> None:
@@ -148,6 +173,7 @@ async def send_success_message_to_user(
settings, settings,
config_link_display, config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
preserve_message=True, preserve_message=True,
) )
try: try:
@@ -177,6 +203,7 @@ async def notify_admins_payment_received(
traffic_is_premium: bool, traffic_is_premium: bool,
tariff_key: Optional[str], tariff_key: Optional[str],
log_prefix: str = "payment_providers", log_prefix: str = "payment_providers",
email: Optional[str] = None,
) -> None: ) -> None:
"""Push the standard ``notify_payment_received`` to the admin log channel.""" """Push the standard ``notify_payment_received`` to the admin log channel."""
try: try:
@@ -189,6 +216,7 @@ async def notify_admins_payment_received(
traffic_gb=traffic_gb_for_admin, traffic_gb=traffic_gb_for_admin,
payment_provider=payment_provider, payment_provider=payment_provider,
username=username, username=username,
email=email,
traffic_is_premium=traffic_is_premium, traffic_is_premium=traffic_is_premium,
tariff_key=tariff_key, tariff_key=tariff_key,
) )
@@ -299,7 +327,6 @@ async def finalize_successful_payment(
config_link_display, connect_button_url = await prepare_config_links( config_link_display, connect_button_url = await prepare_config_links(
req.settings, raw_config_link 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 base_end_date = activation.get("end_date") if activation else None
final_end_date = base_end_date final_end_date = base_end_date
@@ -323,15 +350,41 @@ async def finalize_successful_payment(
), ),
base_end_date=base_end_date, base_end_date=base_end_date,
final_end_date=final_end_date, final_end_date=final_end_date,
config_link_text=config_link_text,
applied_referee_bonus_days=applied_referee_bonus_days, applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days, applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name, 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: if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}" 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( await send_success_message_to_user(
bot=req.bot, bot=req.bot,
user_id=req.user_id, user_id=req.user_id,
@@ -341,6 +394,7 @@ async def finalize_successful_payment(
settings=req.settings, settings=req.settings,
config_link_display=config_link_display, config_link_display=config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=not req.skip_keyboard, include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix, log_prefix=req.log_prefix,
) )
@@ -359,6 +413,7 @@ async def finalize_successful_payment(
traffic_gb_for_admin=traffic_gb_for_activation, traffic_gb_for_admin=traffic_gb_for_activation,
payment_provider=req.provider_notification, payment_provider=req.provider_notification,
username=db_user.username if db_user else None, 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", traffic_is_premium=base == "premium_topup",
tariff_key=tariff_key, tariff_key=tariff_key,
log_prefix=req.log_prefix, log_prefix=req.log_prefix,
+30 -6
View File
@@ -35,6 +35,7 @@ from .shared import (
payment_failed, payment_failed,
payment_record_amounts, payment_record_amounts,
payment_unavailable, payment_unavailable,
quote_hwid_callback_parts,
safe_callback_answer, safe_callback_answer,
sale_mode_base, sale_mode_base,
sale_mode_tariff_key, sale_mode_tariff_key,
@@ -80,9 +81,10 @@ class StarsService:
stars_price: int, stars_price: int,
description: str, description: str,
sale_mode: str = "subscription", sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> Optional[int]: ) -> Optional[int]:
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
sale_base = sale_mode_base(sale_mode) sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
payment_record_data = { payment_record_data = {
"user_id": user_id, "user_id": user_id,
"amount": float(stars_price), "amount": float(stars_price),
@@ -93,7 +95,15 @@ class StarsService:
"provider": "telegram_stars", "provider": "telegram_stars",
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(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: try:
db_payment_record = await payment_dal.create_payment_record( db_payment_record = await payment_dal.create_payment_record(
@@ -197,11 +207,25 @@ async def pay_stars_callback_handler(
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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) await notify_service_unavailable(callback, translator)
return return
parts = parse_payment_callback(callback.data or "") 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: if not parts:
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return return
@@ -217,6 +241,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price, stars_price=stars_price,
description=payment_description, description=payment_description,
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
) )
if payment_db_id: if payment_db_id:
@@ -440,9 +465,8 @@ SPEC = PaymentProviderSpec(
webapp_icon="Sparkles", webapp_icon="Sparkles",
telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"}, telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"},
pending_status="pending_stars", pending_status="pending_stars",
# STARS_ENABLED stays on the global Settings subscription_options reads # Stars toggles stay on global Settings because stars_subscription_options
# it together with STARS_PRICE_* fields, so it has cross-cutting bizlogic # reads them together with STARS_PRICE_* fields.
# reach beyond just the provider flag.
enabled=lambda settings: bool(getattr(settings, "STARS_ENABLED", False)), enabled=lambda settings: bool(getattr(settings, "STARS_ENABLED", False)),
service_key="stars_service", service_key="stars_service",
callback_prefix="pay_stars", callback_prefix="pay_stars",
+612 -103
View File
@@ -2,7 +2,7 @@ import base64
import json import json
import logging import logging
from datetime import datetime, timedelta, timezone 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 aiogram import Bot, F, Router, types
from aiohttp import web from aiohttp import web
@@ -28,6 +28,7 @@ from .base import (
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
provider_env_file, provider_env_file,
provider_runtime_enabled,
) )
from .shared import ( from .shared import (
HttpClientMixin, HttpClientMixin,
@@ -48,13 +49,76 @@ from .shared import (
notify_user_payment_failed, notify_user_payment_failed,
parse_payment_callback, parse_payment_callback,
payment_failed, payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable, payment_unavailable,
post_json_request, post_json_request,
quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
render_payment_link,
safe_callback_answer,
sale_mode_base,
) )
router = Router(name="user_subscription_payments_wata_router") router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata" _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): class WataConfig(ProviderEnvConfig):
@@ -70,21 +134,15 @@ class WataConfig(ProviderEnvConfig):
BASE_URL: str = Field(default="https://api.wata.pro/api/h2h") BASE_URL: str = Field(default="https://api.wata.pro/api/h2h")
RETURN_URL: Optional[str] = None RETURN_URL: Optional[str] = None
FAILED_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) WEBHOOK_VERIFY_SIGNATURE: bool = Field(default=True)
PUBLIC_KEY: Optional[str] = None PUBLIC_KEY: Optional[str] = None
TRUSTED_IPS: str = Field(default="62.84.126.140,51.250.106.150") 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 @classmethod
def _clamp_ttl(cls, v): def _clamp_link_ttl_minutes(cls, v):
if isinstance(v, str): return _clamp_wata_link_ttl_minutes(v, default=_WATA_LINK_DEFAULT_TTL_MINUTES)
v = v.strip()
try:
value = int(v)
except (TypeError, ValueError):
return 3
return min(30, max(1, value))
@field_validator("API_TOKEN", "RETURN_URL", "FAILED_URL", "PUBLIC_KEY", mode="before") @field_validator("API_TOKEN", "RETURN_URL", "FAILED_URL", "PUBLIC_KEY", mode="before")
@classmethod @classmethod
@@ -141,13 +199,13 @@ class WataService(HttpClientMixin):
self._default_return_url = default_return_url self._default_return_url = default_return_url
self._cached_public_key_pem = None # populated by webhook on first verify 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: if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.") logging.warning("WataService initialized but not fully configured. Payments disabled.")
@property @property
def configured(self) -> bool: 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 @property
def base_url(self) -> str: def base_url(self) -> str:
@@ -166,8 +224,8 @@ class WataService(HttpClientMixin):
return self.config.FAILED_URL or self.return_url return self.config.FAILED_URL or self.return_url
@property @property
def payment_link_ttl_days(self) -> int: def payment_link_ttl_minutes(self) -> int:
return self.config.PAYMENT_LINK_TTL_DAYS return self.config.LINK_TTL_MINUTES
@property @property
def verify_webhook_signature(self) -> bool: def verify_webhook_signature(self) -> bool:
@@ -200,7 +258,9 @@ class WataService(HttpClientMixin):
return False, {"message": "service_not_configured"} return False, {"message": "service_not_configured"}
session = await self._get_session() 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] = { body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)), "amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(), "currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
@@ -208,7 +268,7 @@ class WataService(HttpClientMixin):
"orderId": str(payment_db_id), "orderId": str(payment_db_id),
"successRedirectUrl": self.return_url, "successRedirectUrl": self.return_url,
"failRedirectUrl": self.failed_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( return await post_json_request(
session, session,
@@ -216,6 +276,120 @@ class WataService(HttpClientMixin):
body=body, body=body,
headers=self._auth_headers(), headers=self._auth_headers(),
log_prefix="Wata create_payment_link", 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]: async def _get_public_key_pem(self) -> Optional[str]:
@@ -256,6 +430,287 @@ class WataService(HttpClientMixin):
logging.exception("Wata webhook: signature verification failed.") logging.exception("Wata webhook: signature verification failed.")
return False 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
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=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: async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured: if not self.configured:
return web.Response(status=503, text="wata_disabled") return web.Response(status=503, text="wata_disabled")
@@ -287,12 +742,11 @@ class WataService(HttpClientMixin):
return web.Response(status=400, text="bad_request") return web.Response(status=400, text="bad_request")
transaction_id = str(payload.get("transactionId") or "").strip() 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() status = str(payload.get("transactionStatus") or "").strip().lower()
order_id_raw = payload.get("orderId") 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) logging.error("Wata webhook: missing transaction status or ids: %s", payload)
return web.Response(status=400, text="missing_fields") return web.Response(status=400, text="missing_fields")
@@ -302,106 +756,63 @@ class WataService(HttpClientMixin):
order_id_raw=order_id_raw, order_id_raw=order_id_raw,
provider_payment_id=transaction_id or None, 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: if not payment:
logging.error( 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, order_id_raw,
transaction_id, transaction_id,
payment_link_id,
) )
return web.Response(status=404, text="payment_not_found") 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") 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:
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: try:
await payment_dal.update_provider_payment_and_status( await payment_dal.update_provider_payment_and_status(
session, session,
payment.payment_id, payment.payment_id,
resolved_transaction_id, transaction_id,
"succeeded", payment.status,
) )
await session.commit() await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
logging.exception( logging.exception(
"Wata webhook: failed to mark payment %s as succeeded.", "Wata webhook: failed to persist transaction id %s for payment %s.",
resolved_transaction_id, transaction_id,
payment.payment_id,
) )
return web.Response(status=500, text="processing_error") return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1 if status == "paid":
sale_mode = payment.sale_mode or ( if not await self._mark_paid_from_payload(
"traffic" if self.settings.traffic_sale_mode else "subscription" session,
) payment,
payload,
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", log_prefix="Wata webhook",
) ):
)
if outcome is None:
return web.Response(status=500, text="processing_error") return web.Response(status=500, text="processing_error")
return web.Response(text="ok") return web.Response(text="ok")
if status == "declined": if status == "declined":
try: if not await self._mark_declined_from_payload(
await payment_dal.update_provider_payment_and_status(
session, session,
payment.payment_id, payment,
resolved_transaction_id, payload,
"failed", log_prefix="Wata webhook",
) notify_user=True,
await session.commit() ):
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to mark payment %s as failed.",
resolved_transaction_id,
)
return web.Response(status=500, text="processing_error") 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") return web.Response(text="ok")
logging.warning( logging.warning(
@@ -409,7 +820,7 @@ class WataService(HttpClientMixin):
status, status,
transaction_id, transaction_id,
) )
return web.Response(status=202, text="status_ignored") return web.Response(text="status_ignored")
@router.callback_query(F.data.startswith("pay_wata:")) @router.callback_query(F.data.startswith("pay_wata:"))
@@ -428,6 +839,14 @@ async def pay_wata_callback_handler(
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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: if not wata_service or not wata_service.configured:
logging.error("Wata service is not configured or unavailable.") logging.error("Wata service is not configured or unavailable.")
await notify_service_unavailable(callback, translator) await notify_service_unavailable(callback, translator)
@@ -438,9 +857,52 @@ async def pay_wata_callback_handler(
logging.error("Invalid pay_wata data in callback: %s", callback.data) logging.error("Invalid pay_wata data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator) await notify_callback_parse_error(callback, translator)
return 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" currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts) 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( record_payload = build_payment_record_payload(
user_id=callback.from_user.id, user_id=callback.from_user.id,
amount=parts.price, amount=parts.price,
@@ -450,6 +912,7 @@ async def pay_wata_callback_handler(
months=parts.months, months=parts.months,
provider="wata", provider="wata",
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
@@ -463,6 +926,8 @@ async def pay_wata_callback_handler(
await notify_payment_record_failure(callback, translator) await notify_payment_record_failure(callback, translator)
return return
await safe_callback_answer(callback)
success, response_data = await wata_service.create_payment_link( success, response_data = await wata_service.create_payment_link(
payment_db_id=payment_record.payment_id, payment_db_id=payment_record.payment_id,
amount=parts.price, amount=parts.price,
@@ -478,8 +943,8 @@ async def pay_wata_callback_handler(
session=session, session=session,
payment=payment_record, payment=payment_record,
api_success=success, api_success=success,
payment_url=first_value(response_data, "url"), payment_url=first_value(response_data, "url", "paymentUrl", "payment_url"),
provider_payment_id=first_value(response_data, "id"), provider_payment_id=first_value(response_data, "id", "paymentLinkId"),
log_prefix=_LOG, log_prefix=_LOG,
) )
@@ -491,6 +956,45 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return payment_unavailable() return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" 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: try:
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
@@ -514,8 +1018,10 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
session=ctx.session, session=ctx.session,
payment=payment, payment=payment,
api_success=success, api_success=success,
payment_url=first_value(response_data, "url") if success else None, payment_url=first_value(response_data, "url", "paymentUrl", "payment_url")
provider_payment_id=first_value(response_data, "id"), if success
else None,
provider_payment_id=first_value(response_data, "id", "paymentLinkId"),
log_prefix="Wata", log_prefix="Wata",
) )
@@ -623,14 +1129,17 @@ _CONFIG_MANIFEST = (
"WATA_FAILED_URL", "url", "Failed URL", subsection="Wata", attr="FAILED_URL" "WATA_FAILED_URL", "url", "Failed URL", subsection="Wata", attr="FAILED_URL"
), ),
ProviderManifestField( ProviderManifestField(
"WATA_PAYMENT_LINK_TTL_DAYS", "WATA_LINK_TTL_MINUTES",
"int", "int",
"Payment link lifetime (days)", "Payment link lifetime (minutes)",
description="1..30; Wata defaults to 3 days and allows up to 30 days.", description=(
"15..43200; default 15 minutes. Wata requires more than 10 minutes "
"and allows up to 30 days."
),
subsection="Wata", subsection="Wata",
min=1, min=_WATA_LINK_MIN_TTL_MINUTES,
max=30, max=_WATA_LINK_MAX_TTL_MINUTES,
attr="PAYMENT_LINK_TTL_DAYS", attr="LINK_TTL_MINUTES",
), ),
ProviderManifestField( ProviderManifestField(
"WATA_WEBHOOK_VERIFY_SIGNATURE", "WATA_WEBHOOK_VERIFY_SIGNATURE",
+192 -24
View File
@@ -36,6 +36,7 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links 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 bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal from db.dal import payment_dal, user_billing_dal, user_dal
@@ -48,9 +49,12 @@ from .base import (
ServiceFactoryContext, ServiceFactoryContext,
WebAppPaymentContext, WebAppPaymentContext,
provider_env_file, provider_env_file,
provider_runtime_enabled,
) )
from .shared import ( from .shared import (
PaymentCallbackParts,
SuccessMessage, SuccessMessage,
append_hwid_renewal_note,
build_success_message, build_success_message,
create_webapp_payment_record, create_webapp_payment_record,
format_human_units, format_human_units,
@@ -63,6 +67,7 @@ from .shared import (
payment_link_response, payment_link_response,
payment_record_amounts, payment_record_amounts,
payment_unavailable, payment_unavailable,
quote_hwid_callback_parts,
resolve_inviter_name, resolve_inviter_name,
send_success_message_to_user, send_success_message_to_user,
) )
@@ -150,7 +155,7 @@ class YooKassaService:
) )
if not self.configured: if not self.configured:
if not self.config.ENABLED: if not provider_runtime_enabled(self.config):
logging.warning( logging.warning(
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501 "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501
) )
@@ -163,7 +168,11 @@ class YooKassaService:
@property @property
def configured(self) -> bool: 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 return False
self._ensure_sdk_configured() self._ensure_sdk_configured()
return self._sdk_configured_for is not None return self._sdk_configured_for is not None
@@ -254,6 +263,9 @@ class YooKassaService:
if save_payment_method: if save_payment_method:
# Ask YooKassa to save method for off-session charges # Ask YooKassa to save method for off-session charges
builder.set_save_payment_method(True) 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: if payment_method_id:
# Use a previously saved payment method for merchant-initiated payments # Use a previously saved payment method for merchant-initiated payments
builder.set_payment_method_id(payment_method_id) builder.set_payment_method_id(payment_method_id)
@@ -410,6 +422,49 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"77.75.154.128/25", "77.75.154.128/25",
"2a02:5180::/32", "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 = (
int(float(hwid_devices_raw))
if _metadata_value_present(hwid_devices_raw)
else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0)
)
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( async def process_successful_payment(
@@ -427,6 +482,7 @@ async def process_successful_payment(
user_id_str = metadata.get("user_id") user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months") subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb") traffic_gb_str = metadata.get("traffic_gb")
hwid_devices_str = metadata.get("hwid_devices")
sale_mode = metadata.get("sale_mode") or ( sale_mode = metadata.get("sale_mode") or (
"traffic" if settings.traffic_sale_mode else "subscription" "traffic" if settings.traffic_sale_mode else "subscription"
) )
@@ -439,7 +495,11 @@ async def process_successful_payment(
# we will create/ensure a payment record idempotently using provider payment id. # we will create/ensure a payment record idempotently using provider payment id.
if ( if (
not user_id_str 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) or (not payment_db_id_str and not auto_renew_subscription_id_str)
): ):
logging.error( logging.error(
@@ -450,8 +510,18 @@ async def process_successful_payment(
db_user = None db_user = None
try: try:
user_id = int(user_id_str) 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 = ( payment_db_id = (
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
) )
@@ -469,6 +539,21 @@ async def process_successful_payment(
payment_value = float(amount_data.get("value", 0.0)) payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id") 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 payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists # 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: if payment_db_id is None and auto_renew_subscription_id_str:
@@ -613,9 +698,6 @@ async def process_successful_payment(
logging.exception("Failed to persist multi-card YooKassa method from webhook") logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception: except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook") 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( activation_details = await subscription_service.activate_subscription(
session, session,
user_id, user_id,
@@ -625,12 +707,12 @@ async def process_successful_payment(
promo_code_id_from_payment=promo_code_id, promo_code_id_from_payment=promo_code_id,
provider="yookassa", provider="yookassa",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_amount_gb traffic_gb=traffic_gb_for_activation,
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
) )
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( logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501 f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
) )
@@ -648,7 +730,7 @@ async def process_successful_payment(
) )
raise Exception(f"DB Error: Could not update payment record {payment_db_id}") 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 final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0) applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
@@ -683,6 +765,11 @@ async def process_successful_payment(
if not receipt_item_name: if not receipt_item_name:
if is_traffic_sale_base(sale_mode_base): if is_traffic_sale_base(sale_mode_base):
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label) 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: else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format( receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
months=int(subscription_months) months=int(subscription_months)
@@ -702,7 +789,6 @@ async def process_successful_payment(
config_link_display, connect_button_url = await prepare_config_links( config_link_display, connect_button_url = await prepare_config_links(
settings, activation_details.get("subscription_url") if activation_details else None 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 # Auto-renew charges show a concise message and skip the connect keyboard, so
# they bypass the shared success-message builder. # they bypass the shared success-message builder.
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user: if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
@@ -712,7 +798,11 @@ async def process_successful_payment(
end_date=final_end_date_for_user.strftime("%Y-%m-%d"), end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
) )
include_keyboard = False 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( logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501 f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
) )
@@ -729,11 +819,14 @@ async def process_successful_payment(
months=( months=(
traffic_label traffic_label
if is_traffic_sale_base(sale_mode_base) if is_traffic_sale_base(sale_mode_base)
else (
hwid_devices_count
if _is_hwid_device_sale_base(sale_mode_base)
else int(subscription_months) else int(subscription_months)
)
), ),
base_end_date=base_subscription_end_date, base_end_date=base_subscription_end_date,
final_end_date=final_end_date_for_user, 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_referee_bonus_days=applied_referee_bonus_days_from_referral or 0,
applied_promo_bonus_days=applied_promo_bonus_days, applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name, inviter_name=inviter_name,
@@ -741,6 +834,19 @@ async def process_successful_payment(
) )
) )
include_keyboard = True 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( await send_success_message_to_user(
bot=bot, bot=bot,
user_id=user_id, user_id=user_id,
@@ -750,6 +856,7 @@ async def process_successful_payment(
settings=settings, settings=settings,
config_link_display=config_link_display, config_link_display=config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=include_keyboard, include_keyboard=include_keyboard,
log_prefix="YooKassa webhook", log_prefix="YooKassa webhook",
) )
@@ -773,6 +880,7 @@ async def process_successful_payment(
), ),
payment_provider="yookassa", payment_provider="yookassa",
username=user_for_notify.username if user_for_notify else None, 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", traffic_is_premium=sale_mode_base == "premium_topup",
tariff_key=tariff_for_log, tariff_key=tariff_for_log,
log_prefix="YooKassa webhook", log_prefix="YooKassa webhook",
@@ -1191,6 +1299,7 @@ async def _initiate_yk_payment(
payment_method_id: Optional[str] = None, payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None, selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription", sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> bool: ) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card).""" """Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message: if not callback.message:
@@ -1202,7 +1311,7 @@ async def _initiate_yk_payment(
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else ( else (
get_text("payment_description_hwid_devices", count=int(months)) 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)) else get_text("payment_description_subscription", months=int(months))
) )
) )
@@ -1219,8 +1328,15 @@ async def _initiate_yk_payment(
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None, else None,
"purchased_hwid_devices": int(months) "purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"} if sale_base in HWID_DEVICE_SALE_BASES
else None, 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 db_payment_record = None
@@ -1257,6 +1373,8 @@ async def _initiate_yk_payment(
} }
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}: if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
yookassa_metadata["traffic_gb"] = str(months) yookassa_metadata["traffic_gb"] = str(months)
if sale_base in HWID_DEVICE_SALE_BASES:
yookassa_metadata["hwid_devices"] = str(months)
if payment_method_id: if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
@@ -1460,6 +1578,29 @@ async def _initiate_yk_payment(
return False 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:")) @router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler( async def pay_yk_callback_handler(
callback: types.CallbackQuery, callback: types.CallbackQuery,
@@ -1479,6 +1620,9 @@ async def pay_yk_callback_handler(
pass pass
return return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
if not yookassa_service or not yookassa_service.configured: if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.") logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message target_msg_edit = callback.message
@@ -1509,6 +1653,23 @@ async def pay_yk_callback_handler(
return return
months, price_rub, sale_mode = parsed 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 user_id = callback.from_user.id
currency_code_for_yk = "RUB" currency_code_for_yk = "RUB"
autopay_enabled = bool( autopay_enabled = bool(
@@ -1585,6 +1746,7 @@ async def pay_yk_callback_handler(
save_payment_method=autopay_enabled and autopay_require_binding, save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub), back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
sale_mode=sale_mode, sale_mode=sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
await callback.answer() await callback.answer()
@@ -1611,6 +1773,9 @@ async def pay_yk_new_card_handler(
pass pass
return return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
if not yookassa_service or not yookassa_service.configured: if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.") logging.error("YooKassa service unavailable for pay_yk_new.")
try: try:
@@ -1695,6 +1860,9 @@ async def pay_yk_saved_list_handler(
pass pass
return return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
try: try:
_, data_payload = callback.data.split(":", 1) _, data_payload = callback.data.split(":", 1)
except ValueError: except ValueError:
@@ -1855,6 +2023,9 @@ async def pay_yk_use_saved_handler(
pass pass
return return
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
return
if not yookassa_service or not yookassa_service.configured: if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.") logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try: try:
@@ -2557,10 +2728,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description, description=ctx.description,
metadata=metadata, metadata=metadata,
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL, receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
save_payment_method=bool( save_payment_method=False,
service.config.autopayments_active
and service.config.AUTOPAYMENTS_REQUIRE_CARD_BINDING
),
) )
payment_url = response.get("confirmation_url") if response else None payment_url = response.get("confirmation_url") if response else None
if not payment_url: if not payment_url:
@@ -2700,8 +2868,8 @@ SPEC = PaymentProviderSpec(
id="yookassa", id="yookassa",
provider_key="yookassa", provider_key="yookassa",
label="YooKassa", label="YooKassa",
webapp_label="Банковская карта", webapp_label="ЮKassa",
webapp_labels={"ru": "Банковская карта", "en": "Bank card"}, webapp_labels={"ru": "ЮKassa", "en": "YooKassa"},
webapp_icon="CreditCard", webapp_icon="CreditCard",
telegram_labels={"ru": "ЮKassa", "en": "YooKassa"}, telegram_labels={"ru": "ЮKassa", "en": "YooKassa"},
telegram_emoji="💳", telegram_emoji="💳",
+4 -1
View File
@@ -15,6 +15,7 @@ from typing import Optional
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_templates import EmailContent, render_login_code from bot.services.email_templates import EmailContent, render_login_code
from config.settings import Settings from config.settings import Settings
from db.dal import security_dal from db.dal import security_dal
@@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona
class EmailAuthService: class EmailAuthService:
def __init__(self, settings: Settings): def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None):
self.settings = settings self.settings = settings
self.i18n = i18n
def _smtp_attempts(self) -> list[SmtpAttempt]: def _smtp_attempts(self) -> list[SmtpAttempt]:
attempts: list[SmtpAttempt] = [] attempts: list[SmtpAttempt] = []
@@ -465,6 +467,7 @@ class EmailAuthService:
language_code=language_code, language_code=language_code,
magic_link=magic_link, magic_link=magic_link,
purpose=purpose, purpose=purpose,
i18n=self.i18n,
) )
message = EmailMessage() message = EmailMessage()
+5 -2
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from typing import Optional, Sequence, Tuple from typing import Optional, Sequence, Tuple
from urllib.parse import urlsplit 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 from config.settings import Settings
_BG = "#05070a" _BG = "#05070a"
@@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str:
def _normalize_lang(language_code: Optional[str], 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: def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
@@ -0,0 +1,721 @@
"""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_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_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
+81 -6
View File
@@ -56,11 +56,16 @@ class NotificationService:
user_id: int, user_id: int,
username: Optional[str] = None, username: Optional[str] = None,
first_name: Optional[str] = None, first_name: Optional[str] = None,
email: Optional[str] = None,
) -> str: ) -> str:
base_display = display_name_or_fallback(first_name, f"ID {user_id}") base_display = display_name_or_fallback(first_name, f"ID {user_id}")
if username: if username:
base_display = f"{base_display} ({username_for_display(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 @staticmethod
def _build_profile_keyboard( def _build_profile_keyboard(
@@ -254,8 +259,13 @@ class NotificationService:
part for part in [user.first_name, getattr(user, "last_name", None)] if part part for part in [user.first_name, getattr(user, "last_name", None)] if part
) )
if user.username: if user.username:
return f"{name or user.username} (@{user.username})" display = f"{name or user.username} (@{user.username})"
return name or getattr(user, "email", None) or f"ID {user.user_id}" 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 @staticmethod
def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]: def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]:
@@ -540,6 +550,7 @@ class NotificationService:
user_id: int, user_id: int,
username: Optional[str] = None, username: Optional[str] = None,
first_name: Optional[str] = None, first_name: Optional[str] = None,
email: Optional[str] = None,
referred_by_id: Optional[int] = None, referred_by_id: Optional[int] = None,
): ):
"""Send notification about new user registration""" """Send notification about new user registration"""
@@ -553,6 +564,7 @@ class NotificationService:
user_id=user_id, user_id=user_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
email=email,
) )
referral_text = "" referral_text = ""
@@ -640,6 +652,7 @@ class NotificationService:
user_id=telegram_id or user_id, user_id=telegram_id or user_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
email=email,
) )
message = _( message = _(
@@ -676,6 +689,7 @@ class NotificationService:
user_id=telegram_id, user_id=telegram_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
email=email,
) )
message = _( message = _(
@@ -690,6 +704,52 @@ class NotificationService:
profile_keyboard = self._build_profile_keyboard(_, telegram_id) profile_keyboard = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard) await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_account_merged(
self,
*,
primary_user_id: int,
removed_user_id: int,
email: Optional[str],
telegram_id: Optional[int],
username: Optional[str] = None,
first_name: Optional[str] = None,
final_end_date_text: Optional[str] = None,
primary_panel_user_uuid: Optional[str] = None,
removed_panel_user_uuid: Optional[str] = None,
):
"""Send notification when duplicate email/Telegram accounts are merged."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
display_user_id = int(telegram_id or primary_user_id)
user_display = self._format_user_display(
user_id=display_user_id,
username=username,
first_name=first_name,
email=email,
)
message = _(
"log_account_merged",
primary_user_id=primary_user_id,
removed_user_id=removed_user_id,
telegram_id=telegram_id or "",
user_display=user_display,
email=hd.quote(email or ""),
final_end_date=hd.quote(final_end_date_text or ""),
primary_panel_user_uuid=hd.quote(primary_panel_user_uuid or ""),
removed_panel_user_uuid=hd.quote(removed_panel_user_uuid or ""),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
profile_keyboard = (
self._build_profile_keyboard(_, int(telegram_id)) if telegram_id else None
)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
def _format_traffic_gb_admin(self, traffic_gb: float) -> str: def _format_traffic_gb_admin(self, traffic_gb: float) -> str:
value = float(traffic_gb) value = float(traffic_gb)
if value.is_integer(): if value.is_integer():
@@ -716,6 +776,7 @@ class NotificationService:
months: int, months: int,
payment_provider: str, payment_provider: str,
username: Optional[str] = None, username: Optional[str] = None,
email: Optional[str] = None,
traffic_gb: Optional[float] = None, traffic_gb: Optional[float] = None,
*, *,
traffic_is_premium: bool = False, traffic_is_premium: bool = False,
@@ -731,6 +792,7 @@ class NotificationService:
user_display = self._format_user_display( user_display = self._format_user_display(
user_id=user_id, user_id=user_id,
username=username, username=username,
email=email,
) )
try: try:
@@ -782,7 +844,12 @@ class NotificationService:
await self._send_to_log_channel(message, reply_markup=profile_keyboard) await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_promo_activation( 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""" """Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS: if not self.settings.LOG_PROMO_ACTIVATIONS:
@@ -794,6 +861,7 @@ class NotificationService:
user_display = self._format_user_display( user_display = self._format_user_display(
user_id=user_id, user_id=user_id,
username=username, username=username,
email=email,
) )
message = _( message = _(
@@ -809,7 +877,11 @@ class NotificationService:
await self._send_to_log_channel(message, reply_markup=profile_keyboard) await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_trial_activation( 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""" """Send notification about trial activation"""
if not self.settings.LOG_TRIAL_ACTIVATIONS: if not self.settings.LOG_TRIAL_ACTIVATIONS:
@@ -821,6 +893,7 @@ class NotificationService:
user_display = self._format_user_display( user_display = self._format_user_display(
user_id=user_id, user_id=user_id,
username=username, username=username,
email=email,
) )
message = _( message = _(
@@ -873,6 +946,7 @@ class NotificationService:
suspicious_input: str, suspicious_input: str,
username: Optional[str] = None, username: Optional[str] = None,
first_name: Optional[str] = None, first_name: Optional[str] = None,
email: Optional[str] = None,
): ):
"""Send notification about a suspicious promo code attempt.""" """Send notification about a suspicious promo code attempt."""
if not self.settings.LOG_SUSPICIOUS_ACTIVITY: if not self.settings.LOG_SUSPICIOUS_ACTIVITY:
@@ -885,11 +959,12 @@ class NotificationService:
user_id=user_id, user_id=user_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
email=email,
) )
message = _( message = _(
"log_suspicious_promo", "log_suspicious_promo",
user_display=hd.quote(user_display), user_display=user_display,
user_id=user_id, user_id=user_id,
suspicious_input=hd.quote(suspicious_input), suspicious_input=hd.quote(suspicious_input),
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"), 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 f"{base_sub_url}/{client_type.lower()}"
return base_sub_url 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]]]: async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
if self._devices_cache.ttl_seconds <= 0: if self._devices_cache.ttl_seconds <= 0:
return await self._get_user_devices_uncached(user_uuid) return await self._get_user_devices_uncached(user_uuid)
+40 -3
View File
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
) )
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from config.settings import Settings 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_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring from .email_templates import render_subscription_expiring
@@ -63,13 +63,46 @@ class PanelWebhookService:
**kwargs, **kwargs,
): ):
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
extra_text = str(kwargs.pop("extra_text", "") or "").strip()
try: try:
text = _(message_key, **kwargs)
if extra_text:
text = f"{text}\n\n{extra_text}"
await self.bot.send_message( await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup user_id, text, reply_markup=reply_markup
) )
except Exception: except Exception:
logging.exception("Failed to send notification to %s", user_id) 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): async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId") telegram_id = user_payload.get("telegramId")
if not telegram_id: if not telegram_id:
@@ -97,6 +130,7 @@ class PanelWebhookService:
if event_name in EVENT_MAP: if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name] days_left, msg_key = EVENT_MAP[event_name]
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1: if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory) # Trigger auto-renew via SubscriptionService (wired in at factory)
try: try:
@@ -148,6 +182,7 @@ class PanelWebhookService:
"autorenew_48h_charge_tomorrow_notice", "autorenew_48h_charge_tomorrow_notice",
reply_markup=cancel_kb, reply_markup=cancel_kb,
user_name=first_name, user_name=first_name,
extra_text=hwid_renewal_note,
) )
return return
await self._send_message( await self._send_message(
@@ -157,6 +192,7 @@ class PanelWebhookService:
reply_markup=markup, reply_markup=markup,
user_name=first_name, user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10], end_date=user_payload.get("expireAt", "")[:10],
extra_text=hwid_renewal_note,
) )
if days_left == 3 and user_email: if days_left == 3 and user_email:
await self._send_subscription_expiring_email( await self._send_subscription_expiring_email(
@@ -206,8 +242,9 @@ class PanelWebhookService:
days_left=days_left, days_left=days_left,
end_date_text=end_date_text, end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None, 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) await email_service.send_rendered_email(email=recipient, content=content)
except Exception: except Exception:
logging.exception("Failed to send subscription-expiring email to %s", recipient) logging.exception("Failed to send subscription-expiring email to %s", recipient)
@@ -112,6 +112,7 @@ class PromoCodeService:
promo_code=code_input_upper, promo_code=code_input_upper,
bonus_days=bonus_days, bonus_days=bonus_days,
username=user.username if user else None, username=user.username if user else None,
email=getattr(user, "email", None) if user else None,
) )
except Exception as e: except Exception as e:
logging.error(f"Failed to send promo activation notification: {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 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]: def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
snapshot: Dict[str, Any] = {} snapshot: Dict[str, Any] = {}
logo_url = getattr(settings, "WEBAPP_LOGO_URL", None) logo_url = getattr(settings, "WEBAPP_LOGO_URL", None)
@@ -270,6 +297,11 @@ async def update_overrides(
if errors: if errors:
return {"ok": False, "errors": 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 async_session_factory() as session: # type: AsyncSession
async with session.begin(): async with session.begin():
for key, value in coerced_updates.items(): for key, value in coerced_updates.items():
@@ -25,5 +25,6 @@ from db.models import Subscription, User
from bot.services.email_auth_service import EmailAuthService from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import render_payment_success from bot.services.email_templates import render_payment_success
from bot.services.panel_api_service import PanelApiService 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("__")] __all__ = [name for name in globals() if not name.startswith("__")]
@@ -3,6 +3,190 @@ from ._runtime import * # noqa: F403,F405
class HwidDeviceMixin: 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
if not period_start or period_start >= period_end:
period_start = valid_from
period_end = valid_until
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( async def activate_hwid_device_topup(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -12,6 +196,7 @@ class HwidDeviceMixin:
payment_db_id: int, payment_db_id: int,
provider: str = "yookassa", provider: str = "yookassa",
tariff_key: Optional[str] = None, tariff_key: Optional[str] = None,
renewal: bool = False,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
try: try:
purchased_devices = int(device_count) purchased_devices = int(device_count)
@@ -33,6 +218,14 @@ class HwidDeviceMixin:
tariff = None tariff = None
if self._tariffs_config(): if self._tariffs_config():
tariff = self._resolve_tariff(tariff_key or sub.tariff_key) 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 = ( packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars] [*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
if tariff.hwid_device_packages if tariff.hwid_device_packages
@@ -57,19 +250,69 @@ class HwidDeviceMixin:
) )
return { return {
"subscription_id": sub.subscription_id, "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, "hwid_device_limit": 0,
"extra_hwid_devices": int(sub.extra_hwid_devices or 0), "extra_hwid_devices": int(sub.extra_hwid_devices or 0),
"purchased_hwid_devices": 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) effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices)
await self._record_payment_context( await self._record_payment_context(
session, session,
payment_db_id, 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, tariff_key=tariff.key if tariff else sub.tariff_key,
purchased_hwid_devices=purchased_devices, 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( updated_sub = await subscription_dal.update_subscription(
session, session,
@@ -102,16 +345,30 @@ class HwidDeviceMixin:
) )
return None 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( await tariff_dal.create_hwid_device_purchase(
session, session,
subscription_id=updated_sub.subscription_id, subscription_id=updated_sub.subscription_id,
payment_id=payment_db_id, payment_id=payment_db_id,
purchased_devices=purchased_devices, purchased_devices=purchased_devices,
valid_from=valid_from,
valid_until=valid_until,
) )
return { return {
"subscription_id": updated_sub.subscription_id, "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, "hwid_device_limit": effective_hwid_limit,
"extra_hwid_devices": new_extra_devices, "extra_hwid_devices": new_extra_devices,
"purchased_hwid_devices": purchased_devices, "purchased_hwid_devices": purchased_devices,
"tariff_key": tariff.key if tariff else sub.tariff_key, "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: if not sub:
return None return None
before_tariff_key = sub.tariff_key before_tariff_key = sub.tariff_key
options = self.calculate_tariff_switch_options(sub, target)
now = datetime.now(timezone.utc) 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_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
premium_baseline = target.premium_monthly_bytes premium_baseline = target.premium_monthly_bytes
@@ -44,8 +51,20 @@ class SubscriptionLifecycleMixin:
} }
converted_bytes = None converted_bytes = None
base_hwid_limit = self._base_hwid_limit_for_tariff(target) base_hwid_limit = self._base_hwid_limit_for_tariff(target)
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) extra_hwid_devices = int(sub.extra_hwid_devices or 0)
update_data["hwid_device_limit"] = base_hwid_limit update_data["hwid_device_limit"] = base_hwid_limit
update_data["extra_hwid_devices"] = extra_hwid_devices
if target.billing_model == "period": if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes update_data["tier_baseline_bytes"] = target.monthly_bytes
@@ -154,6 +173,8 @@ class SubscriptionLifecycleMixin:
if updated.end_date and target.billing_model == "period" if updated.end_date and target.billing_model == "period"
else None, else None,
"converted_bytes": converted_bytes, "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_before": sub.effective_monthly_price_rub,
"eff_price_after": updated.effective_monthly_price_rub, "eff_price_after": updated.effective_monthly_price_rub,
}, },
@@ -236,7 +257,7 @@ class SubscriptionLifecycleMixin:
payment_db_id=payment_db_id, payment_db_id=payment_db_id,
provider=provider, 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) target_devices = int(traffic_gb if traffic_gb is not None else months)
return await self.activate_hwid_device_topup( return await self.activate_hwid_device_topup(
session=session, session=session,
@@ -246,6 +267,7 @@ class SubscriptionLifecycleMixin:
payment_db_id=payment_db_id, payment_db_id=payment_db_id,
provider=provider, provider=provider,
tariff_key=tariff_key, tariff_key=tariff_key,
renewal=sale_mode_base == "hwid_devices_renewal",
) )
if sale_mode_base == "tariff_upgrade": if sale_mode_base == "tariff_upgrade":
if not tariff_key: if not tariff_key:
@@ -378,7 +400,25 @@ class SubscriptionLifecycleMixin:
) )
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0) 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( premium_topup_balance_bytes = int(
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0 getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
) )
@@ -497,6 +537,8 @@ class SubscriptionLifecycleMixin:
"subscription_url": final_subscription_url, "subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days, "applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None, "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( async def extend_active_subscription_days(
@@ -760,9 +802,50 @@ class SubscriptionLifecycleMixin:
if local_active_sub if local_active_sub
else False 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 { return {
"user_id": panel_user_data.get("uuid"), "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, "end_date": panel_end_date,
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link, "config_link": display_link,
@@ -816,9 +899,11 @@ class SubscriptionLifecycleMixin:
"base_hwid_device_limit": local_active_sub.hwid_device_limit "base_hwid_device_limit": local_active_sub.hwid_device_limit
if local_active_sub if local_active_sub
else None, else None,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0) "extra_hwid_devices": active_extra_hwid_devices,
if local_active_sub "extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
else 0, "extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get(
"next_valid_from"
),
"user_bot_username": db_user.username, "user_bot_username": db_user.username,
"is_panel_data": True, "is_panel_data": True,
"max_devices": hwid_limit, "max_devices": hwid_limit,
@@ -57,18 +57,14 @@ class PanelIdentityMixin:
return f"em_{referral_code}" return f"em_{referral_code}"
def _panel_description_for_user(self, db_user: User) -> str: def _panel_description_for_user(self, db_user: User) -> str:
lines = [ return panel_description_from_profile(
db_user.email or "", db_user.username,
db_user.username or "", db_user.first_name,
db_user.first_name or "", db_user.last_name,
db_user.last_name or "", )
]
return "\n".join(line for line in lines if line).strip()
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]: def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
payload: Dict[str, Any] = { payload: Dict[str, Any] = {}
"description": self._panel_description_for_user(db_user),
}
telegram_id = self._telegram_id_for_panel(db_user) telegram_id = self._telegram_id_for_panel(db_user)
if telegram_id: if telegram_id:
payload["telegramId"] = telegram_id payload["telegramId"] = telegram_id
@@ -26,6 +26,11 @@ class PaymentContextMixin:
tariff_key: Optional[str], tariff_key: Optional[str],
purchased_gb: Optional[float] = None, purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = 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: ) -> None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment: if not payment:
@@ -34,6 +39,16 @@ class PaymentContextMixin:
payment.tariff_key = tariff_key payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb payment.purchased_gb = purchased_gb
payment.purchased_hwid_devices = purchased_hwid_devices 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() await session.flush()
async def get_user_language(self, session: AsyncSession, user_id: int) -> str: async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
@@ -95,6 +110,7 @@ class PaymentContextMixin:
except Exception: except Exception:
provider_label = self._PROVIDER_LABELS.get((provider or "").lower()) provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
i18n = getattr(self, "i18n", None)
try: try:
content = render_payment_success( content = render_payment_success(
@@ -108,8 +124,9 @@ class PaymentContextMixin:
end_date_text=end_date_text, end_date_text=end_date_text,
dashboard_url=dashboard_url, dashboard_url=dashboard_url,
provider_label=provider_label, 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) await email_service.send_rendered_email(email=recipient, content=content)
except Exception: except Exception:
logging.exception("Failed to send payment success email to user %s", db_user.user_id) logging.exception("Failed to send payment success email to user %s", db_user.user_id)
@@ -370,3 +370,91 @@ class TariffMixin:
} }
return {"mode": "traffic_to_period", "remaining_days": remaining_days} 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,11 @@ class TrafficMixin:
current_used = active_sub.traffic_used_bytes current_used = active_sub.traffic_used_bytes
purchase_bytes = self.gb_to_bytes(traffic_gb) 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) base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) 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)) remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0))
@@ -222,10 +226,8 @@ class TrafficMixin:
if sub.hwid_device_limit is not None if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff) else self._base_hwid_limit_for_tariff(tariff)
) )
effective_hwid_limit = self._effective_hwid_limit( extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
base_hwid_limit, effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
int(sub.extra_hwid_devices or 0),
)
updated_sub = await subscription_dal.update_subscription( updated_sub = await subscription_dal.update_subscription(
session, session,
sub.subscription_id, sub.subscription_id,
@@ -235,6 +237,7 @@ class TrafficMixin:
"is_throttled": False, "is_throttled": False,
"tariff_key": tariff.key, "tariff_key": tariff.key,
"hwid_device_limit": base_hwid_limit, "hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
}, },
) )
panel_payload = self._build_panel_update_payload( panel_payload = self._build_panel_update_payload(
@@ -349,24 +352,23 @@ class TrafficMixin:
}, },
) )
panel_payload = { desired_squads = self._panel_squads_for_tariff(
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff, tariff,
include_premium=not premium_is_limited, include_premium=not premium_is_limited,
),
}
updated_panel = await self.panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, panel_payload
) )
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 # Otherwise the user pays for premium top-up but the panel never
# re-grants premium squad access (the most common case here is # re-grants premium squad access (the most common case here is
# transitioning from premium_is_limited=True back to False). # transitioning from premium_is_limited=True back to False).
logging.warning( 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, user_id,
updated_panel,
) )
return None return None
await tariff_dal.create_traffic_topup( await tariff_dal.create_traffic_topup(
@@ -432,10 +434,16 @@ class TrafficMixin:
squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited) squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited)
try: try:
await self.panel_service.update_user_details_on_panel( panel_updated = await self._sync_panel_squads_if_needed(
db_user.panel_user_uuid, db_user.panel_user_uuid,
{"uuid": db_user.panel_user_uuid, "activeInternalSquads": squads}, squads,
log_response=False, 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: except Exception:
logging.exception( logging.exception(
@@ -494,10 +502,8 @@ class TrafficMixin:
if sub.hwid_device_limit is not None if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff) else self._base_hwid_limit_for_tariff(tariff)
) )
effective_hwid_limit = self._effective_hwid_limit( extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
base_hwid_limit, effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
int(sub.extra_hwid_devices or 0),
)
updated_sub = await subscription_dal.update_subscription( updated_sub = await subscription_dal.update_subscription(
session, session,
sub.subscription_id, sub.subscription_id,
@@ -506,6 +512,7 @@ class TrafficMixin:
"traffic_limit_bytes": new_limit, "traffic_limit_bytes": new_limit,
"is_throttled": False, "is_throttled": False,
"hwid_device_limit": base_hwid_limit, "hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
}, },
) )
panel_payload = self._build_panel_update_payload( panel_payload = self._build_panel_update_payload(
@@ -575,10 +582,9 @@ class TrafficMixin:
if sub.hwid_device_limit is not None if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff) else self._base_hwid_limit_for_tariff(tariff)
) )
effective_hwid_limit = self._effective_hwid_limit( extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
base_hwid_limit, sub.extra_hwid_devices = extra_hwid_devices
int(sub.extra_hwid_devices or 0), effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
)
panel_payload = self._build_panel_update_payload( panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid, panel_user_uuid=db_user.panel_user_uuid,
expire_at=sub.end_date, expire_at=sub.end_date,
@@ -679,16 +685,21 @@ class TrafficMixin:
"premium_period_start_at": premium_period_start, "premium_period_start_at": premium_period_start,
}, },
) )
panel_payload = { desired_squads = self._panel_squads_for_tariff(
"uuid": db_user.panel_user_uuid,
"activeInternalSquads": self._panel_squads_for_tariff(
tariff, tariff,
include_premium=not premium_is_limited, include_premium=not premium_is_limited,
), )
}
try: try:
await self.panel_service.update_user_details_on_panel( panel_updated = await self._sync_panel_squads_if_needed(
db_user.panel_user_uuid, panel_payload 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: except Exception:
logging.exception( logging.exception(
@@ -710,3 +721,128 @@ class TrafficMixin:
"premium_is_limited": premium_is_limited, "premium_is_limited": premium_is_limited,
"granted_bytes": purchase_bytes, "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
@@ -83,6 +83,14 @@ class TrialSubscriptionMixin:
status="ACTIVE", status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes, traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY, 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)) panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
+1 -1
View File
@@ -125,7 +125,7 @@ class SupportService:
self.settings = settings self.settings = settings
self.bot = bot self.bot = bot
self.i18n = i18n 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( self.notification_service = notification_service or NotificationService(
bot, bot,
settings, settings,
+390 -16
View File
@@ -2,7 +2,7 @@ import asyncio
import logging import logging
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Any, Optional
from aiogram import Bot from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo 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_BATCH_SIZE = 50
TARIFF_WORKER_PANEL_CONCURRENCY = 10 TARIFF_WORKER_PANEL_CONCURRENCY = 10
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50 TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
class TariffTrafficWorker: class TariffTrafficWorker:
@@ -51,6 +52,7 @@ class TariffTrafficWorker:
self._stopped = asyncio.Event() self._stopped = asyncio.Event()
self._premium_nodes_cache = {} self._premium_nodes_cache = {}
self._premium_node_usage_tick_cache = {} self._premium_node_usage_tick_cache = {}
self._premium_squad_match_cache = {}
async def _user_lang(self, session: AsyncSession, user_id: int) -> str: async def _user_lang(self, session: AsyncSession, user_id: int) -> str:
try: try:
@@ -148,6 +150,7 @@ class TariffTrafficWorker:
return return
panel_users_by_uuid = await self._prefetch_panel_users_by_uuid(subs) 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) semaphore = asyncio.Semaphore(TARIFF_WORKER_PANEL_CONCURRENCY)
async def _fetch_panel(sub: Subscription) -> dict: async def _fetch_panel(sub: Subscription) -> dict:
@@ -155,6 +158,13 @@ class TariffTrafficWorker:
cached_panel_user = panel_users_by_uuid.get(str(sub.panel_user_uuid)) cached_panel_user = panel_users_by_uuid.get(str(sub.panel_user_uuid))
if cached_panel_user is not None: if cached_panel_user is not None:
return cached_panel_user return cached_panel_user
return await self._repair_missing_panel_user_for_subscription(
session,
sub,
panel_users_by_uuid=panel_users_by_uuid,
semaphore=semaphore,
confirmed_missing=True,
)
async with semaphore: async with semaphore:
try: try:
@@ -167,12 +177,22 @@ class TariffTrafficWorker:
sub.panel_user_uuid, sub.panel_user_uuid,
) )
return {} return {}
return data or {} if data:
return data
return await self._repair_missing_panel_user_for_subscription(
session,
sub,
panel_users_by_uuid=None,
semaphore=semaphore,
confirmed_missing=False,
)
for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE): for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE):
chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE] chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE]
panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk)) panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk))
for sub, panel_data in zip(chunk, panel_payloads): for sub, panel_data in zip(chunk, panel_payloads):
if not panel_data:
continue
try: try:
tariff = self.settings.tariffs_config.require(sub.tariff_key) tariff = self.settings.tariffs_config.require(sub.tariff_key)
except Exception: except Exception:
@@ -195,6 +215,7 @@ class TariffTrafficWorker:
if tariff.billing_model == "period": if tariff.billing_model == "period":
await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) 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( await self._maybe_warn_or_throttle(
session, session,
sub, sub,
@@ -213,6 +234,7 @@ class TariffTrafficWorker:
now, now,
panel_username=panel_username, panel_username=panel_username,
panel_user_dict=panel_data, panel_user_dict=panel_data,
panel_view=panel_view,
) )
async def _prefetch_panel_users_by_uuid( async def _prefetch_panel_users_by_uuid(
@@ -255,6 +277,68 @@ class TariffTrafficWorker:
) )
return by_uuid return by_uuid
async def _repair_missing_panel_user_for_subscription(
self,
session: AsyncSession,
sub: Subscription,
*,
panel_users_by_uuid: Optional[dict[str, dict]],
semaphore: asyncio.Semaphore,
confirmed_missing: bool,
) -> dict:
current_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
try:
user_id = int(sub.user_id)
except (TypeError, ValueError):
user_id = 0
db_user = await user_dal.get_user_by_id(session, user_id) if user_id else None
canonical_uuid = str(getattr(db_user, "panel_user_uuid", "") or "").strip()
if canonical_uuid and canonical_uuid != current_uuid:
panel_user = None
if panel_users_by_uuid is not None:
panel_user = panel_users_by_uuid.get(canonical_uuid)
else:
async with semaphore:
try:
panel_user = await self.panel_service.get_user_by_uuid(
canonical_uuid,
log_response=False,
)
except Exception:
logging.exception(
"TariffTrafficWorker: failed to fetch canonical panel user %s",
canonical_uuid,
)
panel_user = None
if panel_user:
logging.warning(
"TariffTrafficWorker: repaired subscription %s panel UUID %s -> %s",
sub.subscription_id,
current_uuid,
canonical_uuid,
)
sub.panel_user_uuid = canonical_uuid
return panel_user
if confirmed_missing:
sub.is_active = False
sub.skip_notifications = True
sub.status_from_panel = "PANEL_USER_NOT_FOUND"
logging.warning(
"TariffTrafficWorker: deactivated subscription %s because panel user %s is missing",
sub.subscription_id,
current_uuid,
)
else:
logging.warning(
"TariffTrafficWorker: skipping subscription %s because panel user %s "
"could not be fetched",
sub.subscription_id,
current_uuid,
)
return {}
async def _ensure_period_reset_strategy( async def _ensure_period_reset_strategy(
self, self,
sub: Subscription, sub: Subscription,
@@ -294,6 +378,64 @@ class TariffTrafficWorker:
sub.panel_user_uuid, payload, log_response=False 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( async def _maybe_warn_or_throttle(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -382,6 +524,7 @@ class TariffTrafficWorker:
*, *,
panel_username: Optional[str] = None, panel_username: Optional[str] = None,
panel_user_dict: Optional[dict] = None, panel_user_dict: Optional[dict] = None,
panel_view: str = "unknown",
) -> None: ) -> None:
if not getattr(tariff, "premium_squad_uuids", None): if not getattr(tariff, "premium_squad_uuids", None):
if ( if (
@@ -402,12 +545,22 @@ class TariffTrafficWorker:
return return
premium_period_start = month_start(now) 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_baseline = int(tariff.premium_monthly_bytes or 0)
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = ( premium_topup_used = (
int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0 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. # Admin-side overrides for free gifted premium traffic.
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
@@ -452,22 +605,59 @@ class TariffTrafficWorker:
should_limit = False should_limit = False
else: else:
should_limit = premium_used >= premium_limit 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( desired_squads = self.subscription_service._panel_squads_for_tariff(
tariff, tariff,
include_premium=not should_limit, include_premium=not should_limit,
) )
desired_set = self._internal_squad_uuid_set(desired_squads) desired_set = self._internal_squad_uuid_set(desired_squads)
if isinstance(panel_user_dict, dict): squad_match_cache_key = self._premium_squad_match_cache_key(
current_known = False sub.panel_user_uuid,
current_raw = None desired_set,
for key in ("activeInternalSquads", "active_internal_squads"): )
if key in panel_user_dict: panel_needs_update = access_state_changed
current_raw = panel_user_dict.get(key) panel_user_for_report = panel_user_dict
current_known = True panel_view_for_report = panel_view
break panel_update_reasons: list[str] = []
if current_known and desired_set != self._internal_squad_uuid_set(current_raw): 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_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_baseline_bytes = premium_baseline
sub.premium_topup_balance_bytes = premium_topup_balance sub.premium_topup_balance_bytes = premium_topup_balance
sub.premium_topup_used_bytes = premium_topup_used sub.premium_topup_used_bytes = premium_topup_used
@@ -487,11 +677,21 @@ class TariffTrafficWorker:
return return
squads = desired_squads 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, sub.panel_user_uuid,
{"uuid": sub.panel_user_uuid, "activeInternalSquads": squads}, {"uuid": sub.panel_user_uuid, "activeInternalSquads": squads},
log_response=False, log_response=False,
) )
if updated_panel_user:
self._remember_premium_squad_match(squad_match_cache_key)
logging.info( logging.info(
"Premium squad access %s for user %s tariff %s: %s/%s bytes", "Premium squad access %s for user %s tariff %s: %s/%s bytes",
"limited" if should_limit else "restored", "limited" if should_limit else "restored",
@@ -501,20 +701,194 @@ class TariffTrafficWorker:
premium_limit, 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 @staticmethod
def _internal_squad_uuid_set(raw) -> set[str]: def _internal_squad_uuid_set(raw) -> set[str]:
if not isinstance(raw, list): if not isinstance(raw, (list, tuple, set)):
return set() return set()
out: set[str] = set() out: set[str] = set()
for item in raw: for item in raw:
if isinstance(item, dict): 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: if u:
out.add(str(u)) out.add(str(u))
elif item: elif item:
out.add(str(item)) out.add(str(item))
return out 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 @staticmethod
def _fmt_bytes(value: int) -> str: def _fmt_bytes(value: int) -> str:
size = float(max(0, int(value or 0))) 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 __future__ import annotations
from typing import Optional 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 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: 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 return None
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular" normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
return append_query_params(base, {"topup": normalized}) 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)}"
+37
View File
@@ -146,6 +146,43 @@ _NORMALIZED_BANNED_TOKENS = {
_USERNAME_PLACEHOLDER = "клиент" _USERNAME_PLACEHOLDER = "клиент"
def looks_like_broken_panel_text(value: Optional[str]) -> bool:
if value is None:
return False
text = unicodedata.normalize("NFKC", str(value)).strip()
if not text:
return False
if "\ufffd" in text:
return True
meaningful = [ch for ch in text if not ch.isspace()]
if len(meaningful) < 2:
return False
question_count = sum(1 for ch in meaningful if ch == "?")
if question_count < 2:
return False
has_content = any(
ch != "?" and not unicodedata.category(ch).startswith("P") for ch in meaningful
)
return not has_content and question_count / len(meaningful) >= 0.5
def panel_description_from_profile(
username: Optional[str],
first_name: Optional[str],
last_name: Optional[str],
) -> str:
lines = []
for value in (username, first_name, last_name):
line = (value or "").strip()
if line and not looks_like_broken_panel_text(line):
lines.append(line)
return "\n".join(lines).strip()
def _normalize_for_detection(value: str) -> str: def _normalize_for_detection(value: str) -> str:
if not value: if not value:
return "" return ""
File diff suppressed because one or more lines are too long
+53 -4
View File
@@ -164,6 +164,7 @@ class Settings(BaseSettings):
) )
STARS_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True)
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
PAYMENT_METHODS_ORDER: Optional[str] = Field( PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None, default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
@@ -273,6 +274,13 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = Field(default=3) TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET") TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
default=None,
description=(
"Comma-separated UUIDs of internal squads to assign during trial activation. "
"Falls back to USER_SQUAD_UUIDS when empty."
),
)
CRYPT4_ENABLED: bool = Field( CRYPT4_ENABLED: bool = Field(
default=False, description="Enable happ crypt4 encryption for subscription URLs" default=False, description="Enable happ crypt4 encryption for subscription URLs"
@@ -323,6 +331,35 @@ class Settings(BaseSettings):
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
SUBSCRIPTION_GUIDES_ENABLED: bool = Field(
default=True,
description="Show embedded install instructions inside the subscription Mini App.",
)
SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED: bool = Field(
default=True,
description=(
"Open Mini App install guides from Telegram bot connect buttons and show public "
"install guide share links."
),
)
SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED: bool = Field(
default=True,
description=(
"Use Remnawave Panel Subscription Page config for embedded guides when available."
),
)
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED: bool = Field(
default=False,
description="Enable admin JSON override for embedded guides config.",
)
SUBSCRIPTION_PAGE_CONFIG_PATH: str = Field(
default="data/subpage-config/multiapp.json",
description="Path to Remnawave Subscription Page v1 JSON config for embedded guides.",
)
SUBSCRIPTION_PAGE_CONFIG_JSON: str = Field(
default="",
description="Admin-provided Remnawave Subscription Page v1 JSON config override.",
)
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
@@ -514,6 +551,17 @@ class Settings(BaseSettings):
return [uuid.strip() for uuid in self.USER_SQUAD_UUIDS.split(",") if uuid.strip()] return [uuid.strip() for uuid in self.USER_SQUAD_UUIDS.split(",") if uuid.strip()]
return None return None
@computed_field
@property
def parsed_trial_squad_uuids(self) -> Optional[List[str]]:
if self.TRIAL_SQUAD_UUIDS:
trial_squads = [
uuid.strip() for uuid in self.TRIAL_SQUAD_UUIDS.split(",") if uuid.strip()
]
if trial_squads:
return trial_squads
return self.parsed_user_squad_uuids
@computed_field @computed_field
@property @property
def parsed_user_external_squad_uuid(self) -> Optional[str]: def parsed_user_external_squad_uuid(self) -> Optional[str]:
@@ -565,13 +613,14 @@ class Settings(BaseSettings):
@property @property
def stars_subscription_options(self) -> Dict[int, int]: def stars_subscription_options(self) -> Dict[int, int]:
options: Dict[int, int] = {} options: Dict[int, int] = {}
if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None: stars_enabled = self.STARS_ENABLED or self.STARS_ADMIN_ONLY_ENABLED
if stars_enabled and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
options[1] = self.STARS_PRICE_1_MONTH options[1] = self.STARS_PRICE_1_MONTH
if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None: if stars_enabled and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
options[3] = self.STARS_PRICE_3_MONTHS options[3] = self.STARS_PRICE_3_MONTHS
if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None: if stars_enabled and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
options[6] = self.STARS_PRICE_6_MONTHS options[6] = self.STARS_PRICE_6_MONTHS
if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None: if stars_enabled and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
options[12] = self.STARS_PRICE_12_MONTHS options[12] = self.STARS_PRICE_12_MONTHS
return options return options
@@ -0,0 +1,669 @@
"""Loader and validator for Remnawave Subscription Page v1 configs."""
from __future__ import annotations
import copy
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
from urllib.parse import urlsplit
class SubscriptionGuidesConfigError(ValueError):
"""Raised when the embedded subscription guides config is invalid."""
APP_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG_PATH = "data/subpage-config/multiapp.json"
DEFAULT_CONFIG_BUNDLED_PATH = (
Path(__file__).resolve().parent / "defaults" / "subscription_page_multiapp.json"
)
ALLOWED_LOCALES = {
"az",
"be",
"de",
"en",
"es",
"fa",
"fr",
"hi",
"id",
"ja",
"pl",
"pt",
"ru",
"th",
"tk",
"tr",
"uk",
"uz",
"vi",
"zh",
}
ALLOWED_PLATFORMS = {
"android",
"androidTV",
"appleTV",
"ios",
"linux",
"macos",
"windows",
}
ALLOWED_BUTTON_TYPES = {"copyButton", "external", "subscriptionLink"}
BASE_TRANSLATION_KEYS = (
"active",
"bandwidth",
"connectionKeysHeader",
"copyLink",
"expired",
"expires",
"expiresIn",
"getLink",
"inactive",
"indefinitely",
"installationGuideHeader",
"linkCopied",
"linkCopiedToClipboard",
"name",
"scanQrCode",
"scanQrCodeDescription",
"scanToImport",
"status",
"unknown",
)
ALLOWED_SVG_COLORS = {
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"slate",
"gray",
"zinc",
"neutral",
"stone",
}
UI_SUBSCRIPTION_INFO_TYPES = {"cards", "collapsed", "expanded", "hidden"}
UI_INSTALLATION_GUIDE_TYPES = {"accordion", "cards", "minimal", "timeline"}
SVG_KEY_RE = re.compile(r"^[A-Za-z]+$")
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
UNSAFE_SVG_RE = re.compile(
r"(<\s*/?\s*(?:script|foreignObject|iframe|object|embed|image|use|style|a)\b)"
r"|(\son[a-z]+\s*=)"
r"|(javascript\s*:)"
r"|(data\s*:)",
re.IGNORECASE,
)
_CONFIG_CACHE: Dict[Tuple[str, str], Dict[str, Any]] = {}
PANEL_CONFIG_KEYS = (
"config",
"subscriptionPageConfig",
"subpageConfig",
"subPageConfig",
"pageConfig",
)
PANEL_WRAPPER_KEYS = ("response", "data", "result", *PANEL_CONFIG_KEYS)
def validate_subscription_guides_config_text(raw: str) -> Dict[str, Any]:
"""Parse and validate a v1 subscription guides config JSON string."""
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise SubscriptionGuidesConfigError(f"Invalid JSON: {exc.msg}") from exc
return validate_subscription_guides_config(payload)
def default_subscription_guides_config_text() -> str:
try:
return DEFAULT_CONFIG_BUNDLED_PATH.read_text(encoding="utf-8")
except OSError as exc:
raise SubscriptionGuidesConfigError(
f"Bundled default config is unavailable: {exc}"
) from exc
def resolve_subscription_guides_config_path(settings: Any) -> Path:
configured_path = str(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", DEFAULT_CONFIG_PATH)
or DEFAULT_CONFIG_PATH
).strip()
if not configured_path:
raise SubscriptionGuidesConfigError("SUBSCRIPTION_PAGE_CONFIG_PATH is empty")
path = Path(configured_path)
if not path.is_absolute():
path = APP_ROOT / path
return path
def ensure_subscription_guides_config_file(settings: Any) -> Path:
path = resolve_subscription_guides_config_path(settings)
if not path.exists():
raise SubscriptionGuidesConfigError(f"Config file does not exist: {path}")
return path
def subscription_guides_admin_config_json(settings: Any) -> Tuple[str, str]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
if admin_json:
return admin_json, "admin_json"
return "", "empty"
def load_subscription_guides_config(settings: Any) -> Tuple[Dict[str, Any], str]:
"""Load the enabled guides config from admin JSON or a configured file path."""
source, raw = _read_config_source(settings)
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
cache_key = (source, digest)
cached = _CONFIG_CACHE.get(cache_key)
if cached is not None:
return copy.deepcopy(cached), source
config = validate_subscription_guides_config_text(raw)
_CONFIG_CACHE[cache_key] = copy.deepcopy(config)
return config, source
def subscription_guides_status(settings: Any) -> Dict[str, Any]:
"""Return a safe status payload for user-facing guide availability checks."""
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return {"enabled": False, "config": None, "source": None, "error": None}
try:
config, source = load_subscription_guides_config(settings)
except SubscriptionGuidesConfigError as exc:
return {"enabled": False, "config": None, "source": None, "error": str(exc)}
return {"enabled": True, "config": config, "source": source, "error": None}
def subscription_guides_available(settings: Any) -> bool:
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return False
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
if (
not (
admin_json
and bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False))
)
and bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True))
and getattr(settings, "PANEL_API_URL", None)
and getattr(settings, "PANEL_API_KEY", None)
):
return True
status = subscription_guides_status(settings)
return bool(status.get("enabled") and status.get("config"))
def extract_subscription_guides_config_from_panel(payload: Any) -> Any:
"""Extract Subscription Page v1 config from flexible Panel API response shapes."""
return _extract_config_candidate(payload, set())
def validate_panel_subscription_guides_config(
payload: Any,
*,
allow_default_when_missing: bool = False,
) -> Dict[str, Any]:
config = extract_subscription_guides_config_from_panel(payload)
if config is None:
if allow_default_when_missing and panel_subscription_page_allowed(payload):
default_text = default_subscription_guides_config_text()
return validate_subscription_guides_config_text(default_text)
raise SubscriptionGuidesConfigError("Panel response does not contain a v1 config")
return validate_subscription_guides_config(config)
def panel_subscription_page_allowed(payload: Any) -> bool:
candidate = _find_panel_response_object(payload, set())
return bool(candidate and candidate.get("webpageAllowed") is True)
def validate_subscription_guides_config(payload: Any) -> Dict[str, Any]:
if not isinstance(payload, Mapping):
raise SubscriptionGuidesConfigError("Config root must be an object")
if payload.get("version") != "1":
raise SubscriptionGuidesConfigError(
"Only Subscription Page config version '1' is supported"
)
locales = _validate_locales(payload.get("locales"))
svg_library = _validate_svg_library(payload.get("svgLibrary"))
branding = _validate_branding(payload.get("brandingSettings"))
ui_config = _validate_ui_config(payload.get("uiConfig"))
base_settings = _validate_base_settings(payload.get("baseSettings"))
base_translations = _validate_base_translations(payload.get("baseTranslations"), locales)
platforms = _validate_platforms(payload.get("platforms"), locales, svg_library)
return {
"version": "1",
"locales": locales,
"brandingSettings": branding,
"uiConfig": ui_config,
"baseSettings": base_settings,
"baseTranslations": base_translations,
"svgLibrary": svg_library,
"platforms": platforms,
}
def _read_config_source(settings: Any) -> Tuple[str, str]:
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 "admin_json", admin_json
path = ensure_subscription_guides_config_file(settings)
try:
return "file", path.read_text(encoding="utf-8")
except OSError as exc:
raise SubscriptionGuidesConfigError(f"Failed to read config file: {exc}") from exc
def _extract_config_candidate(value: Any, seen: set[int]) -> Any:
if isinstance(value, str):
text = value.strip()
if not text.startswith(("{", "[")):
return None
try:
return _extract_config_candidate(json.loads(text), seen)
except json.JSONDecodeError as exc:
raise SubscriptionGuidesConfigError(f"Invalid JSON in Panel config: {exc.msg}") from exc
if not isinstance(value, Mapping):
return None
value_id = id(value)
if value_id in seen:
return None
seen.add(value_id)
if _looks_like_v1_config(value):
return value
for key in PANEL_WRAPPER_KEYS:
if key not in value:
continue
candidate = _extract_config_candidate(value.get(key), seen)
if candidate is not None:
return candidate
return None
def _looks_like_v1_config(value: Mapping[str, Any]) -> bool:
return (
value.get("version") == "1"
and "locales" in value
and "svgLibrary" in value
and "platforms" in value
)
def _find_panel_response_object(value: Any, seen: set[int]) -> Optional[Mapping[str, Any]]:
if not isinstance(value, Mapping):
return None
value_id = id(value)
if value_id in seen:
return None
seen.add(value_id)
if "webpageAllowed" in value:
return value
for key in ("response", "data", "result"):
candidate = _find_panel_response_object(value.get(key), seen)
if candidate is not None:
return candidate
return None
def _validate_locales(value: Any) -> list[str]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError("locales must be a non-empty array")
locales: list[str] = []
for index, item in enumerate(value):
locale = str(item or "").strip()
if locale not in ALLOWED_LOCALES:
raise SubscriptionGuidesConfigError(f"Unsupported locale at locales[{index}]: {locale}")
if locale not in locales:
locales.append(locale)
return locales
def _validate_branding(value: Any) -> Dict[str, str]:
data = _require_object(value, "brandingSettings")
result = {
"title": _require_text(data, "title", "brandingSettings.title"),
"logoUrl": _require_text(data, "logoUrl", "brandingSettings.logoUrl"),
"supportUrl": _require_text(data, "supportUrl", "brandingSettings.supportUrl"),
}
_assert_http_url(result["logoUrl"], "brandingSettings.logoUrl")
_assert_http_url(result["supportUrl"], "brandingSettings.supportUrl")
return result
def _validate_ui_config(value: Any) -> Dict[str, str]:
data = _require_object(value, "uiConfig")
subscription_info = _require_text(
data,
"subscriptionInfoBlockType",
"uiConfig.subscriptionInfoBlockType",
)
installation_guides = _require_text(
data,
"installationGuidesBlockType",
"uiConfig.installationGuidesBlockType",
)
if subscription_info not in UI_SUBSCRIPTION_INFO_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported uiConfig.subscriptionInfoBlockType: {subscription_info}"
)
if installation_guides not in UI_INSTALLATION_GUIDE_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported uiConfig.installationGuidesBlockType: {installation_guides}"
)
return {
"subscriptionInfoBlockType": subscription_info,
"installationGuidesBlockType": installation_guides,
}
def _validate_base_settings(value: Any) -> Dict[str, Any]:
data = value if isinstance(value, Mapping) else {}
return {
"metaTitle": _optional_text(data, "metaTitle") or "Subscription",
"metaDescription": _optional_text(data, "metaDescription") or "Subscription",
"showConnectionKeys": bool(data.get("showConnectionKeys", False)),
"hideGetLinkButton": bool(data.get("hideGetLinkButton", False)),
}
def _validate_base_translations(value: Any, locales: Iterable[str]) -> Dict[str, Dict[str, str]]:
data = _require_object(value, "baseTranslations")
result: Dict[str, Dict[str, str]] = {}
for key in BASE_TRANSLATION_KEYS:
result[key] = _validate_locale_strings(
data.get(key),
locales,
f"baseTranslations.{key}",
)
return result
def _validate_svg_library(value: Any) -> Dict[str, str]:
data = _require_object(value, "svgLibrary")
if not data:
raise SubscriptionGuidesConfigError("svgLibrary must not be empty")
result: Dict[str, str] = {}
for key, raw_svg in data.items():
svg_key = str(key or "").strip()
if not SVG_KEY_RE.fullmatch(svg_key):
raise SubscriptionGuidesConfigError(f"Invalid svgLibrary key: {svg_key}")
result[svg_key] = _sanitize_svg(raw_svg, f"svgLibrary.{svg_key}")
return result
def _validate_platforms(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
) -> Dict[str, Dict[str, Any]]:
data = _require_object(value, "platforms")
if not data:
raise SubscriptionGuidesConfigError("platforms must not be empty")
result: Dict[str, Dict[str, Any]] = {}
for platform_key, raw_platform in data.items():
key = str(platform_key or "").strip()
if key not in ALLOWED_PLATFORMS:
raise SubscriptionGuidesConfigError(f"Unsupported platform: {key}")
platform = _require_object(raw_platform, f"platforms.{key}")
icon_key = _validate_svg_icon_key(
platform.get("svgIconKey"),
svg_library,
f"platforms.{key}.svgIconKey",
)
apps = _validate_apps(platform.get("apps"), locales, svg_library, f"platforms.{key}.apps")
result[key] = {
"displayName": _validate_localized_or_text(
platform.get("displayName"),
locales,
f"platforms.{key}.displayName",
),
"svgIconKey": icon_key,
"apps": apps,
}
return result
def _validate_apps(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError(f"{path} must be a non-empty array")
apps: list[Dict[str, Any]] = []
for index, raw_app in enumerate(value):
app_path = f"{path}[{index}]"
app = _require_object(raw_app, app_path)
name = _require_text(app, "name", f"{app_path}.name")
if len(name) < 2:
raise SubscriptionGuidesConfigError(f"{app_path}.name must contain at least 2 chars")
icon_key = _optional_svg_icon_key(
app.get("svgIconKey"),
svg_library,
f"{app_path}.svgIconKey",
)
apps.append(
{
"name": name,
"svgIconKey": icon_key,
"featured": bool(app.get("featured", False)),
"blocks": _validate_blocks(
app.get("blocks"),
locales,
svg_library,
f"{app_path}.blocks",
),
}
)
return apps
def _validate_blocks(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError(f"{path} must be a non-empty array")
blocks: list[Dict[str, Any]] = []
for index, raw_block in enumerate(value):
block_path = f"{path}[{index}]"
block = _require_object(raw_block, block_path)
color = _optional_text(block, "svgIconColor")
if color and color not in ALLOWED_SVG_COLORS and not HEX_COLOR_RE.fullmatch(color):
raise SubscriptionGuidesConfigError(f"{block_path}.svgIconColor is invalid")
blocks.append(
{
"svgIconKey": _validate_svg_icon_key(
block.get("svgIconKey"),
svg_library,
f"{block_path}.svgIconKey",
),
"svgIconColor": color or "",
"title": _validate_locale_strings(
block.get("title"),
locales,
f"{block_path}.title",
),
"description": _validate_locale_strings(
block.get("description"),
locales,
f"{block_path}.description",
),
"buttons": _validate_buttons(
block.get("buttons"),
locales,
svg_library,
f"{block_path}.buttons",
),
}
)
return blocks
def _validate_buttons(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise SubscriptionGuidesConfigError(f"{path} must be an array")
buttons: list[Dict[str, Any]] = []
for index, raw_button in enumerate(value):
button_path = f"{path}[{index}]"
button = _require_object(raw_button, button_path)
button_type = _require_text(button, "type", f"{button_path}.type")
if button_type not in ALLOWED_BUTTON_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported button type at {button_path}: {button_type}"
)
link = _require_text(button, "link", f"{button_path}.link")
_validate_button_link(link, button_type, f"{button_path}.link")
buttons.append(
{
"type": button_type,
"link": link,
"text": _validate_locale_strings(
button.get("text"),
locales,
f"{button_path}.text",
),
"svgIconKey": _validate_svg_icon_key(
button.get("svgIconKey"),
svg_library,
f"{button_path}.svgIconKey",
),
}
)
return buttons
def _validate_locale_strings(value: Any, locales: Iterable[str], path: str) -> Dict[str, str]:
data = _require_object(value, path)
result: Dict[str, str] = {}
for locale in locales:
text = data.get(locale)
if not isinstance(text, str) or not text.strip():
raise SubscriptionGuidesConfigError(f"{path}.{locale} is required")
result[locale] = text.strip()
return result
def _validate_localized_or_text(
value: Any,
locales: Iterable[str],
path: str,
) -> str | Dict[str, str]:
if isinstance(value, str):
text = value.strip()
if text:
return text
return _validate_locale_strings(value, locales, path)
def _validate_svg_icon_key(value: Any, svg_library: Mapping[str, str], path: str) -> str:
key = _string_value(value)
if not key:
raise SubscriptionGuidesConfigError(f"{path} is required")
if key not in svg_library:
raise SubscriptionGuidesConfigError(f"{path} references missing svgLibrary key: {key}")
return key
def _optional_svg_icon_key(value: Any, svg_library: Mapping[str, str], path: str) -> Optional[str]:
key = _string_value(value)
if not key:
return None
if key not in svg_library:
raise SubscriptionGuidesConfigError(f"{path} references missing svgLibrary key: {key}")
return key
def _validate_button_link(link: str, _button_type: str, path: str) -> None:
_assert_safe_link(link, path)
def _assert_safe_link(value: str, path: str) -> None:
if CONTROL_CHARS_RE.search(value):
raise SubscriptionGuidesConfigError(f"{path} contains control characters")
lower = value.strip().lower()
if lower.startswith(("javascript:", "data:", "vbscript:")):
raise SubscriptionGuidesConfigError(f"{path} uses an unsafe URL scheme")
def _assert_http_url(value: str, path: str) -> None:
_assert_safe_link(value, path)
parts = urlsplit(value)
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise SubscriptionGuidesConfigError(f"{path} must be an http(s) URL")
def _sanitize_svg(value: Any, path: str) -> str:
svg = _string_value(value)
if not svg:
raise SubscriptionGuidesConfigError(f"{path} is required")
trimmed = svg.strip()
if not trimmed.lower().startswith("<svg"):
raise SubscriptionGuidesConfigError(f"{path} must be an SVG document")
if UNSAFE_SVG_RE.search(trimmed):
raise SubscriptionGuidesConfigError(f"{path} contains unsafe SVG markup")
return trimmed
def _require_object(value: Any, path: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise SubscriptionGuidesConfigError(f"{path} must be an object")
return value
def _require_text(data: Mapping[str, Any], key: str, path: str) -> str:
value = _string_value(data.get(key))
if not value:
raise SubscriptionGuidesConfigError(f"{path} is required")
return value
def _optional_text(data: Mapping[str, Any], key: str) -> str:
return _string_value(data.get(key))
def _string_value(value: Any) -> str:
if not isinstance(value, str):
return ""
return value.strip()
+26
View File
@@ -25,6 +25,8 @@ class TrafficPackage(BaseModel):
class HwidDevicePackage(BaseModel): class HwidDevicePackage(BaseModel):
count: int count: int
price: float price: float
prices: Dict[str, float] = Field(default_factory=dict)
min_price: Optional[float] = None
@model_validator(mode="after") @model_validator(mode="after")
def validate_values(self) -> "HwidDevicePackage": def validate_values(self) -> "HwidDevicePackage":
@@ -32,8 +34,32 @@ class HwidDevicePackage(BaseModel):
raise ValueError("device package count must be greater than zero") raise ValueError("device package count must be greater than zero")
if self.price < 0: if self.price < 0:
raise ValueError("device package price must be non-negative") raise ValueError("device package price must be non-negative")
normalized_prices: Dict[str, float] = {}
for period, value in (self.prices or {}).items():
period_key = str(period).strip()
if not period_key:
raise ValueError("device package price period must not be empty")
try:
period_months = int(period_key)
except (TypeError, ValueError) as exc:
raise ValueError("device package price period must be an integer") from exc
if period_months <= 0:
raise ValueError("device package price period must be positive")
if float(value) < 0:
raise ValueError("device package period price must be non-negative")
normalized_prices[str(period_months)] = float(value)
self.prices = normalized_prices
if self.min_price is not None and self.min_price < 0:
raise ValueError("device package min_price must be non-negative")
return self return self
def price_for_period(self, months: int) -> float:
months_int = max(1, int(months or 1))
value = self.prices.get(str(months_int))
if value is not None:
return float(value)
return float(self.price) * months_int
class PackageSet(BaseModel): class PackageSet(BaseModel):
rub: List[TrafficPackage] = Field(default_factory=list) rub: List[TrafficPackage] = Field(default_factory=list)
+8 -1
View File
@@ -365,7 +365,11 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
except OSError: except OSError:
return True return True
if key == "light": if key == "light":
return "--success-text" not in style or ".theme-key-light.app-shell" not in style return (
"--success-text" not in style
or ".theme-key-light.app-shell" not in style
or "Install guide theme surfaces" not in style
)
if key == "ascii": if key == "ascii":
return ( return (
".theme-key-ascii" not in style ".theme-key-ascii" not in style
@@ -374,6 +378,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "ascii-boot-type" not in style or "ascii-boot-type" not in style
or "Console-style tables" not in style or "Console-style tables" not in style
or "New webapp surfaces: support, purchase info, password login" not in style or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
) )
if key != "windows95": if key != "windows95":
return False return False
@@ -394,7 +399,9 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "::-webkit-slider-thumb" not in style or "::-webkit-slider-thumb" not in style
or "?v=9" not in style or "?v=9" not in style
or "lucide-life-buoy" not in style or "lucide-life-buoy" not in style
or "lucide-qr-code" not in style
or "New webapp surfaces: support, purchase info, password login" not in style or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
) )
+2
View File
@@ -1,6 +1,7 @@
from . import ( from . import (
ad_dal, ad_dal,
app_settings_dal, app_settings_dal,
locale_overrides_dal,
message_log_dal, message_log_dal,
panel_sync_dal, panel_sync_dal,
payment_dal, payment_dal,
@@ -23,5 +24,6 @@ __all__ = (
"ad_dal", "ad_dal",
"security_dal", "security_dal",
"app_settings_dal", "app_settings_dal",
"locale_overrides_dal",
"support_dal", "support_dal",
) )
+86
View File
@@ -0,0 +1,86 @@
"""Persistent overrides for localization strings."""
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import LocaleOverride
async def get_all_overrides(session: AsyncSession) -> Dict[str, Dict[str, str]]:
rows = (await session.execute(select(LocaleOverride))).scalars().all()
result: Dict[str, Dict[str, str]] = {}
for row in rows:
result.setdefault(row.lang, {})[row.key] = row.value
return result
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, object]]:
rows = (await session.execute(select(LocaleOverride))).scalars().all()
items: List[Dict[str, object]] = []
for row in rows:
items.append(
{
"lang": row.lang,
"key": row.key,
"value": row.value,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": row.updated_by,
}
)
return items
async def upsert_override(
session: AsyncSession,
*,
lang: str,
key: str,
value: str,
updated_by: Optional[int],
) -> None:
now = datetime.now(timezone.utc)
stmt = (
pg_insert(LocaleOverride)
.values(lang=lang, key=key, value=value, updated_at=now, updated_by=updated_by)
.on_conflict_do_update(
index_elements=[LocaleOverride.lang, LocaleOverride.key],
set_={
"value": value,
"updated_at": now,
"updated_by": updated_by,
},
)
)
await session.execute(stmt)
async def delete_override(session: AsyncSession, *, lang: str, key: str) -> bool:
stmt = delete(LocaleOverride).where(
LocaleOverride.lang == lang,
LocaleOverride.key == key,
)
result = await session.execute(stmt)
return bool(result.rowcount or 0)
async def bulk_apply(
session: AsyncSession,
*,
updates: Dict[Tuple[str, str], Tuple[bool, str]],
updated_by: Optional[int],
) -> None:
for (lang, key), (set_flag, value) in updates.items():
if set_flag:
await upsert_override(
session,
lang=lang,
key=key,
value=value,
updated_by=updated_by,
)
else:
await delete_override(session, lang=lang, key=key)
+9 -1
View File
@@ -4,6 +4,7 @@ from typing import List, Optional
from sqlalchemy import func, or_ from sqlalchemy import func, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from ..models import MessageLog from ..models import MessageLog
@@ -22,7 +23,13 @@ async def create_message_log(session: AsyncSession, log_data: dict) -> Optional[
async def get_all_message_logs(session: AsyncSession, limit: int, offset: int) -> List[MessageLog]: async def get_all_message_logs(session: AsyncSession, limit: int, offset: int) -> List[MessageLog]:
stmt = select(MessageLog).order_by(MessageLog.timestamp.desc()).limit(limit).offset(offset) stmt = (
select(MessageLog)
.options(selectinload(MessageLog.author_user), selectinload(MessageLog.target_user))
.order_by(MessageLog.timestamp.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalars().all() return result.scalars().all()
@@ -38,6 +45,7 @@ async def get_user_message_logs(
) -> List[MessageLog]: ) -> List[MessageLog]:
stmt = ( stmt = (
select(MessageLog) select(MessageLog)
.options(selectinload(MessageLog.author_user), selectinload(MessageLog.target_user))
.where( .where(
or_( or_(
MessageLog.user_id == user_id_to_search, MessageLog.user_id == user_id_to_search,
+60
View File
@@ -86,6 +86,66 @@ async def get_payment_by_db_id(session: AsyncSession, payment_db_id: int) -> Opt
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def find_recent_pending_provider_payment(
session: AsyncSession,
*,
user_id: int,
provider: str,
pending_status: str,
amount: float,
sale_mode: Optional[str],
months: Optional[int],
purchased_gb: Optional[float],
purchased_hwid_devices: Optional[int],
tariff_key: Optional[str] = None,
since_minutes: int = 60,
) -> Optional[Payment]:
"""Return the most recent pending payment matching the given tariff parameters.
Used to reuse an existing provider payment link instead of creating a new one
on repeated user clicks. Only payments with a populated ``provider_payment_id``
are returned without it, there's no link to reuse.
"""
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
conditions = [
Payment.user_id == user_id,
Payment.provider == provider,
Payment.status == pending_status,
Payment.provider_payment_id.isnot(None),
Payment.created_at >= cutoff,
func.abs(Payment.amount - float(amount)) < 0.01,
]
if sale_mode is not None:
conditions.append(Payment.sale_mode == sale_mode)
if tariff_key is not None:
conditions.append(Payment.tariff_key == tariff_key)
if months is not None:
conditions.append(Payment.subscription_duration_months == months)
else:
conditions.append(Payment.subscription_duration_months.is_(None))
if purchased_gb is not None:
conditions.append(func.abs(Payment.purchased_gb - float(purchased_gb)) < 0.0001)
else:
conditions.append(Payment.purchased_gb.is_(None))
if purchased_hwid_devices is not None:
conditions.append(Payment.purchased_hwid_devices == purchased_hwid_devices)
else:
conditions.append(Payment.purchased_hwid_devices.is_(None))
stmt = (
select(Payment)
.where(and_(*conditions))
.options(joinedload(Payment.user), joinedload(Payment.promo_code_used))
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_payment_status_by_db_id( async def update_payment_status_by_db_id(
session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None
) -> Optional[Payment]: ) -> Optional[Payment]:
+78
View File
@@ -1,4 +1,6 @@
import logging import logging
import re
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload
from db.models import Subscription from db.models import Subscription
INSTALL_SHARE_TOKEN_BYTES = 16
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]: def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
model_columns = Subscription.__mapper__.columns.keys() model_columns = Subscription.__mapper__.columns.keys()
@@ -42,6 +46,80 @@ async def get_subscription_by_panel_subscription_uuid(
return result.scalar_one_or_none() return result.scalar_one_or_none()
def normalize_install_share_token(value: Any) -> str:
token = str(value or "").strip().lower()
if not re.fullmatch(r"[a-f0-9]{32}", token):
return ""
return token
async def get_subscription_by_install_share_token(
session: AsyncSession,
token: str,
) -> Optional[Subscription]:
normalized = normalize_install_share_token(token)
if not normalized:
return None
stmt = select(Subscription).where(Subscription.install_share_token == normalized)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def ensure_install_share_token(
session: AsyncSession,
subscription: Subscription,
) -> str:
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
existing = normalize_install_share_token(raw_existing)
if existing:
if existing != getattr(subscription, "install_share_token", None):
subscription.install_share_token = existing
await session.flush()
return existing
subscription_id = getattr(subscription, "subscription_id", None)
for _attempt in range(10):
token = secrets.token_hex(INSTALL_SHARE_TOKEN_BYTES)
if await get_subscription_by_install_share_token(session, token):
continue
if subscription_id:
result = await session.execute(
update(Subscription)
.where(
Subscription.subscription_id == subscription_id,
or_(
Subscription.install_share_token.is_(None),
Subscription.install_share_token == "",
Subscription.install_share_token == raw_existing,
),
)
.values(install_share_token=token)
)
await session.flush()
if result.rowcount:
await session.refresh(subscription)
return (
normalize_install_share_token(
getattr(subscription, "install_share_token", None)
)
or token
)
await session.refresh(subscription)
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
existing = normalize_install_share_token(raw_existing)
if existing:
return existing
continue
subscription.install_share_token = token
await session.flush()
await session.refresh(subscription)
return token
raise RuntimeError("Failed to generate a unique install share token")
async def get_active_subscriptions_for_user( async def get_active_subscriptions_for_user(
session: AsyncSession, user_id: int session: AsyncSession, user_id: int
) -> List[Subscription]: ) -> List[Subscription]:
+149 -2
View File
@@ -1,9 +1,11 @@
import inspect
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, select from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import HwidDevicePurchase, TariffChange, TrafficTopup, TrafficWarning from db.models import HwidDevicePurchase, Payment, TariffChange, TrafficTopup, TrafficWarning
async def create_traffic_topup( async def create_traffic_topup(
@@ -26,17 +28,41 @@ async def create_traffic_topup(
return record return record
async def sum_traffic_topups(
session: AsyncSession,
*,
subscription_id: int,
kinds: Optional[List[str]] = None,
created_at_gte=None,
) -> int:
conditions = [TrafficTopup.subscription_id == subscription_id]
if kinds:
conditions.append(TrafficTopup.kind.in_(list(kinds)))
if created_at_gte is not None:
conditions.append(TrafficTopup.created_at >= created_at_gte)
result = await session.execute(
select(func.coalesce(func.sum(TrafficTopup.purchased_bytes), 0)).where(
and_(*conditions)
)
)
return int(result.scalar() or 0)
async def create_hwid_device_purchase( async def create_hwid_device_purchase(
session: AsyncSession, session: AsyncSession,
*, *,
subscription_id: int, subscription_id: int,
payment_id: Optional[int], payment_id: Optional[int],
purchased_devices: int, purchased_devices: int,
valid_from: Optional[datetime] = None,
valid_until: Optional[datetime] = None,
) -> HwidDevicePurchase: ) -> HwidDevicePurchase:
record = HwidDevicePurchase( record = HwidDevicePurchase(
subscription_id=subscription_id, subscription_id=subscription_id,
payment_id=payment_id, payment_id=payment_id,
purchased_devices=purchased_devices, purchased_devices=purchased_devices,
valid_from=valid_from or datetime.now(timezone.utc),
valid_until=valid_until,
) )
session.add(record) session.add(record)
await session.flush() await session.flush()
@@ -44,6 +70,127 @@ async def create_hwid_device_purchase(
return record return record
def _hwid_active_conditions(subscription_id: int, at: datetime) -> List[Any]:
return [
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_from.is_(None), HwidDevicePurchase.valid_from <= at),
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
]
async def _resolve_result_value(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
return value
async def sum_active_hwid_devices(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> int:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0)).where(
and_(*_hwid_active_conditions(subscription_id, at))
)
)
return int(await _resolve_result_value(result.scalar()) or 0)
async def get_hwid_device_entitlement_summary(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> Dict[str, Any]:
at = at or datetime.now(timezone.utc)
active_result = await session.execute(
select(
func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0),
func.max(HwidDevicePurchase.valid_until),
).where(and_(*_hwid_active_conditions(subscription_id, at)))
)
active_devices, active_until = await _resolve_result_value(active_result.one())
future_result = await session.execute(
select(func.min(HwidDevicePurchase.valid_from)).where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
HwidDevicePurchase.valid_from > at,
)
)
)
return {
"active_devices": int(active_devices or 0),
"active_until": active_until,
"next_valid_from": await _resolve_result_value(future_result.scalar_one_or_none()),
}
async def get_hwid_device_value_entries(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(
HwidDevicePurchase.purchase_id,
HwidDevicePurchase.purchased_devices,
HwidDevicePurchase.valid_from,
HwidDevicePurchase.valid_until,
HwidDevicePurchase.created_at,
Payment.amount,
Payment.currency,
)
.outerjoin(Payment, Payment.payment_id == HwidDevicePurchase.payment_id)
.where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
)
)
)
entries = []
rows = await _resolve_result_value(result.all())
for row in rows:
entries.append(
{
"purchase_id": row[0],
"purchased_devices": row[1],
"valid_from": row[2],
"valid_until": row[3],
"created_at": row[4],
"amount": row[5],
"currency": row[6],
}
)
return entries
async def expire_hwid_device_purchases(
session: AsyncSession,
*,
purchase_ids: List[int],
at: Optional[datetime] = None,
) -> int:
ids = [int(item) for item in purchase_ids if item is not None]
if not ids:
return 0
at = at or datetime.now(timezone.utc)
result = await session.execute(
update(HwidDevicePurchase)
.where(HwidDevicePurchase.purchase_id.in_(ids))
.values(valid_until=at)
)
return result.rowcount or 0
async def create_tariff_change( async def create_tariff_change(
session: AsyncSession, session: AsyncSession,
change_data: Dict[str, Any], change_data: Dict[str, Any],
+95 -4
View File
@@ -12,10 +12,17 @@ from sqlalchemy.orm import aliased
from ..models import ( from ..models import (
AdAttribution, AdAttribution,
EmailVerificationCode,
HwidDevicePurchase,
MessageLog, MessageLog,
Payment, Payment,
PromoCodeActivation, PromoCodeActivation,
Subscription, Subscription,
SupportTicket,
SupportTicketMessage,
TariffChange,
TrafficTopup,
TrafficWarning,
User, User,
UserBilling, UserBilling,
UserPaymentMethod, UserPaymentMethod,
@@ -583,6 +590,40 @@ async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
return result.scalars().all() return result.scalars().all()
async def get_panel_user_uuids_for_user(
session: AsyncSession,
user_id: int,
*,
user: Optional[User] = None,
) -> List[str]:
"""Return every Remnawave user UUID linked to a bot user.
The canonical UUID normally lives on ``users.panel_user_uuid``, but older
or partially-synced records can still have UUIDs only on subscription rows.
"""
if user is None:
user = await get_user_by_id(session, user_id)
panel_uuids: List[str] = []
seen: set[str] = set()
def add_uuid(value: Any) -> None:
panel_uuid = str(value or "").strip()
if panel_uuid and panel_uuid not in seen:
seen.add(panel_uuid)
panel_uuids.append(panel_uuid)
add_uuid(getattr(user, "panel_user_uuid", None))
stmt = select(Subscription.panel_user_uuid).where(Subscription.user_id == user_id)
result = await session.execute(stmt)
for panel_uuid in result.scalars().all():
add_uuid(panel_uuid)
return panel_uuids
async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive user statistics including active users, trial users, etc.""" """Get comprehensive user statistics including active users, trial users, etc."""
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -706,19 +747,69 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None) update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
) )
# Clean up dependent tables that do not cascade automatically subscription_ids = select(Subscription.subscription_id).where(Subscription.user_id == user_id)
payment_ids = select(Payment.payment_id).where(Payment.user_id == user_id)
support_ticket_ids = select(SupportTicket.ticket_id).where(SupportTicket.user_id == user_id)
# Clean up dependent tables that do not cascade automatically.
await session.execute(
delete(TrafficTopup).where(
or_(
TrafficTopup.subscription_id.in_(subscription_ids),
TrafficTopup.payment_id.in_(payment_ids),
)
)
)
await session.execute(
delete(HwidDevicePurchase).where(
or_(
HwidDevicePurchase.subscription_id.in_(subscription_ids),
HwidDevicePurchase.payment_id.in_(payment_ids),
)
)
)
await session.execute(
delete(TariffChange).where(
or_(
TariffChange.subscription_id.in_(subscription_ids),
TariffChange.payment_id.in_(payment_ids),
)
)
)
await session.execute(
delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids))
)
await session.execute(
delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids))
)
await session.execute(
update(SupportTicketMessage)
.where(SupportTicketMessage.author_user_id == user_id)
.values(author_user_id=None)
)
await session.execute(delete(SupportTicket).where(SupportTicket.user_id == user_id))
await session.execute(
delete(EmailVerificationCode).where(EmailVerificationCode.target_user_id == user_id)
)
await session.execute( await session.execute(
delete(MessageLog).where( delete(MessageLog).where(
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id) or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
) )
) )
await session.execute(delete(Payment).where(Payment.user_id == user_id)) await session.execute(
await session.execute(delete(Subscription).where(Subscription.user_id == user_id)) delete(PromoCodeActivation).where(
await session.execute(delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)) or_(
PromoCodeActivation.user_id == user_id,
PromoCodeActivation.payment_id.in_(payment_ids),
)
)
)
await session.execute(delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)) await session.execute(delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id))
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id)) await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id)) await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id)) await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
await session.execute(delete(Payment).where(Payment.user_id == user_id))
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
await session.delete(user) await session.delete(user)
await session.flush() await session.flush()
+3 -2
View File
@@ -1,5 +1,6 @@
import logging import logging
from sqlalchemy import text
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -10,6 +11,7 @@ from db.models import Base
from .migrator import run_database_migrations from .migrator import run_database_migrations
async_engine = None async_engine = None
DB_INIT_ADVISORY_LOCK_ID = 817512404897421337
def redacted_database_url(database_url: str) -> str: def redacted_database_url(database_url: str) -> str:
@@ -71,6 +73,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
) )
async with async_engine.begin() as conn: async with async_engine.begin() as conn:
await conn.execute(text(f"SELECT pg_advisory_xact_lock({DB_INIT_ADVISORY_LOCK_ID})"))
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_database_migrations) await conn.run_sync(run_database_migrations)
logging.info("PostgreSQL database initialized/checked successfully using SQLAlchemy.") logging.info("PostgreSQL database initialized/checked successfully using SQLAlchemy.")
@@ -83,8 +86,6 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
logging.warning(f"Failed to load setting overrides on startup: {e_overrides}") logging.warning(f"Failed to load setting overrides on startup: {e_overrides}")
async with session_factory() as session: async with session_factory() as session:
from sqlalchemy import text
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
try: try:

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