Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5892a72575 | ||
|
|
d299a3c524 | ||
|
|
1c1ef06e29 | ||
|
|
bea62d75a9 | ||
|
|
e998b9ddd1 | ||
|
|
2f192447ce | ||
|
|
e89ed870dc | ||
|
|
6a722bfa17 | ||
|
|
a96aaa763f | ||
|
|
bbe05d7f57 | ||
|
|
da28b69461 | ||
|
|
cd469ae2bb | ||
|
|
9af49453f8 | ||
|
|
e543665704 | ||
|
|
39c0696e53 | ||
|
|
c6cc4ab963 | ||
|
|
4dfe71f392 | ||
|
|
cf05c6580d | ||
|
|
d544ebd879 | ||
|
|
7e0e8ff319 | ||
|
|
613a9860a0 | ||
|
|
92f0277dad | ||
|
|
749693078b | ||
|
|
b566a34725 | ||
|
|
3ab6c18a76 | ||
|
|
52458eb223 | ||
|
|
790946d89e | ||
|
|
30c5d9ae11 | ||
|
|
12e60629fe | ||
|
|
6c0d2932c3 | ||
|
|
ce19c7e2d1 | ||
|
|
93c9dde572 | ||
|
|
d46324cb6a | ||
|
|
f153ca5bf4 | ||
|
|
62e950f5c4 | ||
|
|
2b8bcd10b8 | ||
|
|
4c2ee19957 | ||
|
|
8217d13cd6 | ||
|
|
a2a887b898 | ||
|
|
1620de9a01 | ||
|
|
b6c6887842 | ||
|
|
ef4b493e65 | ||
|
|
50dd1951c4 | ||
|
|
b3894e53e4 | ||
|
|
ded044b4c0 | ||
|
|
bce78c4f28 | ||
|
|
75586d8883 | ||
|
|
f2fc335221 | ||
|
|
1c9e55d797 | ||
|
|
25056602d8 | ||
|
|
bd2e67059f | ||
|
|
fe34edfe73 | ||
|
|
4706be53ab | ||
|
|
4bd547f06a | ||
|
|
0250264fa0 | ||
|
|
3aede8fe95 | ||
|
|
e90988ea5c | ||
|
|
69d3400310 | ||
|
|
6a44da1f31 | ||
|
|
77c8785ec0 | ||
|
|
f2c0a9f6d5 | ||
|
|
f77a6ea46d | ||
|
|
a92ad32b23 | ||
|
|
a6af8f8415 | ||
|
|
da9db7b7f8 | ||
|
|
11048a6ed8 | ||
|
|
c3381bdd31 | ||
|
|
53f1cec401 | ||
|
|
bf46a15446 | ||
|
|
0df52d0235 | ||
|
|
0c167c8f09 | ||
|
|
804ccdabec | ||
|
|
5833b18052 | ||
|
|
f30e729f1f | ||
|
|
1e63431ae3 | ||
|
|
072e7273c4 | ||
|
|
df079138ee | ||
|
|
87f32114d7 | ||
|
|
b330e604f6 | ||
|
|
827d69231c | ||
|
|
0623850c3f | ||
|
|
4c77d129e7 | ||
|
|
c68cb97964 | ||
|
|
59fa301344 | ||
|
|
23784d00fe | ||
|
|
17cff74b7c | ||
|
|
2a3a3c21ba | ||
|
|
ba7811621e | ||
|
|
340afe1b80 | ||
|
|
11b7823188 | ||
|
|
de1763cfe1 | ||
|
|
d30b069876 | ||
|
|
0d68da9624 | ||
|
|
4d857b386b | ||
|
|
be7a3bc153 | ||
|
|
5c71fc0de2 | ||
|
|
82cc33587c | ||
|
|
121f3c6ddf | ||
|
|
3410eeaec1 | ||
|
|
85ecd644b8 | ||
|
|
7cffb4667f | ||
|
|
0449ded505 | ||
|
|
e15cbffdb1 | ||
|
|
9674c674a2 | ||
|
|
bafc5f4709 | ||
|
|
a60de46173 | ||
|
|
1165dc3fa6 | ||
|
|
f5ab18a67b | ||
|
|
65db4aaff6 | ||
|
|
d9a23235e5 | ||
|
|
00b54e1f15 | ||
|
|
3c4ff66150 | ||
|
|
2b3078fcce | ||
|
|
6f4074e8d6 | ||
|
|
235ee25d9f | ||
|
|
d1b9990bac | ||
|
|
60d1ba4efc | ||
|
|
29b6e6eb24 | ||
|
|
aa65972483 | ||
|
|
ad275a7c83 | ||
|
|
02b57ead00 | ||
|
|
7f484fa7b3 | ||
|
|
3ea906a74d | ||
|
|
939c40ccbf | ||
|
|
f0eb291f56 | ||
|
|
46391b10e2 | ||
|
|
5918a6cc71 | ||
|
|
0eeabc7b3a | ||
|
|
31eb5c06ad | ||
|
|
60d8c297f9 | ||
|
|
dab70d5a97 | ||
|
|
520a6289a8 | ||
|
|
7adca53116 | ||
|
|
fbc3e6c084 | ||
|
|
19ba5c8f11 | ||
|
|
ce0d4dccf7 | ||
|
|
7c874cd4aa | ||
|
|
17224b4f74 | ||
|
|
40414264be | ||
|
|
d9a4a007e2 | ||
|
|
d3925cad22 | ||
|
|
443e2e62db | ||
|
|
9e61a3d8a8 |
@@ -13,9 +13,14 @@ scratch/
|
||||
*.local.*
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
docs-site/node_modules/
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/src/content/docs/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
|
||||
# CI
|
||||
@@ -30,10 +35,38 @@ deploy/compose/*.yml
|
||||
tmp/
|
||||
|
||||
# WebApp build artifacts (regenerated inside Docker)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
bot/app/web/templates/subscription_webapp.min.*.js
|
||||
bot/app/web/templates/subscription_webapp.*.css
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp.*.css.br
|
||||
bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.css
|
||||
bot/app/web/templates/subscription_webapp_admin.js
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
@@ -12,6 +12,12 @@ scratch/
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
# Documentation site build artifacts
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
bot/app/web/templates/subscription_webapp.js
|
||||
@@ -29,6 +35,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
@@ -45,6 +56,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
tmp
|
||||
.claude
|
||||
|
||||
@@ -63,3 +79,4 @@ locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
|
||||
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
|
||||
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
|
||||
|
||||
## Возможности
|
||||
|
||||
@@ -14,6 +14,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
|
||||
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
|
||||
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||
@@ -25,20 +26,25 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
|
||||
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
|
||||
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
|
||||
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
|
||||
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
|
||||
- ручная синхронизация пользователей и подписок с панелью.
|
||||
|
||||
## Документация
|
||||
|
||||
- [Настройка окружения](docs/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
|
||||
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
|
||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
|
||||
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
|
||||
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
|
||||
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
|
||||
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
|
||||
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
|
||||
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
|
||||
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
|
||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
||||
|
||||
## Совместимость
|
||||
|
||||
@@ -86,14 +92,15 @@ docker compose logs -f backend worker frontend
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку и уведомления через UI. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
touch data/locales-overrides.json
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
```
|
||||
@@ -107,18 +114,34 @@ docker compose up -d --build
|
||||
# Логи приложения
|
||||
docker compose logs -f backend worker frontend
|
||||
|
||||
# Запуск с Caddy
|
||||
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
|
||||
# Рекомендуемый продакшен-вариант с Caddy
|
||||
cd deploy/examples/caddy # или nginx, newt, no-proxy
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
docker compose up -d
|
||||
|
||||
# Запуск из готового образа
|
||||
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
|
||||
# Запуск из готового образа с конкретным тегом
|
||||
IMAGE_TAG=3.1.0 docker compose up -d
|
||||
```
|
||||
|
||||
GHCR image names for releases:
|
||||
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
|
||||
|
||||
Имена образов для релизов:
|
||||
|
||||
- `ghcr.io/3252a8/remnawave-minishop-backend`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-worker`
|
||||
- `ghcr.io/3252a8/remnawave-minishop-frontend`
|
||||
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||
|
||||
Сборка и публикация сразу в GHCR и Docker Hub:
|
||||
|
||||
```bash
|
||||
docker login ghcr.io
|
||||
docker login docker.io
|
||||
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
||||
```
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def build_core_services(
|
||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||
email_auth_service = EmailAuthService(settings)
|
||||
email_auth_service = EmailAuthService(settings, i18n)
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
|
||||
@@ -6,6 +6,7 @@ from bot.app.web.admin_api_impl import (
|
||||
_runtime as _runtime,
|
||||
ads as _ads,
|
||||
auth as _auth,
|
||||
backups as _backups,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
logs as _logs,
|
||||
@@ -19,6 +20,7 @@ from bot.app.web.admin_api_impl import (
|
||||
sync as _sync,
|
||||
tariffs as _tariffs,
|
||||
themes as _themes,
|
||||
translations as _translations,
|
||||
users as _users,
|
||||
)
|
||||
|
||||
@@ -35,9 +37,11 @@ _MODULES = (
|
||||
_broadcast,
|
||||
_sync,
|
||||
_ads,
|
||||
_backups,
|
||||
_settings,
|
||||
_tariffs,
|
||||
_themes,
|
||||
_translations,
|
||||
_panel,
|
||||
_routes,
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ from config.tariffs_config import TariffsConfig
|
||||
from db.dal import (
|
||||
ad_dal,
|
||||
app_settings_dal,
|
||||
locale_overrides_dal,
|
||||
message_log_dal,
|
||||
panel_sync_dal,
|
||||
payment_dal,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_restore_service import (
|
||||
BACKUP_UPLOAD_MAX_BYTES,
|
||||
BackupArchiveError,
|
||||
BackupArchiveInfo,
|
||||
BackupRestoreError,
|
||||
BackupRestoreService,
|
||||
)
|
||||
from bot.services.backup_worker import BackupWorker
|
||||
|
||||
|
||||
def _backup_archive_payload(archive) -> Dict[str, Any]:
|
||||
return archive.to_payload()
|
||||
|
||||
|
||||
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
|
||||
settings: Settings = request.app["settings"]
|
||||
service = BackupRestoreService(settings)
|
||||
backup_dir = service.backup_dir()
|
||||
temp_path: Optional[Path] = None
|
||||
|
||||
reader = await request.multipart()
|
||||
try:
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
|
||||
original_filename = part.filename or "backup.zip"
|
||||
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
|
||||
size = 0
|
||||
with temp_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = await part.read_chunk(size=1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > BACKUP_UPLOAD_MAX_BYTES:
|
||||
raise BackupArchiveError("Backup archive is too large")
|
||||
handle.write(chunk)
|
||||
if size <= 0:
|
||||
raise BackupArchiveError("Uploaded archive is empty")
|
||||
archive = service.import_uploaded_archive(temp_path, original_filename)
|
||||
temp_path = None
|
||||
return archive
|
||||
finally:
|
||||
if temp_path is not None and temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary backup upload %s", temp_path)
|
||||
|
||||
raise BackupArchiveError("file field is required")
|
||||
|
||||
|
||||
async def admin_backups_list_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
try:
|
||||
service = BackupRestoreService(settings)
|
||||
archives = service.list_archives()
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to list backup archives")
|
||||
return _error(500, "backup_list_failed", str(exc))
|
||||
return _ok(
|
||||
{
|
||||
"backup_dir": str(service.backup_dir()),
|
||||
"archives": [_backup_archive_payload(archive) for archive in archives],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_upload_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
content_type = (request.headers.get("Content-Type") or "").lower()
|
||||
if not content_type.startswith("multipart/form-data"):
|
||||
return _error(400, "invalid_backup_archive", "multipart file upload is required")
|
||||
try:
|
||||
archive = await _read_uploaded_backup_file(request)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except OSError as exc:
|
||||
logger.exception("Failed to save uploaded backup archive")
|
||||
return _error(500, "backup_upload_failed", str(exc))
|
||||
return _ok({"archive": _backup_archive_payload(archive)})
|
||||
|
||||
|
||||
async def admin_backups_create_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
bot = request.app["bot"]
|
||||
session_factory = request.app.get("async_session_factory")
|
||||
worker = BackupWorker(settings, bot, session_factory=session_factory)
|
||||
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_create_busy", "Backup or restore is already running")
|
||||
await worker.refresh_settings()
|
||||
result = await worker.create_and_send_backup(backup_type="manual")
|
||||
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Manual backup creation failed")
|
||||
return _error(500, "backup_create_failed", str(exc))
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"result": result.to_payload(),
|
||||
"archive": _backup_archive_payload(archive),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_backups_restore_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
archive_name = str(payload.get("archive_name") or "").strip()
|
||||
restore_database = bool(payload.get("restore_database"))
|
||||
restore_compose = bool(payload.get("restore_compose"))
|
||||
confirm = bool(payload.get("confirm"))
|
||||
if not confirm:
|
||||
return _error(400, "restore_confirmation_required")
|
||||
|
||||
service = BackupRestoreService(settings)
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(
|
||||
max(
|
||||
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
|
||||
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
|
||||
)
|
||||
),
|
||||
)
|
||||
try:
|
||||
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
|
||||
if not acquired:
|
||||
return _error(409, "backup_restore_busy", "Backup or restore is already running")
|
||||
result = await service.restore_archive(
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
except BackupArchiveError as exc:
|
||||
return _error(400, "invalid_backup_archive", str(exc))
|
||||
except BackupRestoreError as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
|
||||
logger.exception("Backup restore failed")
|
||||
return _error(500, "backup_restore_failed", str(exc))
|
||||
|
||||
if result.database_restored:
|
||||
try:
|
||||
from db import database_setup
|
||||
|
||||
if database_setup.async_engine is not None:
|
||||
await database_setup.async_engine.dispose()
|
||||
except Exception:
|
||||
logger.exception("Failed to dispose DB engine after backup restore")
|
||||
|
||||
return _ok({"result": result.to_payload()})
|
||||
@@ -234,6 +234,7 @@ def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
||||
"telegram_username": entry.telegram_username,
|
||||
"telegram_first_name": entry.telegram_first_name,
|
||||
"email": getattr(getattr(entry, "author_user", None), "email", None),
|
||||
"event_type": entry.event_type,
|
||||
"content": entry.content,
|
||||
"is_admin_event": bool(entry.is_admin_event),
|
||||
|
||||
@@ -32,6 +32,35 @@ async def admin_payments_list_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def admin_payment_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
try:
|
||||
payment_id = int(request.match_info["payment_id"])
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_payment", "Invalid payment id")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment:
|
||||
return _error(404, "not_found", "Payment not found")
|
||||
|
||||
payload = _serialize_payment(payment)
|
||||
payload.update(
|
||||
{
|
||||
"yookassa_payment_id": payment.yookassa_payment_id,
|
||||
"idempotence_key": payment.idempotence_key,
|
||||
"promo_code": (
|
||||
payment.promo_code_used.code if payment.promo_code_used is not None else None
|
||||
),
|
||||
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return _ok({"payment": payload})
|
||||
|
||||
|
||||
async def admin_payments_export_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
@@ -36,6 +36,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
|
||||
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
|
||||
|
||||
router.add_get("/api/admin/promos", admin_promos_list_route)
|
||||
@@ -65,6 +66,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
||||
router.add_get("/api/admin/translations", admin_translations_get_route)
|
||||
router.add_patch("/api/admin/translations", admin_translations_patch_route)
|
||||
|
||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
@@ -72,4 +75,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
|
||||
router.add_get("/api/admin/backups", admin_backups_list_route)
|
||||
router.add_post("/api/admin/backups/create", admin_backups_create_route)
|
||||
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
|
||||
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_admin_config_json,
|
||||
)
|
||||
|
||||
|
||||
async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
@@ -27,12 +33,25 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
override = overrides_by_key.get(key)
|
||||
value = current_value(settings, key)
|
||||
is_secret = bool(field.get("secret"))
|
||||
overridden = bool(override)
|
||||
source = None
|
||||
read_error = None
|
||||
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
|
||||
try:
|
||||
value, source = subscription_guides_admin_config_json(settings)
|
||||
overridden = source == "admin_json"
|
||||
except SubscriptionGuidesConfigError as exc:
|
||||
read_error = str(exc)
|
||||
response_field = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"overridden": bool(override),
|
||||
"overridden": overridden,
|
||||
"updated_at": override.get("updated_at") if override else None,
|
||||
}
|
||||
if source:
|
||||
response_field["source"] = source
|
||||
if read_error:
|
||||
response_field["read_error"] = read_error
|
||||
if is_secret:
|
||||
response_field["has_value"] = bool(value)
|
||||
webhook_path = str(response_field.get("webhook_path") or "").strip()
|
||||
@@ -60,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
if (
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON" in updates
|
||||
and not str(updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or "").strip()
|
||||
):
|
||||
updates = dict(updates)
|
||||
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
|
||||
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
|
||||
|
||||
result = await update_overrides(
|
||||
settings,
|
||||
@@ -74,32 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Bust the public webapp settings cache so users see new values immediately.
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
try:
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_all_webapp_user_caches
|
||||
|
||||
await invalidate_all_webapp_user_caches(settings, include_devices=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to invalidate WebApp user payload caches after settings update")
|
||||
if (
|
||||
"WEBAPP_LOGO_URL" in updates
|
||||
or "WEBAPP_LOGO_URL" in deletes
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in updates
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in deletes
|
||||
or "WEBAPP_FAVICON_URL" in updates
|
||||
or "WEBAPP_FAVICON_URL" in deletes
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
|
||||
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in updates
|
||||
or "WEBAPP_LOGO_FAVICON_URL" in deletes
|
||||
):
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
|
||||
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
|
||||
@@ -55,9 +56,6 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Failed to write tariffs config to %s", path)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -65,11 +66,9 @@ def _bump_theme_asset_versions(
|
||||
previous_theme = previous_by_key.get(key)
|
||||
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
|
||||
current_version = int(theme.get("assets_version") or 1)
|
||||
theme_changed = (
|
||||
previous_theme is None
|
||||
or _theme_payload_for_version_compare(theme)
|
||||
!= _theme_payload_for_version_compare(previous_theme)
|
||||
)
|
||||
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
|
||||
theme
|
||||
) != _theme_payload_for_version_compare(previous_theme)
|
||||
if theme_changed or (default_changed and key == config.default_theme):
|
||||
theme["assets_version"] = max(previous_version + 1, current_version, 1)
|
||||
elif previous_version > current_version:
|
||||
@@ -213,12 +212,7 @@ async def _persist_appearance_upload(
|
||||
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||
return False
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
request.app["webapp_logo_cache"] = None
|
||||
prune_unused_appearance_assets(settings)
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
|
||||
return True
|
||||
|
||||
|
||||
@@ -484,10 +478,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
|
||||
from bot.services.locale_override_service import (
|
||||
LOCALE_OVERRIDES_PATH,
|
||||
audience_for_locale_key,
|
||||
group_id_for_locale_key,
|
||||
locale_group_catalog,
|
||||
load_locale_overrides,
|
||||
update_locale_overrides,
|
||||
)
|
||||
|
||||
|
||||
def _locale_languages(
|
||||
i18n: JsonI18n,
|
||||
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_languages = set((i18n.base_locales_data or {}).keys())
|
||||
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
|
||||
override_languages.update((i18n.locale_overrides or {}).keys())
|
||||
return locale_language_options(
|
||||
base_languages | override_languages,
|
||||
base_languages=base_languages,
|
||||
)
|
||||
|
||||
|
||||
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
|
||||
result: Dict[Tuple[str, str], Dict] = {}
|
||||
for entry in overrides:
|
||||
lang = str(entry.get("lang") or "")
|
||||
raw_key = str(entry.get("key") or "")
|
||||
key = resolve_locale_key(raw_key)
|
||||
if lang and key:
|
||||
if raw_key != key and (lang, key) in result:
|
||||
continue
|
||||
result[(lang, key)] = entry
|
||||
return result
|
||||
|
||||
|
||||
def _admin_translations_payload(
|
||||
i18n: JsonI18n,
|
||||
overrides: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
base_data = i18n.base_locales_data or i18n.locales_data or {}
|
||||
effective_data = i18n.locales_data or {}
|
||||
override_meta = _locale_override_meta_map(overrides)
|
||||
language_items = _locale_languages(i18n, overrides)
|
||||
languages = [item["code"] for item in language_items]
|
||||
all_keys = sorted(
|
||||
{key for messages in base_data.values() for key in messages.keys()}
|
||||
| {key for _, key in override_meta.keys()}
|
||||
)
|
||||
|
||||
groups_by_id = {
|
||||
group["id"]: {
|
||||
**group,
|
||||
"items": [],
|
||||
}
|
||||
for group in locale_group_catalog()
|
||||
}
|
||||
|
||||
for key in all_keys:
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
for lang in languages:
|
||||
meta = override_meta.get((lang, key))
|
||||
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
|
||||
values[lang] = {
|
||||
"base": base_data.get(lang, {}).get(key, ""),
|
||||
"fallback": fallback_base,
|
||||
"effective": effective_data.get(lang, {}).get(key, ""),
|
||||
"override": meta.get("value") if meta else "",
|
||||
"overridden": bool(meta),
|
||||
"updated_at": meta.get("updated_at") if meta else None,
|
||||
"updated_by": meta.get("updated_by") if meta else None,
|
||||
}
|
||||
group_id = group_id_for_locale_key(key)
|
||||
groups_by_id.setdefault(
|
||||
group_id,
|
||||
{"id": group_id, "title": group_id, "description": "", "items": []},
|
||||
)
|
||||
groups_by_id[group_id]["items"].append(
|
||||
{
|
||||
"key": key,
|
||||
"audience": audience_for_locale_key(key),
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
|
||||
groups = [group for group in groups_by_id.values() if group["items"]]
|
||||
return {
|
||||
"languages": language_items,
|
||||
"groups": groups,
|
||||
"path": str(LOCALE_OVERRIDES_PATH),
|
||||
"override_count": len(overrides),
|
||||
}
|
||||
|
||||
|
||||
async def admin_translations_get_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
await load_locale_overrides(i18n, async_session_factory)
|
||||
async with async_session_factory() as session:
|
||||
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
|
||||
|
||||
return _ok(_admin_translations_payload(i18n, overrides))
|
||||
|
||||
|
||||
async def admin_translations_patch_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||
if i18n is None:
|
||||
return _error(503, "i18n_unavailable")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
payload = await _read_json(request)
|
||||
updates = payload.get("updates") or {}
|
||||
deletes = payload.get("deletes") or []
|
||||
if not isinstance(updates, dict):
|
||||
return _error(400, "invalid_updates")
|
||||
if not isinstance(deletes, list):
|
||||
return _error(400, "invalid_deletes")
|
||||
|
||||
result = await update_locale_overrides(
|
||||
i18n,
|
||||
async_session_factory,
|
||||
updates=updates,
|
||||
deletes=deletes,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||
status=400,
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"applied": result.get("applied", 0),
|
||||
"reverted": result.get("reverted", 0),
|
||||
"file_written": result.get("file_written", False),
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,16 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import (
|
||||
_build_admin_webapp_referral_link,
|
||||
_error,
|
||||
_ok,
|
||||
_premium_traffic_list_payload,
|
||||
_read_json,
|
||||
_serialize_payment,
|
||||
_serialize_subscription,
|
||||
_serialize_user,
|
||||
)
|
||||
|
||||
import hashlib
|
||||
from html import escape as html_escape
|
||||
@@ -861,18 +872,62 @@ async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_id,
|
||||
user=user,
|
||||
)
|
||||
if panel_user_uuids and panel_service is None:
|
||||
await session.rollback()
|
||||
return _error(503, "panel_service_unavailable")
|
||||
|
||||
for panel_uuid in panel_user_uuids:
|
||||
try:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(
|
||||
panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Admin webapp failed to delete panel user %s for user %s: %s",
|
||||
panel_uuid,
|
||||
target_id,
|
||||
exc,
|
||||
)
|
||||
await session.rollback()
|
||||
return _error(502, "panel_delete_failed", str(exc))
|
||||
|
||||
if not panel_deleted:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
502,
|
||||
"panel_delete_failed",
|
||||
f"Failed to delete panel user {panel_uuid}",
|
||||
)
|
||||
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
await message_log_dal.create_message_log(
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"user_id": actor_id if actor_id != target_id else None,
|
||||
"event_type": "admin_delete_user_webapp",
|
||||
"content": f"Deleted user_id={target_id}",
|
||||
"content": (
|
||||
f"Deleted user_id={target_id}; "
|
||||
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_all_webapp_user_payloads,
|
||||
reset_subscription_guides_cache,
|
||||
reset_webapp_settings_cache,
|
||||
)
|
||||
|
||||
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"WEBAPP_TITLE",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
}
|
||||
)
|
||||
|
||||
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"MY_DEVICES_SECTION_ENABLED",
|
||||
"USER_HWID_DEVICE_LIMIT",
|
||||
"USER_TRAFFIC_LIMIT_GB",
|
||||
"USER_TRAFFIC_STRATEGY",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def changed_setting_keys(
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
) -> set[str]:
|
||||
keys = {str(key) for key in (updates or {}).keys()}
|
||||
keys.update(str(key) for key in (deletes or []) if key is not None)
|
||||
return keys
|
||||
|
||||
|
||||
async def refresh_webapp_runtime_after_settings_change(
|
||||
request: Any,
|
||||
*,
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
include_user_payloads: bool = True,
|
||||
) -> None:
|
||||
settings = request.app["settings"]
|
||||
keys = changed_setting_keys(updates, deletes)
|
||||
|
||||
reset_webapp_settings_cache(request.app)
|
||||
reset_subscription_guides_cache(request.app)
|
||||
|
||||
if include_user_payloads:
|
||||
await invalidate_all_webapp_user_payloads(
|
||||
settings,
|
||||
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
|
||||
)
|
||||
|
||||
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
@@ -16,7 +16,7 @@ from typing import Any, List, Optional, Tuple
|
||||
@dataclass(frozen=True)
|
||||
class SettingField:
|
||||
key: str
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon"
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
|
||||
section: str
|
||||
label: str
|
||||
description: str = ""
|
||||
@@ -34,6 +34,13 @@ class SettingField:
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── General ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE",
|
||||
"string",
|
||||
"general",
|
||||
"Web App title",
|
||||
placeholder="My subscription",
|
||||
),
|
||||
SettingField(
|
||||
"DEFAULT_LANGUAGE",
|
||||
"string",
|
||||
@@ -117,9 +124,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
subsection="Remnawave",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"url",
|
||||
@@ -160,6 +164,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
|
||||
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
|
||||
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Embedded install guides",
|
||||
"Open install instructions inside the Web App instead of an external connect page.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Open install guides from bot",
|
||||
(
|
||||
"Use the Telegram Mini App install screen for bot connect buttons and show "
|
||||
"public install guide links."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Use Remnawave Panel config",
|
||||
(
|
||||
"Fetch Subscription Page config from Remnawave Panel by the user's "
|
||||
"subscription short UUID."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Enable admin JSON override",
|
||||
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PATH",
|
||||
"string",
|
||||
"subscription_guides",
|
||||
"Subscription Page config path",
|
||||
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
|
||||
placeholder="data/subpage-config/multiapp.json",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON",
|
||||
"json",
|
||||
"subscription_guides",
|
||||
"Subscription Page config JSON",
|
||||
(
|
||||
"Optional admin JSON override. It is applied only when the JSON override "
|
||||
"switch is enabled."
|
||||
),
|
||||
placeholder='{\n "version": "1"\n}',
|
||||
),
|
||||
# ─── Subscription periods & pricing ────────────────────────────
|
||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
|
||||
@@ -177,37 +234,46 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||
),
|
||||
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"pricing",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую, например: severpay,freekassa,yookassa,heleket",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"payments",
|
||||
"Показывать описание подписки",
|
||||
"Текст появится перед выбором срока покупки или продления.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
|
||||
"text",
|
||||
"pricing",
|
||||
"payments",
|
||||
"Описание подписки (RU)",
|
||||
"Русская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
|
||||
"text",
|
||||
"pricing",
|
||||
"payments",
|
||||
"Описание подписки (EN)",
|
||||
"Английская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
||||
SettingField(
|
||||
"STARS_ADMIN_ONLY_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Telegram Stars admin-only",
|
||||
(
|
||||
"Shows Telegram Stars only to users from ADMIN_IDS. "
|
||||
"Payment callbacks remain active for admin test payments."
|
||||
),
|
||||
subsection="common",
|
||||
i18n_label_key="admin_settings_provider_admin_only_label",
|
||||
i18n_description_key="admin_settings_provider_admin_only_description",
|
||||
),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
@@ -217,10 +283,48 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
||||
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
|
||||
SettingField(
|
||||
"TRIAL_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Триал включён",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Длительность триала (дней)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_LIMIT_GB",
|
||||
"float",
|
||||
"pricing",
|
||||
"Лимит трафика триала (ГБ)",
|
||||
optional=False,
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_STRATEGY",
|
||||
"string",
|
||||
"pricing",
|
||||
"Стратегия сброса трафика триала",
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
"string",
|
||||
"pricing",
|
||||
"Internal Squads для триала",
|
||||
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
|
||||
subsection="trial",
|
||||
),
|
||||
# ─── Referral program ──────────────────────────────────────────
|
||||
SettingField(
|
||||
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
|
||||
@@ -305,6 +409,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"За сколько дней предупреждать",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
|
||||
"int",
|
||||
"notifications",
|
||||
"За сколько часов предупреждать",
|
||||
min=0,
|
||||
max=23,
|
||||
),
|
||||
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
|
||||
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
|
||||
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
|
||||
@@ -340,6 +452,55 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"ID треда поддержки",
|
||||
"Тред лог-чата для уведомлений о тикетах поддержки.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Бэкапы включены",
|
||||
"Worker будет периодически собирать ZIP-архив и отправлять его в Telegram.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_CHAT_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID чата для бэкапов",
|
||||
"Куда отправлять ZIP-архивы. Если пусто, используется LOG_CHAT_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_THREAD_ID",
|
||||
"int",
|
||||
"backups",
|
||||
"ID треда для бэкапов",
|
||||
"Необязательный topic/thread ID. Если пусто, используется LOG_THREAD_ID.",
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"int",
|
||||
"backups",
|
||||
"Период бэкапов (сек.)",
|
||||
"По умолчанию 3600: запуск на границе часа (12:00, 13:00 и т.д.).",
|
||||
optional=False,
|
||||
min=60,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"int",
|
||||
"backups",
|
||||
"Сколько архивов хранить",
|
||||
"Сколько последних ZIP-архивов оставлять в data/backups на сервере.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
"bool",
|
||||
"backups",
|
||||
"Добавлять compose-папку",
|
||||
(
|
||||
"Добавляет snapshot /app/compose-source. Если папка не смонтирована, "
|
||||
"бэкап БД все равно будет создан."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUPPORT_TICKETS_ENABLED",
|
||||
"bool",
|
||||
@@ -454,7 +615,21 @@ def manifest_keys() -> List[str]:
|
||||
def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
"""Coerce a value coming from JSON to the type declared by the field."""
|
||||
|
||||
if field.type == "json":
|
||||
if raw is None:
|
||||
return ""
|
||||
text = raw if isinstance(raw, str) else str(raw)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return ""
|
||||
from config.subscription_guides_config import validate_subscription_guides_config_text
|
||||
|
||||
validate_subscription_guides_config_text(text)
|
||||
return text
|
||||
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
if not field.optional:
|
||||
raise ValueError(f"{field.key}: value required")
|
||||
return None
|
||||
|
||||
if field.type == "bool":
|
||||
@@ -510,19 +685,27 @@ def manifest_payload() -> List[dict]:
|
||||
from bot.payment_providers import (
|
||||
find_manifest_owner,
|
||||
manifest_field_default,
|
||||
provider_admin_only_pairs,
|
||||
provider_webhook_metadata,
|
||||
)
|
||||
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"pricing": 3,
|
||||
"pricing": 11,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
"referral": 6,
|
||||
"notifications": 7,
|
||||
"support": 8,
|
||||
"devices": 9,
|
||||
"backups": 9,
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
for public_key, admin_key in provider_admin_only_pairs()
|
||||
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
|
||||
}
|
||||
items: List[dict] = []
|
||||
for field in aggregated_manifest():
|
||||
@@ -565,6 +748,12 @@ def manifest_payload() -> List[dict]:
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.min is not None:
|
||||
item["min"] = field.min
|
||||
if field.max is not None:
|
||||
item["max"] = field.max
|
||||
if field.key in exclusive_map:
|
||||
item["mutually_exclusive_key"] = exclusive_map[field.key]
|
||||
if default_value is not None:
|
||||
item["default"] = default_value
|
||||
if webhook_metadata:
|
||||
|
||||
@@ -11,6 +11,7 @@ from bot.app.web.webapp import (
|
||||
billing as _billing,
|
||||
common as _common,
|
||||
devices as _devices,
|
||||
guides as _guides,
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
@@ -27,6 +28,7 @@ _MODULES = (
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_guides,
|
||||
_support,
|
||||
_routes,
|
||||
_application,
|
||||
|
||||
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,219 @@
|
||||
<!doctype html>
|
||||
<html lang="__LANG__">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>__PAGE_TITLE__</title>
|
||||
<style nonce="__NONCE__">
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: #0b1017;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #aeb8c5;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: #14b86f;
|
||||
color: #03120b;
|
||||
padding: 0 18px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
border-color: #2d3847;
|
||||
background: transparent;
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"] {
|
||||
pointer-events: none;
|
||||
background: #344052;
|
||||
color: #aeb8c5;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 id="title"></h1>
|
||||
<p id="status"></p>
|
||||
<div class="actions">
|
||||
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
|
||||
<button id="close-button" class="button secondary" type="button" hidden></button>
|
||||
</div>
|
||||
</main>
|
||||
<script nonce="__NONCE__">
|
||||
(() => {
|
||||
const messages = __MESSAGES_JSON__;
|
||||
const titleEl = document.getElementById("title");
|
||||
const statusEl = document.getElementById("status");
|
||||
const openLink = document.getElementById("open-link");
|
||||
const closeButton = document.getElementById("close-button");
|
||||
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const target = String(params.get("url") || "").trim();
|
||||
const isUnsafe =
|
||||
!target ||
|
||||
hasControlChars(target) ||
|
||||
/^(?:javascript|data|vbscript|https?):/i.test(target);
|
||||
let attempted = false;
|
||||
let pageLeft = false;
|
||||
let state = "opening";
|
||||
|
||||
function hasControlChars(value) {
|
||||
return Array.from(String(value || "")).some((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
function text(key, fallback) {
|
||||
const value = messages && messages[key];
|
||||
return typeof value === "string" && value ? value : fallback;
|
||||
}
|
||||
|
||||
function tryCloseWindow() {
|
||||
try {
|
||||
window.close();
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
}
|
||||
|
||||
function render(nextState) {
|
||||
state = nextState;
|
||||
if (nextState === "unavailable") {
|
||||
titleEl.textContent = text("unavailableTitle", "App link unavailable");
|
||||
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.setAttribute("aria-disabled", "true");
|
||||
openLink.removeAttribute("href");
|
||||
closeButton.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState === "done") {
|
||||
titleEl.textContent = text("doneTitle", "Settings added");
|
||||
statusEl.textContent = text("doneHint", "You can close this window.");
|
||||
openLink.textContent = text("retryButton", "Open again");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.textContent = text("closeButton", "Close window");
|
||||
closeButton.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
titleEl.textContent = text("title", "Opening app");
|
||||
statusEl.textContent =
|
||||
nextState === "manual"
|
||||
? text("manualHint", "If the app did not open automatically, tap the button below.")
|
||||
: text("hint", "Opening the app on this device...");
|
||||
openLink.textContent = text("button", "Open app");
|
||||
openLink.removeAttribute("aria-disabled");
|
||||
openLink.href = target;
|
||||
closeButton.hidden = true;
|
||||
}
|
||||
|
||||
function markDone() {
|
||||
if (state === "done" || isUnsafe) return;
|
||||
render("done");
|
||||
window.setTimeout(tryCloseWindow, 120);
|
||||
}
|
||||
|
||||
function notePageLeft() {
|
||||
if (!attempted) return;
|
||||
pageLeft = true;
|
||||
window.setTimeout(markDone, 900);
|
||||
}
|
||||
|
||||
function openTarget() {
|
||||
if (isUnsafe) return;
|
||||
attempted = true;
|
||||
pageLeft = false;
|
||||
render("opening");
|
||||
window.location.href = target;
|
||||
window.setTimeout(() => {
|
||||
if (state === "opening" && !pageLeft) render("manual");
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
if (isUnsafe) {
|
||||
render("unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
openLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openTarget();
|
||||
});
|
||||
closeButton.addEventListener("click", () => {
|
||||
tryCloseWindow();
|
||||
render("done");
|
||||
});
|
||||
window.addEventListener("pagehide", notePageLeft);
|
||||
window.addEventListener("blur", notePageLeft);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!attempted) return;
|
||||
if (document.hidden) {
|
||||
pageLeft = true;
|
||||
} else if (pageLeft) {
|
||||
markDone();
|
||||
}
|
||||
});
|
||||
|
||||
render("opening");
|
||||
window.setTimeout(openTarget, 80);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,7 +8,20 @@
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#03070b" />
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any" />
|
||||
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
|
||||
<link
|
||||
id="app-apple-touch-icon"
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon-precomposed"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon-precomposed.png"
|
||||
/>
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||
<style>
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(255, 255, 255, 0.08);
|
||||
--surface-muted: #0a0a0a;
|
||||
--surface-subtle: #000000;
|
||||
--surface-subtle-border: #ffffff;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.85);
|
||||
--nav-bg: #000000;
|
||||
@@ -119,6 +120,8 @@
|
||||
/* ---------- Panels / cards ---------- */
|
||||
|
||||
.theme-key-ascii .card,
|
||||
.theme-key-ascii .trial-card-facts span,
|
||||
.theme-key-ascii .trial-activation-facts div,
|
||||
.theme-key-ascii .period-card,
|
||||
.theme-key-ascii .method-card,
|
||||
.theme-key-ascii .settings-row,
|
||||
@@ -149,6 +152,12 @@
|
||||
.theme-key-ascii .ticket-message-avatar,
|
||||
.theme-key-ascii .ticket-message-bubble,
|
||||
.theme-key-ascii .ticket-composer,
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .install-step,
|
||||
.theme-key-ascii .install-subscription-card,
|
||||
.theme-key-ascii .install-qr-wrap,
|
||||
.theme-key-ascii .install-loading,
|
||||
.theme-key-ascii .admin-sidebar,
|
||||
.theme-key-ascii .admin-header,
|
||||
.theme-key-ascii .admin-card,
|
||||
@@ -183,6 +192,8 @@
|
||||
.theme-key-ascii .support-new-ticket-button,
|
||||
.theme-key-ascii .support-select-trigger,
|
||||
.theme-key-ascii .support-status-tabs-trigger,
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .admin-btn,
|
||||
.theme-key-ascii .admin-chip,
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
@@ -230,6 +241,7 @@
|
||||
.theme-key-ascii .support-status-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .support-select-item[data-highlighted],
|
||||
.theme-key-ascii .support-select-item[data-selected],
|
||||
.theme-key-ascii .install-app-button.active,
|
||||
.theme-key-ascii .admin-nav-item.active,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active {
|
||||
@@ -259,6 +271,9 @@
|
||||
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-ascii .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-ascii .language-select-trigger:focus-visible,
|
||||
.theme-key-ascii .install-platform-trigger:focus-visible,
|
||||
.theme-key-ascii .install-platform-trigger[data-state="open"],
|
||||
.theme-key-ascii .install-app-button:focus-visible,
|
||||
.theme-key-ascii .bottom-nav button:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 1px;
|
||||
@@ -305,12 +320,22 @@
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-ascii .trial-offer-card,
|
||||
.theme-key-ascii .trial-card-facts span,
|
||||
.theme-key-ascii .trial-activation-card,
|
||||
.theme-key-ascii .trial-activation-facts div,
|
||||
.theme-key-ascii .activation-success-dialog,
|
||||
.theme-key-ascii .subscription-purchase-description,
|
||||
.theme-key-ascii .support-create-panel,
|
||||
.theme-key-ascii .ticket-composer {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .trial-card-head > svg,
|
||||
.theme-key-ascii .dialog-title-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .support-heading-icon,
|
||||
.theme-key-ascii .support-new-ticket-icon,
|
||||
.theme-key-ascii .support-empty-state svg,
|
||||
@@ -412,6 +437,7 @@
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-content,
|
||||
body:has(.theme-key-ascii) .install-platform-content,
|
||||
body:has(.theme-key-ascii) .field-error-tooltip {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
@@ -420,19 +446,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
|
||||
box-shadow: 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item {
|
||||
body:has(.theme-key-ascii) .support-select-item,
|
||||
body:has(.theme-key-ascii) .install-platform-item {
|
||||
border-radius: 0;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected] {
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
|
||||
background: #ffffff;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
@@ -695,7 +726,8 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
.theme-key-ascii .admin-btn-primary svg.lucide,
|
||||
.theme-key-ascii .admin-nav-item.active svg.lucide,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
|
||||
.theme-key-ascii .install-app-button.active svg.lucide {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
@@ -1044,12 +1076,16 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
.theme-key-ascii :is(
|
||||
.card, .dialog-card, .toast,
|
||||
.btn, .input,
|
||||
.trial-card-facts span, .trial-activation-facts div,
|
||||
.period-card, .method-card, .settings-row, .option-row,
|
||||
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
|
||||
.topup-carryover-note, .subscription-purchase-description,
|
||||
.language-select-content, .language-select-item,
|
||||
.language-select-trigger, .bottom-nav, .bottom-nav button,
|
||||
.link-button,
|
||||
.install-platform-trigger, .install-platform-content, .install-platform-item,
|
||||
.install-app-button, .install-step, .install-subscription-card,
|
||||
.install-qr-wrap, .install-subscription-header-icon, .install-loading,
|
||||
.support-overview-card, .support-list-card, .support-ticket-card,
|
||||
.support-conversation-card, .support-new-ticket-button,
|
||||
.support-create-panel, .support-select-trigger, .support-select-content,
|
||||
@@ -1083,6 +1119,9 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
|
||||
.admin-panel-dash-card,
|
||||
.admin-select-trigger, .admin-select-content,
|
||||
.install-platform-trigger, .install-platform-content,
|
||||
.install-app-button, .install-step, .install-subscription-card,
|
||||
.install-qr-wrap, .install-loading,
|
||||
.admin-cn-card,
|
||||
.admin-input, .admin-textarea, .admin-btn,
|
||||
.admin-nav-item, .admin-tabs-trigger
|
||||
@@ -1096,6 +1135,71 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ---------- Install guide theme surfaces ---------- */
|
||||
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .install-step,
|
||||
.theme-key-ascii .install-subscription-card,
|
||||
.theme-key-ascii .install-qr-wrap,
|
||||
.theme-key-ascii .install-loading,
|
||||
body:has(.theme-key-ascii) .install-platform-content {
|
||||
border: 1px solid #ffffff !important;
|
||||
border-radius: 0 !important;
|
||||
background: #000000 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-platform-trigger:hover,
|
||||
.theme-key-ascii .install-app-button:hover:not(:disabled) {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-app-button.active,
|
||||
.theme-key-ascii .install-app-button.active:hover:not(:disabled),
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
border-color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-app-button.active svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-step:hover,
|
||||
.theme-key-ascii .install-subscription-card:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-step-icon,
|
||||
.theme-key-ascii .install-subscription-header-icon {
|
||||
border: 1px solid currentColor !important;
|
||||
background: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-qr-divider {
|
||||
color: #ffffff !important;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-feature-star.attention-dot {
|
||||
background: #ffffff !important;
|
||||
animation: ascii-caret 1s steps(1) infinite !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-loading .ui-spinner {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Console-style tables: cell borders, header underline,
|
||||
* row separator using dashed line.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"assets_version": 4,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
|
||||
--surface-hover: rgba(15, 23, 42, 0.045);
|
||||
--surface-muted: rgba(15, 23, 42, 0.035);
|
||||
--surface-subtle: rgba(15, 23, 42, 0.025);
|
||||
--surface-subtle-border: rgba(15, 23, 42, 0.1);
|
||||
--overlay-scrim: rgba(15, 23, 42, 0.34);
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
@@ -109,6 +110,26 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* New user-facing activation surfaces */
|
||||
.theme-key-light .trial-offer-card,
|
||||
.theme-key-light .trial-activation-card,
|
||||
.theme-key-light .activation-success-dialog {
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-head > svg,
|
||||
.theme-key-light .dialog-title-icon {
|
||||
color: color-mix(in srgb, var(--accent) 54%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-facts span,
|
||||
.theme-key-light .trial-activation-facts div {
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
background: rgba(15, 23, 42, 0.025);
|
||||
}
|
||||
|
||||
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
|
||||
.theme-key-light .admin-revenue-svg-frame {
|
||||
background: #ffffff;
|
||||
@@ -129,3 +150,57 @@
|
||||
.theme-key-light .bonus-card-head > svg {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
/* Install guide theme surfaces */
|
||||
|
||||
.theme-key-light .install-platform-trigger,
|
||||
.theme-key-light .install-app-button,
|
||||
.theme-key-light .install-step,
|
||||
.theme-key-light .install-subscription-card,
|
||||
.theme-key-light .install-qr-wrap {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
|
||||
}
|
||||
|
||||
.theme-key-light .install-app-button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .install-platform-trigger:focus-visible,
|
||||
.theme-key-light .install-platform-trigger[data-state="open"],
|
||||
.theme-key-light .install-app-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-content {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.14);
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .install-step-icon,
|
||||
.theme-key-light .install-subscription-header-icon {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .install-qr-divider {
|
||||
color: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.theme-key-light .install-feature-star.attention-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.theme-key-light .install-loading .ui-spinner {
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 2,
|
||||
"assets_version": 3,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(0, 0, 128, 0.14);
|
||||
--surface-muted: #c0c0c0;
|
||||
--surface-subtle: #dfdfdf;
|
||||
--surface-subtle-border: #808080;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.35);
|
||||
--nav-bg: #c0c0c0;
|
||||
@@ -138,12 +139,15 @@
|
||||
.theme-key-windows95 svg.lucide-megaphone,
|
||||
.theme-key-windows95 svg.lucide-message-square,
|
||||
.theme-key-windows95 svg.lucide-message-square-plus,
|
||||
.theme-key-windows95 svg.lucide-monitor,
|
||||
.theme-key-windows95 svg.lucide-paintbrush,
|
||||
.theme-key-windows95 svg.lucide-plus,
|
||||
.theme-key-windows95 svg.lucide-qr-code,
|
||||
.theme-key-windows95 svg.lucide-refresh-cw,
|
||||
.theme-key-windows95 svg.lucide-save,
|
||||
.theme-key-windows95 svg.lucide-search,
|
||||
.theme-key-windows95 svg.lucide-send,
|
||||
.theme-key-windows95 svg.lucide-share-2,
|
||||
.theme-key-windows95 svg.lucide-settings,
|
||||
.theme-key-windows95 svg.lucide-shield,
|
||||
.theme-key-windows95 svg.lucide-sliders,
|
||||
@@ -273,6 +277,10 @@
|
||||
--win95-button-icon: var(--win95-icon-send);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-monitor {
|
||||
--win95-button-icon: var(--win95-icon-dashboard);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-paintbrush {
|
||||
--win95-button-icon: var(--win95-icon-paintbrush);
|
||||
}
|
||||
@@ -281,6 +289,10 @@
|
||||
--win95-button-icon: var(--win95-icon-folder);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-qr-code {
|
||||
--win95-button-icon: var(--win95-icon-key);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-refresh-cw {
|
||||
--win95-button-icon: var(--win95-icon-refresh);
|
||||
}
|
||||
@@ -293,6 +305,10 @@
|
||||
--win95-button-icon: var(--win95-icon-search);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-share-2 {
|
||||
--win95-button-icon: var(--win95-icon-send);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-settings {
|
||||
--win95-button-icon: var(--win95-icon-settings);
|
||||
}
|
||||
@@ -371,12 +387,15 @@
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -425,12 +444,15 @@
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -488,7 +510,13 @@
|
||||
.theme-key-windows95 .support-message-scroll,
|
||||
.theme-key-windows95 .ticket-message-avatar,
|
||||
.theme-key-windows95 .ticket-message-bubble,
|
||||
.theme-key-windows95 .ticket-composer {
|
||||
.theme-key-windows95 .ticket-composer,
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button,
|
||||
.theme-key-windows95 .install-step,
|
||||
.theme-key-windows95 .install-subscription-card,
|
||||
.theme-key-windows95 .install-qr-wrap,
|
||||
.theme-key-windows95 .install-loading {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
@@ -510,6 +538,7 @@
|
||||
|
||||
body:has(.theme-key-windows95) .language-select-content,
|
||||
body:has(.theme-key-windows95) .support-select-content,
|
||||
body:has(.theme-key-windows95) .install-platform-content,
|
||||
body:has(.theme-key-windows95) .field-error-tooltip,
|
||||
body:has(.theme-key-windows95) .admin-select-content {
|
||||
border-width: 2px;
|
||||
@@ -525,18 +554,23 @@ body:has(.theme-key-windows95) .admin-select-content {
|
||||
|
||||
body:has(.theme-key-windows95) .language-select-item,
|
||||
body:has(.theme-key-windows95) .support-select-item,
|
||||
body:has(.theme-key-windows95) .install-platform-item,
|
||||
body:has(.theme-key-windows95) .admin-select-item {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .support-select-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected] {
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
background: #000080;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg,
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected] svg,
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] svg {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
@@ -560,7 +594,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .link-button,
|
||||
.theme-key-windows95 .support-new-ticket-button,
|
||||
.theme-key-windows95 .support-select-trigger,
|
||||
.theme-key-windows95 .support-status-tabs-trigger {
|
||||
.theme-key-windows95 .support-status-tabs-trigger,
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button {
|
||||
min-height: 34px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
@@ -625,13 +661,17 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .support-new-ticket-button.active,
|
||||
.theme-key-windows95 .support-status-tabs-trigger[data-state="active"],
|
||||
.theme-key-windows95 .support-select-item[data-highlighted],
|
||||
.theme-key-windows95 .support-select-item[data-selected] {
|
||||
.theme-key-windows95 .support-select-item[data-selected],
|
||||
.theme-key-windows95 .install-app-button.active {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-windows95 .trial-offer-card,
|
||||
.theme-key-windows95 .trial-activation-card,
|
||||
.theme-key-windows95 .activation-success-dialog,
|
||||
.theme-key-windows95 .subscription-purchase-description,
|
||||
.theme-key-windows95 .support-create-panel,
|
||||
.theme-key-windows95 .ticket-composer,
|
||||
@@ -639,6 +679,20 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .trial-card-facts span,
|
||||
.theme-key-windows95 .trial-activation-facts div {
|
||||
border: 2px solid;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
background: #dfdfdf;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .dialog-title-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .support-heading-icon,
|
||||
.theme-key-windows95 .support-new-ticket-icon,
|
||||
.theme-key-windows95 .support-empty-state svg,
|
||||
@@ -718,6 +772,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
|
||||
.theme-key-windows95 .ticket-composer:focus-within,
|
||||
.theme-key-windows95 .support-select-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger[data-state="open"],
|
||||
.theme-key-windows95 .install-app-button:focus-visible,
|
||||
.theme-key-windows95 .ticket-card:focus-visible,
|
||||
.theme-key-windows95 .support-status-tabs-trigger:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
@@ -731,6 +788,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* ---------- Install guide theme surfaces ---------- */
|
||||
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button {
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-platform-trigger:hover,
|
||||
.theme-key-windows95 .install-app-button:hover:not(:disabled):not(.active) {
|
||||
background: #dfdfdf;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-app-button.active,
|
||||
.theme-key-windows95 .install-app-button.active:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #000000,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step,
|
||||
.theme-key-windows95 .install-subscription-card,
|
||||
.theme-key-windows95 .install-qr-wrap,
|
||||
.theme-key-windows95 .install-loading {
|
||||
background: #c0c0c0;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step:hover,
|
||||
.theme-key-windows95 .install-subscription-card:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step-icon,
|
||||
.theme-key-windows95 .install-subscription-header-icon {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #dfdfdf;
|
||||
color: var(--accent);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #ffffff,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .install-platform-content {
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
background: var(--accent);
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-qr-divider {
|
||||
color: #404040;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-feature-star.attention-dot {
|
||||
background: #ffff00 !important;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: 1px 1px 0 #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-loading .ui-spinner {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .card-heading-accent,
|
||||
.theme-key-windows95 .brand-row strong,
|
||||
.theme-key-windows95 .login-brand h1,
|
||||
@@ -1059,6 +1195,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-windows95 .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-windows95 .language-select-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger[data-state="open"],
|
||||
.theme-key-windows95 .install-app-button:focus-visible,
|
||||
.theme-key-windows95 .bottom-nav button:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: -4px;
|
||||
@@ -1116,6 +1255,7 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .admin-nav-item.active svg.lucide,
|
||||
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
|
||||
.theme-key-windows95 .install-app-button.active svg.lucide,
|
||||
.theme-key-windows95 .admin-header svg.lucide {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
@@ -1133,7 +1273,6 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 svg.lucide-map,
|
||||
.theme-key-windows95 svg.lucide-menu,
|
||||
.theme-key-windows95 svg.lucide-mouse-pointer-click,
|
||||
.theme-key-windows95 svg.lucide-qr-code,
|
||||
.theme-key-windows95 svg.lucide-radio,
|
||||
.theme-key-windows95 svg.lucide-repeat-2,
|
||||
.theme-key-windows95 svg.lucide-server,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 9,
|
||||
"assets_version": 11,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
|
||||
@@ -49,7 +49,11 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from bot.utils.text_sanitizer import (
|
||||
panel_description_from_profile,
|
||||
sanitize_display_name,
|
||||
sanitize_username,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
@@ -59,6 +63,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
|
||||
APP_ROOT = Path(__file__).resolve().parents[5]
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
|
||||
@@ -66,6 +71,12 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_DEFAULT_BRAND_DIR = ASSET_DIR / "default-brand"
|
||||
WEBAPP_DEFAULT_LOGO_FILE = WEBAPP_DEFAULT_BRAND_DIR / "default-logo.webp"
|
||||
WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
|
||||
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
||||
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
||||
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
|
||||
@@ -430,6 +430,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
async def me_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
fresh = str(request.query.get("fresh") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
if fresh:
|
||||
await _invalidate_webapp_user_caches(settings, user_id)
|
||||
data = await _build_user_payload(request, user_id)
|
||||
return web.json_response({"ok": True, **data})
|
||||
|
||||
data = await webapp_cached_user_payload(
|
||||
settings,
|
||||
"me",
|
||||
@@ -471,12 +482,18 @@ async def account_avatar_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_language_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
language = _normalize_language(str(language_payload.language or ""))
|
||||
i18n = request.app.get("i18n")
|
||||
if i18n and hasattr(i18n, "reload_overrides_from_file"):
|
||||
i18n.reload_overrides_from_file()
|
||||
if i18n and language not in getattr(i18n, "locales_data", {}):
|
||||
return _json_error(400, "unsupported_language", "Unsupported language")
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .guides import warm_subscription_guides_config
|
||||
|
||||
|
||||
def create_subscription_webapp_application(
|
||||
@@ -20,10 +21,12 @@ def create_subscription_webapp_application(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
app["email_auth_service"] = EmailAuthService(settings)
|
||||
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
|
||||
app["subscription_guides_config_lock"] = asyncio.Lock()
|
||||
app["webapp_rate_limit_buckets"] = {}
|
||||
app["webapp_rate_limit_lock"] = asyncio.Lock()
|
||||
|
||||
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
await warm_subscription_guides_config(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
|
||||
@@ -9,6 +9,7 @@ from config.webapp_themes_config import (
|
||||
public_theme_payload,
|
||||
public_themes_catalog_payload,
|
||||
)
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
|
||||
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
|
||||
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
|
||||
@@ -95,9 +96,7 @@ async def theme_css_asset_route(request: web.Request) -> web.Response:
|
||||
raise web.HTTPNotFound(text="theme_css_not_found") from None
|
||||
|
||||
query = getattr(request, "query", {}) or {}
|
||||
cache_control = (
|
||||
"public, max-age=31536000, immutable" if query.get("v") else "no-cache"
|
||||
)
|
||||
cache_control = "public, max-age=31536000, immutable" if query.get("v") else "no-cache"
|
||||
try:
|
||||
stat = path.stat()
|
||||
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
|
||||
@@ -204,9 +203,9 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return ""
|
||||
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||
if not raw_logo_url:
|
||||
return ""
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if parsed_logo_url.scheme == "https":
|
||||
@@ -216,7 +215,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
return raw_logo_url
|
||||
if raw_logo_url.startswith("/"):
|
||||
return raw_logo_url
|
||||
return ""
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
|
||||
|
||||
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
|
||||
@@ -228,7 +227,9 @@ def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
|
||||
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
|
||||
if resolved:
|
||||
return resolved
|
||||
return logo_url or ""
|
||||
if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH:
|
||||
return logo_url
|
||||
return WEBAPP_DEFAULT_FAVICON_URL
|
||||
|
||||
|
||||
def _resolve_webapp_asset_url(raw_url: str) -> str:
|
||||
@@ -365,6 +366,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
|
||||
return _uploaded_webapp_logo_response(filename)
|
||||
|
||||
|
||||
async def webapp_default_logo_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp")
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
async def webapp_favicon_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
@@ -372,6 +383,78 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
|
||||
|
||||
digest = str(request.match_info.get("digest") or "").strip().lower()
|
||||
filename = str(request.match_info.get("filename") or "").strip()
|
||||
return _webapp_favicon_file_response(digest, filename)
|
||||
|
||||
|
||||
async def webapp_current_favicon_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
requested_filename = str(request.path.rsplit("/", 1)[-1] or "").strip()
|
||||
target_filename = _webapp_root_favicon_target_filename(requested_filename)
|
||||
if not target_filename:
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
|
||||
digest = _webapp_generated_favicon_digest(favicon_url)
|
||||
if digest:
|
||||
return _webapp_favicon_file_response(digest, target_filename)
|
||||
|
||||
redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename)
|
||||
if redirect_url:
|
||||
raise web.HTTPFound(location=redirect_url)
|
||||
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
|
||||
def _webapp_root_favicon_target_filename(filename: str) -> str:
|
||||
if filename == "apple-touch-icon-precomposed.png":
|
||||
return "apple-touch-icon.png"
|
||||
if filename in {
|
||||
"apple-touch-icon.png",
|
||||
"favicon.ico",
|
||||
"icon-192.png",
|
||||
"icon-512.png",
|
||||
}:
|
||||
return filename
|
||||
return ""
|
||||
|
||||
|
||||
def _webapp_generated_favicon_digest(favicon_url: str) -> str:
|
||||
parsed = urlsplit(str(favicon_url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(favicon_url or "")
|
||||
match = re.fullmatch(
|
||||
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/"
|
||||
r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))",
|
||||
path,
|
||||
)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def _webapp_redirectable_favicon_url(favicon_url: str, target_filename: str) -> str:
|
||||
href = str(favicon_url or "").strip()
|
||||
if not href:
|
||||
return ""
|
||||
|
||||
parsed = urlsplit(href)
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else href
|
||||
suffix = Path(path).suffix.lower()
|
||||
if target_filename in {"apple-touch-icon.png", "icon-192.png", "icon-512.png"}:
|
||||
if suffix != ".png":
|
||||
return ""
|
||||
elif target_filename == "favicon.ico":
|
||||
if suffix != ".ico":
|
||||
return ""
|
||||
else:
|
||||
return ""
|
||||
|
||||
if parsed.scheme in {"http", "https"} or href.startswith("/"):
|
||||
return href
|
||||
return ""
|
||||
|
||||
|
||||
def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
|
||||
if not re.fullmatch(r"[0-9a-f]{16}", digest):
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
if not re.fullmatch(
|
||||
@@ -380,6 +463,9 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
|
||||
):
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
|
||||
return _webapp_default_favicon_file_response(filename)
|
||||
|
||||
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
|
||||
path = (root / digest / filename).resolve()
|
||||
try:
|
||||
@@ -406,6 +492,29 @@ async def webapp_favicon_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _webapp_default_favicon_file_response(filename: str) -> web.Response:
|
||||
path = WEBAPP_DEFAULT_FAVICON_DIR / filename
|
||||
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
|
||||
if not content_type:
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
response = _webapp_default_brand_file_response(path, content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response:
|
||||
try:
|
||||
body = _read_template_binary_cached(path)
|
||||
except OSError:
|
||||
raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None
|
||||
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise web.HTTPNotFound(text="webapp_default_brand_not_found")
|
||||
|
||||
return web.Response(body=body, content_type=content_type)
|
||||
|
||||
|
||||
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
||||
ext = str(request.match_info.get("ext") or "").strip().lower()
|
||||
@@ -1000,6 +1109,30 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
|
||||
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
|
||||
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
|
||||
WEBAPP_I18N_SCOPES = {"webapp", "admin"}
|
||||
APP_DEEPLINK_I18N_KEYS = {
|
||||
"title": "wa_app_launch_title",
|
||||
"hint": "wa_app_launch_opening_hint",
|
||||
"manualHint": "wa_app_launch_hint",
|
||||
"button": "wa_app_launch_button",
|
||||
"retryButton": "wa_app_launch_retry_button",
|
||||
"doneTitle": "wa_app_launch_done_title",
|
||||
"doneHint": "wa_app_launch_done_hint",
|
||||
"closeButton": "wa_app_launch_close_button",
|
||||
"unavailableTitle": "wa_app_launch_unavailable_title",
|
||||
"unavailableHint": "wa_app_launch_unavailable_hint",
|
||||
}
|
||||
APP_DEEPLINK_I18N_FALLBACKS = {
|
||||
"wa_app_launch_title": "Opening app",
|
||||
"wa_app_launch_opening_hint": "Opening the app on this device...",
|
||||
"wa_app_launch_hint": "If the app did not open automatically, tap the button below.",
|
||||
"wa_app_launch_button": "Open app",
|
||||
"wa_app_launch_retry_button": "Open again",
|
||||
"wa_app_launch_done_title": "Settings added",
|
||||
"wa_app_launch_done_hint": "If the app opened, you can close this window.",
|
||||
"wa_app_launch_close_button": "Close window",
|
||||
"wa_app_launch_unavailable_title": "App link unavailable",
|
||||
"wa_app_launch_unavailable_hint": "Return to Telegram and try again.",
|
||||
}
|
||||
|
||||
|
||||
def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
|
||||
@@ -1063,7 +1196,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
preview_key = ""
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
|
||||
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
|
||||
i18n_instance.reload_overrides_from_file()
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
base_locales_data = getattr(i18n_instance, "base_locales_data", {}) if i18n_instance else {}
|
||||
return {
|
||||
"config": {
|
||||
"title": settings.WEBAPP_TITLE,
|
||||
@@ -1094,6 +1230,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
"userAgreementUrl": cached["user_agreement_url"],
|
||||
"currency": cached["currency"],
|
||||
"language": cached["language"],
|
||||
"languages": locale_language_options(
|
||||
locales_data.keys(),
|
||||
base_languages=base_locales_data.keys(),
|
||||
),
|
||||
"emailAuthEnabled": cached["email_auth_enabled"],
|
||||
"appVersion": _resolve_app_version(),
|
||||
"appRepositoryUrl": APP_REPOSITORY_URL,
|
||||
@@ -1110,6 +1250,8 @@ async def bootstrap_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def i18n_route(request: web.Request) -> web.Response:
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
|
||||
i18n_instance.reload_overrides_from_file()
|
||||
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
response = web.json_response(
|
||||
@@ -1123,6 +1265,72 @@ async def i18n_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _webapp_page_title(settings: Settings, suffix: str = "") -> str:
|
||||
base = str(getattr(settings, "WEBAPP_TITLE", "") or "").strip() or "Subscription"
|
||||
suffix = str(suffix or "").strip()
|
||||
return f"{base} - {suffix}" if suffix else base
|
||||
|
||||
|
||||
def _webapp_preview_meta_markup(page_title: str) -> str:
|
||||
escaped_title = html.escape(str(page_title or ""), quote=True)
|
||||
return "\n".join(
|
||||
[
|
||||
f'<meta name="application-name" content="{escaped_title}">',
|
||||
f'<meta name="apple-mobile-web-app-title" content="{escaped_title}">',
|
||||
f'<meta property="og:title" content="{escaped_title}">',
|
||||
'<meta property="og:type" content="website">',
|
||||
f'<meta property="og:site_name" content="{escaped_title}">',
|
||||
'<meta name="twitter:card" content="summary">',
|
||||
f'<meta name="twitter:title" content="{escaped_title}">',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _replace_webapp_title(html_text: str, page_title: str) -> str:
|
||||
escaped_title = html.escape(str(page_title or ""), quote=False)
|
||||
next_title = f"<title>{escaped_title}</title>"
|
||||
replaced = re.sub(
|
||||
r"<title\b[^>]*>.*?</title>",
|
||||
next_title,
|
||||
html_text,
|
||||
count=1,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if replaced != html_text:
|
||||
return replaced
|
||||
return html_text.replace("</head>", f"{next_title}\n</head>", 1)
|
||||
|
||||
|
||||
def _replace_webapp_favicon(html_text: str, favicon_markup: str) -> str:
|
||||
markup = str(favicon_markup or "").strip()
|
||||
if not markup:
|
||||
return html_text
|
||||
replaced = re.sub(
|
||||
r"<link\b(?=[^>]*\bid=[\"']app-favicon[\"'])[^>]*>",
|
||||
markup,
|
||||
html_text,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if replaced != html_text:
|
||||
return replaced
|
||||
return html_text.replace("</head>", f"{markup}\n</head>", 1)
|
||||
|
||||
|
||||
def _apply_webapp_head_metadata(html_text: str, page_title: str, favicon_url: str = "") -> str:
|
||||
html_text = _replace_webapp_title(html_text, page_title)
|
||||
if 'property="og:title"' not in html_text and "property='og:title'" not in html_text:
|
||||
meta_markup = _webapp_preview_meta_markup(page_title)
|
||||
html_text = re.sub(
|
||||
r"(<title\b[^>]*>.*?</title>)",
|
||||
lambda match: f"{match.group(1)}\n{meta_markup}",
|
||||
html_text,
|
||||
count=1,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
return _replace_webapp_favicon(html_text, _favicon_head_markup(favicon_url))
|
||||
|
||||
|
||||
async def index_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
@@ -1144,6 +1352,7 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||
if initial_theme_markup:
|
||||
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
|
||||
html = _apply_webapp_head_metadata(html, _webapp_page_title(settings), cached["favicon_url"])
|
||||
i18n_payload = bootstrap["i18n"]
|
||||
nonce = request.get("csp_nonce", "")
|
||||
html = html.replace(
|
||||
@@ -1166,12 +1375,6 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
WEBAPP_JS_PLACEHOLDER,
|
||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||
)
|
||||
favicon_markup = _favicon_head_markup(cached["favicon_url"])
|
||||
if favicon_markup:
|
||||
html = html.replace(
|
||||
'<link id="app-favicon" rel="icon" href="data:," sizes="any">',
|
||||
favicon_markup,
|
||||
)
|
||||
brand_asset_url = cached["logo_url"]
|
||||
if (
|
||||
not brand_asset_url
|
||||
@@ -1193,6 +1396,50 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
async def app_deeplink_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not getattr(settings, "WEBAPP_ENABLED", True):
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
||||
query = getattr(request, "query", {}) or {}
|
||||
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
|
||||
messages = _app_deeplink_i18n_payload(request, lang)
|
||||
page_title = _webapp_page_title(settings, messages["title"])
|
||||
messages_json = json.dumps(
|
||||
messages,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).replace("</", "<\\/")
|
||||
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
|
||||
html_text = (
|
||||
_read_template_text_cached(APP_DEEPLINK_TEMPLATE_PATH)
|
||||
.replace("__LANG__", html.escape(lang, quote=True))
|
||||
.replace("__PAGE_TITLE__", html.escape(page_title, quote=False))
|
||||
.replace("__NONCE__", nonce)
|
||||
.replace("__MESSAGES_JSON__", messages_json)
|
||||
)
|
||||
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
|
||||
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _app_deeplink_i18n_payload(request: web.Request, lang: str) -> Dict[str, str]:
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
payload: Dict[str, str] = {}
|
||||
for payload_key, i18n_key in APP_DEEPLINK_I18N_KEYS.items():
|
||||
fallback = APP_DEEPLINK_I18N_FALLBACKS[i18n_key]
|
||||
value = ""
|
||||
if i18n_instance is not None:
|
||||
try:
|
||||
value = str(i18n_instance.gettext(lang, i18n_key) or "")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to resolve open-app i18n key %s: %s", i18n_key, exc)
|
||||
payload[payload_key] = value if value and value != i18n_key else fallback
|
||||
return payload
|
||||
|
||||
|
||||
async def _serve_template_asset(
|
||||
request: web.Request,
|
||||
filename: str,
|
||||
|
||||
@@ -1013,13 +1013,11 @@ def _telegram_id_for_user(user: User) -> Optional[int]:
|
||||
|
||||
|
||||
def _panel_description_for_user(user: User) -> str:
|
||||
lines = [
|
||||
user.email or "",
|
||||
user.username or "",
|
||||
user.first_name or "",
|
||||
user.last_name or "",
|
||||
]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
return panel_description_from_profile(
|
||||
user.username,
|
||||
user.first_name,
|
||||
user.last_name,
|
||||
)
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
@@ -1042,9 +1040,7 @@ async def _sync_panel_identity_for_user(
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"description": _panel_description_for_user(user),
|
||||
}
|
||||
payload: Dict[str, Any] = {}
|
||||
telegram_id = _telegram_id_for_user(user)
|
||||
if telegram_id:
|
||||
payload["telegramId"] = telegram_id
|
||||
@@ -1058,11 +1054,19 @@ async def _sync_panel_identity_for_user(
|
||||
payload["status"] = "ACTIVE"
|
||||
|
||||
try:
|
||||
await subscription_service.panel_service.update_user_details_on_panel(
|
||||
updated_panel_user = await subscription_service.panel_service.update_user_details_on_panel(
|
||||
user.panel_user_uuid,
|
||||
payload,
|
||||
log_response=False,
|
||||
)
|
||||
if not updated_panel_user or (
|
||||
isinstance(updated_panel_user, dict) and updated_panel_user.get("error")
|
||||
):
|
||||
logger.warning(
|
||||
"Panel identity update returned no success payload for user %s",
|
||||
user.user_id,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -1198,11 +1202,9 @@ def _apply_telegram_profile_to_user(
|
||||
telegram_user: Dict[str, Any],
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
language_code = (
|
||||
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
||||
language_code = _normalize_language(
|
||||
user.language_code or telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = user.language_code or settings.DEFAULT_LANGUAGE
|
||||
|
||||
user.telegram_id = int(telegram_user["id"])
|
||||
user.username = sanitize_username(telegram_user.get("username"))
|
||||
@@ -1248,13 +1250,11 @@ async def _link_telegram_to_user(
|
||||
return merged_user
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
language_code = (
|
||||
telegram_user.get("language_code")
|
||||
or current_user.language_code
|
||||
language_code = _normalize_language(
|
||||
current_user.language_code
|
||||
or telegram_user.get("language_code")
|
||||
or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
target_user, _ = await user_dal.create_user(
|
||||
session,
|
||||
{
|
||||
@@ -1400,20 +1400,19 @@ async def _ensure_user_from_telegram(
|
||||
referral_param: Optional[str] = None,
|
||||
) -> User:
|
||||
user_id = int(telegram_user["id"])
|
||||
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
if language_code not in {"ru", "en"}:
|
||||
language_code = settings.DEFAULT_LANGUAGE
|
||||
telegram_language_code = _normalize_language(
|
||||
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
update_data = {
|
||||
profile_data = {
|
||||
"telegram_id": user_id,
|
||||
"username": sanitize_username(telegram_user.get("username")),
|
||||
"first_name": sanitize_display_name(telegram_user.get("first_name")),
|
||||
"last_name": sanitize_display_name(telegram_user.get("last_name")),
|
||||
"language_code": language_code,
|
||||
}
|
||||
telegram_photo_url = _telegram_photo_url_value(telegram_user)
|
||||
if telegram_photo_url:
|
||||
update_data["telegram_photo_url"] = telegram_photo_url
|
||||
profile_data["telegram_photo_url"] = telegram_photo_url
|
||||
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -1428,7 +1427,8 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
**update_data,
|
||||
**profile_data,
|
||||
"language_code": telegram_language_code,
|
||||
"referred_by_id": referred_by_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
},
|
||||
@@ -1436,6 +1436,10 @@ async def _ensure_user_from_telegram(
|
||||
setattr(db_user, "_webapp_created", bool(created))
|
||||
return db_user
|
||||
|
||||
update_data = {
|
||||
**profile_data,
|
||||
"language_code": _normalize_language(db_user.language_code or telegram_language_code),
|
||||
}
|
||||
changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value}
|
||||
if changed:
|
||||
db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
|
||||
|
||||
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _billing_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
text = str(value)
|
||||
try:
|
||||
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def _parse_positive_int_units(value: Any) -> Optional[int]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not number.is_integer():
|
||||
return None
|
||||
integer = int(number)
|
||||
return integer if integer > 0 else None
|
||||
|
||||
|
||||
async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
@@ -62,14 +100,20 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return validation_error
|
||||
method = str(payment_payload.method or "").strip().lower()
|
||||
settings: Settings = request.app["settings"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
tariffs_config = settings.tariffs_config
|
||||
traffic_mode = bool(settings.traffic_sale_mode)
|
||||
sale_mode = "subscription"
|
||||
traffic_gb_for_payment: Optional[float] = None
|
||||
hwid_quote: Optional[Dict[str, Any]] = None
|
||||
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
|
||||
|
||||
if tariffs_config and requested_sale_mode in {"hwid_device", "hwid_devices"}:
|
||||
if tariffs_config and requested_sale_mode in {
|
||||
"hwid_device",
|
||||
"hwid_devices",
|
||||
"hwid_devices_renewal",
|
||||
}:
|
||||
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||
if not tariff_key:
|
||||
return _json_error(400, "invalid_plan", "Tariff is not selected")
|
||||
@@ -77,33 +121,19 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
tariff = tariffs_config.require(tariff_key)
|
||||
except Exception:
|
||||
return _json_error(400, "invalid_plan", "Tariff is not available")
|
||||
try:
|
||||
device_count = int(
|
||||
float(
|
||||
payment_payload.device_count
|
||||
if payment_payload.device_count is not None
|
||||
else payment_payload.months
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
if tariff.billing_model != "period":
|
||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
||||
device_count = _parse_positive_int_units(
|
||||
payment_payload.device_count
|
||||
if payment_payload.device_count is not None
|
||||
else payment_payload.months
|
||||
)
|
||||
if device_count is None:
|
||||
return _json_error(400, "invalid_plan", "Invalid device package")
|
||||
packages = tariff.hwid_device_packages
|
||||
rub_packages = {
|
||||
int(package.count): float(package.price)
|
||||
for package in (packages.rub if packages else [])
|
||||
}
|
||||
stars_packages = {
|
||||
int(package.count): int(float(package.price))
|
||||
for package in (packages.stars if packages else [])
|
||||
}
|
||||
price = rub_packages.get(device_count)
|
||||
stars_price = stars_packages.get(device_count)
|
||||
if price is None and method != "stars":
|
||||
if not tariff.hwid_device_packages:
|
||||
return _json_error(400, "invalid_plan", "Device package is not available")
|
||||
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
|
||||
return _json_error(400, "invalid_plan", "Stars price is not configured")
|
||||
payment_units = device_count
|
||||
sale_mode = f"hwid_devices@{tariff.key}"
|
||||
sale_mode = f"{requested_sale_mode}@{tariff.key}"
|
||||
elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}:
|
||||
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||
if not tariff_key:
|
||||
@@ -251,6 +281,42 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
if _sale_mode_is_hwid_devices(sale_mode):
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, db_user.panel_user_uuid
|
||||
)
|
||||
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
|
||||
if not sub or not sub.tariff_key or sub.tariff_key != sale_tariff_key:
|
||||
return _json_error(
|
||||
400, "subscription_required", "Active tariff subscription is required"
|
||||
)
|
||||
try:
|
||||
active_tariff = tariffs_config.require(sub.tariff_key) if tariffs_config else None
|
||||
except Exception:
|
||||
active_tariff = None
|
||||
if not active_tariff or active_tariff.billing_model != "period":
|
||||
return _json_error(400, "invalid_plan", "Device top-up is not available")
|
||||
currency = "stars" if method == "stars" else "rub"
|
||||
hwid_quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
device_count=int(payment_units),
|
||||
tariff_key=sale_tariff_key,
|
||||
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
|
||||
currency=currency,
|
||||
)
|
||||
if not hwid_quote:
|
||||
return _json_error(400, "invalid_plan", "Device package is not available")
|
||||
if method == "stars":
|
||||
stars_price = int(hwid_quote["price"])
|
||||
price = 0.0
|
||||
if stars_price <= 0:
|
||||
return _json_error(400, "invalid_plan", "Stars price is not configured")
|
||||
else:
|
||||
price = float(hwid_quote["price"])
|
||||
stars_price = None
|
||||
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
return await _create_subscription_payment(
|
||||
request=request,
|
||||
session=session,
|
||||
@@ -262,6 +328,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
lang=lang,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb_for_payment,
|
||||
is_admin=is_admin,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
|
||||
@@ -311,7 +379,12 @@ async def activate_trial_route(request: web.Request) -> web.Response:
|
||||
notification_service = NotificationService(
|
||||
request.app["bot"], settings, i18n_instance
|
||||
)
|
||||
await notification_service.notify_trial_activation(user_id, end_date)
|
||||
await notification_service.notify_trial_activation(
|
||||
user_id,
|
||||
end_date,
|
||||
username=db_user.username,
|
||||
email=getattr(db_user, "email", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send WebApp trial activation notification")
|
||||
|
||||
@@ -324,6 +397,8 @@ async def activate_trial_route(request: web.Request) -> web.Response:
|
||||
await session.rollback()
|
||||
logger.exception("Failed to mark WebApp trial activation for ad attribution")
|
||||
|
||||
await invalidate_webapp_user_caches(settings, user_id)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -448,7 +523,9 @@ async def tariff_change_options_route(request: web.Request) -> web.Response:
|
||||
for tariff in config.enabled_tariffs:
|
||||
if tariff.key == current.key:
|
||||
continue
|
||||
options = subscription_service.calculate_tariff_switch_options(sub, tariff)
|
||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||
session, sub, tariff
|
||||
)
|
||||
targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang))
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -525,7 +602,9 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
|
||||
400, "subscription_required", "Active tariff subscription is required"
|
||||
)
|
||||
target = config.require(tariff_key)
|
||||
options = subscription_service.calculate_tariff_switch_options(sub, target)
|
||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||
session, sub, target
|
||||
)
|
||||
price = float(options.get("paid_diff_rub") or 0)
|
||||
if price <= 0:
|
||||
return _json_error(
|
||||
@@ -567,25 +646,231 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
400, "subscription_required", "Active tariff subscription is required"
|
||||
)
|
||||
tariff = config.require(sub.tariff_key)
|
||||
if tariff.billing_model != "period":
|
||||
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
plans = _serialize_hwid_device_packages(
|
||||
settings,
|
||||
tariff,
|
||||
tariff.hwid_device_packages,
|
||||
db_user.language_code or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
||||
extra_hwid_valid_until_text = (
|
||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||
) or _billing_datetime_text(extra_hwid_valid_until)
|
||||
packages = tariff.hwid_device_packages
|
||||
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
|
||||
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
|
||||
plans = []
|
||||
for count in sorted(rub_counts | stars_counts):
|
||||
rub_quote = (
|
||||
await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
currency="rub",
|
||||
)
|
||||
if count in rub_counts
|
||||
else None
|
||||
)
|
||||
stars_quote = (
|
||||
await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
currency="stars",
|
||||
)
|
||||
if count in stars_counts
|
||||
else None
|
||||
)
|
||||
if not rub_quote and not stars_quote:
|
||||
continue
|
||||
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
||||
plan = {
|
||||
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": sale_mode_for_plan,
|
||||
"months": count,
|
||||
"device_count": count,
|
||||
"price": float(rub_quote.get("price") if rub_quote else 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
"valid_from": _billing_iso_datetime((rub_quote or stars_quote).get("valid_from")),
|
||||
"valid_until": _billing_iso_datetime((rub_quote or stars_quote).get("valid_until")),
|
||||
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
plan["stars_price"] = int(stars_quote["price"])
|
||||
plans.append(plan)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE),
|
||||
"tariff_name": tariff.name(lang),
|
||||
"current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None,
|
||||
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
|
||||
if active
|
||||
else int(sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||
"renewal_available": renewal_available,
|
||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
||||
if active and renewal_available
|
||||
else 0,
|
||||
"plans": plans,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _yookassa_payment_payload_for_processing(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized = dict(payload or {})
|
||||
if not isinstance(normalized.get("amount"), dict):
|
||||
amount_value = normalized.get("amount_value")
|
||||
amount_currency = normalized.get("amount_currency")
|
||||
if amount_value is not None or amount_currency:
|
||||
normalized["amount"] = {
|
||||
"value": str(amount_value if amount_value is not None else 0),
|
||||
"currency": amount_currency or "RUB",
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
def _payment_status_can_be_refreshed(payment: Payment) -> bool:
|
||||
normalized = str(getattr(payment, "status", "") or "").lower()
|
||||
if normalized == "succeeded":
|
||||
return False
|
||||
if normalized in {"failed", "canceled", "cancelled", "failed_creation"}:
|
||||
return False
|
||||
return normalized.startswith("pending") or normalized in {"waiting_for_capture", "created"}
|
||||
|
||||
|
||||
async def _refresh_yookassa_payment_status(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
payment: Payment,
|
||||
) -> Payment:
|
||||
if str(getattr(payment, "provider", "") or "").lower() != "yookassa":
|
||||
return payment
|
||||
if not _payment_status_can_be_refreshed(payment):
|
||||
return payment
|
||||
|
||||
yookassa_payment_id = payment.yookassa_payment_id or payment.provider_payment_id
|
||||
yookassa_service = request.app.get("yookassa_service")
|
||||
if (
|
||||
not yookassa_payment_id
|
||||
or not yookassa_service
|
||||
or not getattr(yookassa_service, "configured", False)
|
||||
or not hasattr(yookassa_service, "get_payment_info")
|
||||
):
|
||||
return payment
|
||||
|
||||
try:
|
||||
provider_payload = await yookassa_service.get_payment_info(yookassa_payment_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh YooKassa payment %s status", payment.payment_id)
|
||||
return payment
|
||||
|
||||
if not provider_payload:
|
||||
return payment
|
||||
|
||||
provider_payload = _yookassa_payment_payload_for_processing(provider_payload)
|
||||
provider_status = str(provider_payload.get("status") or "").lower()
|
||||
if provider_status == "succeeded" and provider_payload.get("paid") is True:
|
||||
from bot.payment_providers.yookassa import (
|
||||
payment_processing_lock,
|
||||
process_successful_payment,
|
||||
)
|
||||
|
||||
async with payment_processing_lock:
|
||||
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
|
||||
if not current:
|
||||
return payment
|
||||
if current.status == "succeeded":
|
||||
return current
|
||||
try:
|
||||
await process_successful_payment(
|
||||
session,
|
||||
request.app["bot"],
|
||||
provider_payload,
|
||||
request.app["i18n"],
|
||||
request.app["settings"],
|
||||
request.app["panel_service"],
|
||||
request.app["subscription_service"],
|
||||
request.app["referral_service"],
|
||||
request.app.get("lknpd_service"),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception(
|
||||
"Failed to process refreshed YooKassa payment %s",
|
||||
payment.payment_id,
|
||||
)
|
||||
return current
|
||||
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current
|
||||
|
||||
if provider_status in {"canceled", "cancelled"}:
|
||||
from bot.payment_providers.yookassa import (
|
||||
payment_processing_lock,
|
||||
process_cancelled_payment,
|
||||
)
|
||||
|
||||
async with payment_processing_lock:
|
||||
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
|
||||
if not current:
|
||||
return payment
|
||||
if not _payment_status_can_be_refreshed(current):
|
||||
return current
|
||||
try:
|
||||
await process_cancelled_payment(
|
||||
session,
|
||||
request.app["bot"],
|
||||
provider_payload,
|
||||
request.app["i18n"],
|
||||
request.app["settings"],
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception(
|
||||
"Failed to process refreshed cancelled YooKassa payment %s",
|
||||
payment.payment_id,
|
||||
)
|
||||
return current
|
||||
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def _refresh_wata_payment_status(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
payment: Payment,
|
||||
) -> Payment:
|
||||
if str(getattr(payment, "provider", "") or "").lower() != "wata":
|
||||
return payment
|
||||
if not _payment_status_can_be_refreshed(payment):
|
||||
return payment
|
||||
|
||||
wata_service = request.app.get("wata_service")
|
||||
if (
|
||||
not wata_service
|
||||
or not getattr(wata_service, "configured", False)
|
||||
or not hasattr(wata_service, "refresh_payment_status")
|
||||
):
|
||||
return payment
|
||||
|
||||
try:
|
||||
return await wata_service.refresh_payment_status(session, payment)
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh Wata payment %s status", payment.payment_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def payment_status_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
try:
|
||||
@@ -598,6 +883,10 @@ async def payment_status_route(request: web.Request) -> web.Response:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment or payment.user_id != user_id:
|
||||
return _json_error(404, "not_found", "Payment not found")
|
||||
payment = await _refresh_yookassa_payment_status(request, session, payment)
|
||||
payment = await _refresh_wata_payment_status(request, session, payment)
|
||||
if payment.status == "succeeded":
|
||||
await invalidate_webapp_user_caches(request.app["settings"], user_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -623,7 +912,11 @@ def _sale_mode_is_traffic(sale_mode: str) -> bool:
|
||||
|
||||
|
||||
def _sale_mode_is_hwid_devices(sale_mode: str) -> bool:
|
||||
return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
|
||||
return _sale_mode_base(sale_mode) in {
|
||||
"hwid_device",
|
||||
"hwid_devices",
|
||||
"hwid_devices_renewal",
|
||||
}
|
||||
|
||||
|
||||
async def _create_subscription_payment(
|
||||
@@ -638,6 +931,8 @@ async def _create_subscription_payment(
|
||||
lang: str,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
is_admin: bool = False,
|
||||
hwid_quote: Optional[Dict[str, Any]] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
sale_mode = str(sale_mode or "subscription")
|
||||
@@ -655,11 +950,11 @@ async def _create_subscription_payment(
|
||||
|
||||
provider_spec = get_provider_spec(method)
|
||||
if provider_spec and provider_spec.create_webapp_payment:
|
||||
if not provider_spec.is_visible(settings, request.app):
|
||||
if not provider_spec.is_visible_for_user(settings, request.app, is_admin=is_admin):
|
||||
logger.warning(
|
||||
"WebApp payment method unavailable: method=%s enabled=%s configured=%s",
|
||||
method,
|
||||
provider_spec.is_enabled(settings),
|
||||
provider_spec.is_effectively_enabled(settings),
|
||||
provider_spec.is_service_configured(request.app),
|
||||
)
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
@@ -675,6 +970,13 @@ async def _create_subscription_payment(
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,24 @@ from config.settings import Settings
|
||||
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
def reset_webapp_settings_cache(app: Any) -> None:
|
||||
cache = app.get("webapp_settings_cache") if hasattr(app, "get") else None
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
|
||||
|
||||
def reset_subscription_guides_cache(app: Any) -> None:
|
||||
cache = app.get("subscription_guides_config_cache") if hasattr(app, "get") else None
|
||||
if isinstance(cache, dict):
|
||||
cache["fingerprint"] = None
|
||||
cache["status"] = None
|
||||
|
||||
|
||||
def _payload_namespaces(include_devices: bool = False) -> tuple[str, ...]:
|
||||
return ("me", "devices") if include_devices else ("me",)
|
||||
|
||||
|
||||
def _webapp_user_payload_cache(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
@@ -48,9 +66,7 @@ def invalidate_local_webapp_user_payload(
|
||||
user_id: int,
|
||||
) -> None:
|
||||
key = str(int(user_id))
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(
|
||||
_WEBAPP_USER_PAYLOAD_CACHES.items()
|
||||
):
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
|
||||
if settings_id == id(settings) and cache_namespace == namespace:
|
||||
cache.invalidate(key)
|
||||
|
||||
@@ -58,13 +74,20 @@ def invalidate_local_webapp_user_payload(
|
||||
def invalidate_all_local_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
namespace: Optional[str] = None,
|
||||
*,
|
||||
include_devices: Optional[bool] = None,
|
||||
) -> None:
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(
|
||||
_WEBAPP_USER_PAYLOAD_CACHES.items()
|
||||
):
|
||||
if include_devices is not None:
|
||||
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
|
||||
elif namespace is not None:
|
||||
namespaces = {namespace}
|
||||
else:
|
||||
namespaces = None
|
||||
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
|
||||
if settings_id != id(settings):
|
||||
continue
|
||||
if namespace is not None and cache_namespace != namespace:
|
||||
if namespaces is not None and cache_namespace not in namespaces:
|
||||
continue
|
||||
cache.invalidate()
|
||||
|
||||
@@ -95,21 +118,23 @@ async def invalidate_webapp_user_caches(
|
||||
await cache_delete(settings, *keys)
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
for namespace in _payload_namespaces(include_devices):
|
||||
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
|
||||
try:
|
||||
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
|
||||
await cache_delete_pattern(settings, pattern)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
namespaces = ["me"]
|
||||
if include_devices:
|
||||
namespaces.append("devices")
|
||||
|
||||
for namespace in namespaces:
|
||||
invalidate_all_local_webapp_user_payloads(settings, namespace)
|
||||
try:
|
||||
await cache_delete_pattern(
|
||||
settings,
|
||||
redis_key(settings, "cache", "webapp", namespace, "*"),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_local_webapp_user_payload,
|
||||
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
|
||||
)
|
||||
from bot.middlewares.i18n import (
|
||||
is_valid_locale_language_code,
|
||||
normalize_locale_language_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,25 +30,7 @@ async def _invalidate_webapp_user_caches(
|
||||
*user_ids: Optional[int],
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
keys: List[str] = []
|
||||
seen: set[int] = set()
|
||||
for raw_user_id in user_ids:
|
||||
if raw_user_id is None:
|
||||
continue
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if user_id in seen:
|
||||
continue
|
||||
seen.add(user_id)
|
||||
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "me", user_id)
|
||||
if include_devices:
|
||||
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "devices", user_id)
|
||||
if keys:
|
||||
await cache_delete(settings, *keys)
|
||||
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
|
||||
|
||||
|
||||
def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
@@ -91,8 +77,8 @@ def _validate_model_payload(
|
||||
|
||||
|
||||
def _normalize_language(lang: Optional[str]) -> str:
|
||||
value = (lang or "ru").split("-")[0].lower()
|
||||
return value if value in {"ru", "en"} else "ru"
|
||||
value = normalize_locale_language_code(lang, prefer_known_base=False)
|
||||
return value if is_valid_locale_language_code(value) else "ru"
|
||||
|
||||
|
||||
def _format_remaining(seconds: int, lang: str) -> str:
|
||||
|
||||
@@ -86,8 +86,7 @@ async def _load_devices_payload(
|
||||
"max_devices": max_devices,
|
||||
"max_devices_label": _format_devices_limit(max_devices),
|
||||
"devices": [
|
||||
_serialize_device(device, index)
|
||||
for index, device in enumerate(devices, start=1)
|
||||
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -194,6 +193,15 @@ def _format_device_datetime(value: Any) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _serialize_device_datetime(value: Any) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
hwid = str(device.get("hwid") or "").strip()
|
||||
model = str(device.get("deviceModel") or "").strip()
|
||||
@@ -209,7 +217,7 @@ def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"os_version": os_version,
|
||||
"platform_label": platform_label,
|
||||
"user_agent": user_agent,
|
||||
"created_at": device.get("createdAt"),
|
||||
"created_at": _serialize_device_datetime(device.get("createdAt")),
|
||||
"created_at_text": _format_device_datetime(device.get("createdAt")),
|
||||
"hwid_short": _shorten_hwid_for_display(hwid),
|
||||
"token": _device_hwid_token(hwid) if hwid else "",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -6,6 +6,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/install", index_route)
|
||||
app.router.add_get("/trial", index_route)
|
||||
app.router.add_get("/open-app", app_deeplink_route)
|
||||
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
@@ -15,15 +19,23 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings|support}"
|
||||
"appearance|settings|translations|support|backups}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/payments/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/payments/{payment_id:\\d+}", index_route)
|
||||
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
app.router.add_get("/favicon.ico", webapp_current_favicon_route)
|
||||
app.router.add_get("/apple-touch-icon.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-192.png", webapp_current_favicon_route)
|
||||
app.router.add_get("/icon-512.png", webapp_current_favicon_route)
|
||||
app.router.add_get(WEBAPP_DEFAULT_LOGO_PATH, webapp_default_logo_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
@@ -60,6 +72,11 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/i18n", i18n_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_get("/api/subscription-guides", subscription_guides_route)
|
||||
app.router.add_get(
|
||||
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
|
||||
public_subscription_guides_route,
|
||||
)
|
||||
app.router.add_get("/api/account/avatar", account_avatar_route)
|
||||
app.router.add_post("/api/account/language", account_language_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.subscription_guides_config import subscription_guides_available
|
||||
from config.webapp_themes_config import public_themes_catalog_payload
|
||||
|
||||
|
||||
@@ -52,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
if db_user.panel_user_uuid
|
||||
else None
|
||||
)
|
||||
install_share_token = (
|
||||
await subscription_dal.ensure_install_share_token(session, local_sub)
|
||||
if active and local_sub
|
||||
else None
|
||||
)
|
||||
trial_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
@@ -82,7 +88,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"language_code": lang,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
"subscription": _serialize_subscription(settings, active, local_sub, lang),
|
||||
"subscription": _serialize_subscription(
|
||||
request,
|
||||
settings,
|
||||
active,
|
||||
local_sub,
|
||||
lang,
|
||||
install_share_token=install_share_token,
|
||||
),
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_link,
|
||||
@@ -105,7 +118,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app, lang),
|
||||
"payment_methods": _serialize_payment_methods(
|
||||
settings,
|
||||
request.app,
|
||||
lang,
|
||||
is_admin=is_admin,
|
||||
),
|
||||
"themes_catalog": public_themes_catalog_payload(
|
||||
settings.webapp_themes_catalog,
|
||||
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
@@ -132,6 +150,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||
"subscription_purchase_description": settings.subscription_purchase_description(lang),
|
||||
"subscription_guides_enabled": subscription_guides_available(settings),
|
||||
"email_auth_enabled": settings.email_auth_configured,
|
||||
},
|
||||
}
|
||||
@@ -179,11 +198,26 @@ def _build_webapp_referral_link(
|
||||
|
||||
|
||||
def _serialize_subscription(
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
lang: str,
|
||||
request_or_settings: Any,
|
||||
settings_or_active: Any,
|
||||
active_or_local_sub: Optional[Any] = None,
|
||||
local_sub_or_lang: Optional[Any] = None,
|
||||
lang: Optional[str] = None,
|
||||
*,
|
||||
install_share_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if lang is None:
|
||||
request = None
|
||||
settings = request_or_settings
|
||||
active = settings_or_active
|
||||
local_sub = active_or_local_sub
|
||||
lang = str(local_sub_or_lang or "ru")
|
||||
else:
|
||||
request = request_or_settings
|
||||
settings = settings_or_active
|
||||
active = active_or_local_sub
|
||||
local_sub = local_sub_or_lang
|
||||
|
||||
if not active:
|
||||
return {
|
||||
"active": False,
|
||||
@@ -192,6 +226,9 @@ def _serialize_subscription(
|
||||
"days_left": 0,
|
||||
"config_link": None,
|
||||
"connect_url": None,
|
||||
"panel_short_uuid": None,
|
||||
"install_share_token": None,
|
||||
"install_share_url": None,
|
||||
}
|
||||
|
||||
end_date = active.get("end_date")
|
||||
@@ -222,7 +259,8 @@ def _serialize_subscription(
|
||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
||||
can_topup_devices = bool(
|
||||
tariff.has_hwid_device_packages()
|
||||
tariff.billing_model == "period"
|
||||
and tariff.has_hwid_device_packages()
|
||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
||||
)
|
||||
except Exception:
|
||||
@@ -231,6 +269,23 @@ def _serialize_subscription(
|
||||
can_topup_traffic = False
|
||||
can_topup_devices = False
|
||||
|
||||
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
|
||||
share_token = str(
|
||||
install_share_token or getattr(local_sub, "install_share_token", "") or ""
|
||||
).strip()
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until")
|
||||
if extra_hwid_valid_until and extra_hwid_valid_until.tzinfo is None:
|
||||
extra_hwid_valid_until = extra_hwid_valid_until.replace(tzinfo=timezone.utc)
|
||||
extra_hwid_next_valid_from = active.get("extra_hwid_devices_next_valid_from")
|
||||
if extra_hwid_next_valid_from and extra_hwid_next_valid_from.tzinfo is None:
|
||||
extra_hwid_next_valid_from = extra_hwid_next_valid_from.replace(tzinfo=timezone.utc)
|
||||
extra_hwid_count = _coerce_int_or_none(active.get("extra_hwid_devices")) or 0
|
||||
device_topup_renewal_available = bool(
|
||||
extra_hwid_count > 0
|
||||
and extra_hwid_valid_until
|
||||
and end_date
|
||||
and extra_hwid_valid_until < end_date
|
||||
)
|
||||
return {
|
||||
"active": seconds_left > 0,
|
||||
"status": active.get("status_from_panel") or "UNKNOWN",
|
||||
@@ -240,6 +295,9 @@ def _serialize_subscription(
|
||||
"remaining_text": _format_remaining(seconds_left, lang),
|
||||
"config_link": active.get("config_link"),
|
||||
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
||||
"panel_short_uuid": panel_short_uuid or None,
|
||||
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
|
||||
"install_share_url": _build_install_share_link(request, settings, share_token),
|
||||
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
|
||||
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
||||
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
||||
@@ -278,12 +336,46 @@ def _serialize_subscription(
|
||||
"is_throttled": bool(active.get("is_throttled")),
|
||||
"max_devices": _coerce_int_or_none(active.get("max_devices")),
|
||||
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
|
||||
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
|
||||
"extra_hwid_devices": extra_hwid_count,
|
||||
"extra_hwid_devices_valid_until": extra_hwid_valid_until.isoformat()
|
||||
if extra_hwid_valid_until
|
||||
else None,
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until.strftime("%d.%m.%Y %H:%M")
|
||||
if extra_hwid_valid_until
|
||||
else None,
|
||||
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from.isoformat()
|
||||
if extra_hwid_next_valid_from
|
||||
else None,
|
||||
"device_topup_renewal_available": device_topup_renewal_available,
|
||||
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
|
||||
"provider": getattr(local_sub, "provider", None),
|
||||
}
|
||||
|
||||
|
||||
def _build_install_share_link(
|
||||
request: Optional[web.Request],
|
||||
settings: Settings,
|
||||
share_token: str,
|
||||
) -> Optional[str]:
|
||||
share_token = subscription_dal.normalize_install_share_token(share_token)
|
||||
if not share_token or request is None:
|
||||
return None
|
||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if configured_base:
|
||||
parts = urlsplit(configured_base)
|
||||
if parts.scheme and parts.netloc:
|
||||
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
else:
|
||||
base = configured_base.rstrip("/")
|
||||
else:
|
||||
host = (
|
||||
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
|
||||
)
|
||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
base = f"{proto}://{host}"
|
||||
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||
|
||||
|
||||
def _serialize_plans(
|
||||
settings: Settings,
|
||||
lang: str,
|
||||
@@ -310,7 +402,9 @@ def _serialize_plans(
|
||||
tariff,
|
||||
tariff.hwid_device_packages,
|
||||
lang,
|
||||
),
|
||||
)
|
||||
if tariff.billing_model == "period"
|
||||
else [],
|
||||
}
|
||||
if tariff.billing_model == "period":
|
||||
for months in sorted(tariff.enabled_periods):
|
||||
@@ -519,6 +613,8 @@ def _serialize_tariff_change_target(
|
||||
"title": "recalc_days",
|
||||
"days_after": int(options.get("recalc_days") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
|
||||
}
|
||||
)
|
||||
paid_diff = float(options.get("paid_diff_rub") or 0)
|
||||
@@ -540,6 +636,8 @@ def _serialize_tariff_change_target(
|
||||
"title": "convert_days_to_gb",
|
||||
"converted_gb": float(options.get("converted_gb") or 0),
|
||||
"remaining_days": int(options.get("remaining_days") or 0),
|
||||
"converted_hwid_value_rub": float(options.get("converted_hwid_value_rub") or 0),
|
||||
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
|
||||
}
|
||||
)
|
||||
actions.extend(
|
||||
@@ -582,6 +680,8 @@ def _serialize_payment_methods(
|
||||
settings: Settings,
|
||||
app: web.Application,
|
||||
lang: str = "ru",
|
||||
*,
|
||||
is_admin: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
|
||||
|
||||
@@ -589,7 +689,7 @@ def _serialize_payment_methods(
|
||||
for method in settings.payment_methods_order:
|
||||
method = method.lower()
|
||||
spec = get_provider_spec(method)
|
||||
if spec and spec.is_visible(settings, app):
|
||||
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
|
||||
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||
methods.append(
|
||||
{
|
||||
|
||||
@@ -156,7 +156,6 @@ async def admin_panel_actions_callback_handler(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
)
|
||||
await callback.answer(_("admin_sync_initiated_from_panel"))
|
||||
elif action == "queue_status":
|
||||
await show_queue_status_handler(callback, i18n_data)
|
||||
elif action == "view_payments":
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
@@ -25,6 +26,44 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _user_email(user: Optional[User]) -> str:
|
||||
return str(getattr(user, "email", None) or "").strip()
|
||||
|
||||
|
||||
def _format_user_with_email(
|
||||
*,
|
||||
first_name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
fallback: str = "",
|
||||
) -> str:
|
||||
parts = []
|
||||
if first_name:
|
||||
parts.append(first_name)
|
||||
if username:
|
||||
parts.append(f"(@{username})")
|
||||
|
||||
display = " ".join(parts).strip() or str(fallback or "").strip()
|
||||
clean_email = str(email or "").strip()
|
||||
if clean_email:
|
||||
display = (
|
||||
f"{display} · {clean_email}" if display and display != clean_email else clean_email
|
||||
)
|
||||
return hd.quote(display)
|
||||
|
||||
|
||||
def _format_log_entry_user(log_entry: MessageLog, translate) -> str:
|
||||
fallback = (
|
||||
translate("system_or_unknown_user") if not log_entry.user_id else f"ID: {log_entry.user_id}"
|
||||
)
|
||||
return _format_user_with_email(
|
||||
first_name=log_entry.telegram_first_name,
|
||||
username=log_entry.telegram_username,
|
||||
email=_user_email(getattr(log_entry, "author_user", None)),
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
async def display_logs_menu(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
):
|
||||
@@ -94,19 +133,7 @@ async def _display_formatted_logs(
|
||||
|
||||
log_entries_text = []
|
||||
for log_entry_model in logs:
|
||||
user_display_parts = []
|
||||
if log_entry_model.telegram_first_name:
|
||||
user_display_parts.append(log_entry_model.telegram_first_name)
|
||||
if log_entry_model.telegram_username:
|
||||
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
|
||||
|
||||
user_display = " ".join(user_display_parts).strip()
|
||||
if not user_display:
|
||||
user_display = (
|
||||
_("system_or_unknown_user")
|
||||
if not log_entry_model.user_id
|
||||
else f"ID: {log_entry_model.user_id}"
|
||||
)
|
||||
user_display = _format_log_entry_user(log_entry_model, _)
|
||||
|
||||
user_id_display = (
|
||||
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
|
||||
@@ -270,10 +297,11 @@ async def process_user_id_for_logs_handler(
|
||||
return
|
||||
|
||||
target_user_id = user_model_for_logs.user_id
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username
|
||||
else (user_model_for_logs.email or f"ID {target_user_id}")
|
||||
user_display_name = _format_user_with_email(
|
||||
first_name=user_model_for_logs.first_name,
|
||||
username=user_model_for_logs.username,
|
||||
email=user_model_for_logs.email,
|
||||
fallback=f"ID {target_user_id}",
|
||||
)
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
@@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username
|
||||
else (user_model_for_logs.email or f"ID {target_user_id}")
|
||||
user_display_name = _format_user_with_email(
|
||||
first_name=user_model_for_logs.first_name,
|
||||
username=user_model_for_logs.username,
|
||||
email=user_model_for_logs.email,
|
||||
fallback=f"ID {target_user_id}",
|
||||
)
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
@@ -392,6 +421,7 @@ async def export_logs_csv_handler(
|
||||
_("admin_csv_header_user_id"),
|
||||
_("admin_csv_header_telegram_username"),
|
||||
_("admin_csv_header_telegram_first_name"),
|
||||
_("admin_csv_header_email"),
|
||||
_("admin_csv_header_event_type"),
|
||||
_("admin_csv_header_content"),
|
||||
_("admin_csv_header_is_admin_event"),
|
||||
@@ -417,6 +447,7 @@ async def export_logs_csv_handler(
|
||||
log.user_id or "",
|
||||
log.telegram_username or "",
|
||||
log.telegram_first_name or "",
|
||||
_user_email(getattr(log, "author_user", None)),
|
||||
log.event_type or "",
|
||||
content_clean,
|
||||
"Yes" if log.is_admin_event else "No",
|
||||
|
||||
@@ -1264,8 +1264,13 @@ async def process_delete_user_confirmation_handler(
|
||||
return
|
||||
|
||||
try:
|
||||
if user_model.panel_user_uuid:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid)
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_user_id,
|
||||
user=user_model,
|
||||
)
|
||||
for panel_uuid in panel_user_uuids:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(panel_uuid)
|
||||
if not panel_deleted:
|
||||
await message.answer(
|
||||
_(
|
||||
|
||||
@@ -16,7 +16,12 @@ from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.states.user_states import UserPromoStates
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -133,10 +138,12 @@ async def process_promo_code_input(
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
db_user = await user_dal.get_user_by_id(session, user.id)
|
||||
await notification_service.notify_suspicious_promo_attempt(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
suspicious_input=code_input,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -160,12 +167,30 @@ async def process_promo_code_input(
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user.id)
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
response_to_user_text = append_install_share_link_text(
|
||||
response_to_user_text,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for promo user %s.",
|
||||
user.id,
|
||||
)
|
||||
install_share_url = None
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
else:
|
||||
await session.commit()
|
||||
|
||||
@@ -17,12 +17,16 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
@@ -715,6 +719,23 @@ async def start_command_handler(
|
||||
),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
promo_success_text = append_install_share_link_text(
|
||||
promo_success_text,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for promo user %s.",
|
||||
user_id,
|
||||
)
|
||||
install_share_url = None
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
|
||||
@@ -726,6 +747,7 @@ async def start_command_handler(
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -891,7 +913,12 @@ async def select_language_callback_handler(
|
||||
|
||||
try:
|
||||
lang_payload = callback.data.split("_", 2)[2]
|
||||
lang_code, _, return_target = lang_payload.partition(":")
|
||||
raw_lang_code, _, return_target = lang_payload.partition(":")
|
||||
lang_code = normalize_locale_language_code(
|
||||
raw_lang_code,
|
||||
set(i18n.locales_data.keys()),
|
||||
prefer_known_base=True,
|
||||
)
|
||||
except IndexError:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
@@ -899,6 +926,13 @@ async def select_language_callback_handler(
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
if lang_code not in i18n.locales_data:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Unsupported language.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
|
||||
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import subscription_dal, user_billing_dal
|
||||
from db.models import Subscription
|
||||
@@ -326,6 +330,7 @@ async def select_tariff_period_callback(
|
||||
settings,
|
||||
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
||||
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -378,6 +383,7 @@ async def select_tariff_package_callback(
|
||||
settings,
|
||||
sale_mode=sale_mode,
|
||||
back_callback=back_callback,
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -490,6 +496,7 @@ async def select_tariff_premium_package_callback(
|
||||
settings,
|
||||
sale_mode=f"premium_topup@{tariff.key}",
|
||||
back_callback="tariff_topup:list",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -518,10 +525,14 @@ async def hwid_devices_list_callback(
|
||||
await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
|
||||
return
|
||||
tariff = config.require(active["tariff_key"])
|
||||
if tariff.billing_model != "period":
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
|
||||
if not packages:
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
renewal_available = bool(active.get("device_topup_renewal_available"))
|
||||
markup = get_hwid_device_packages_keyboard(
|
||||
tariff,
|
||||
packages,
|
||||
@@ -529,14 +540,29 @@ async def hwid_devices_list_callback(
|
||||
i18n,
|
||||
settings,
|
||||
back_callback="main_action:my_devices",
|
||||
renewal=renewal_available,
|
||||
)
|
||||
text_key = (
|
||||
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
text_key,
|
||||
date=active.get("extra_hwid_devices_valid_until_text") or "",
|
||||
),
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.message.edit_text(get_text("select_hwid_device_package"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("hwid_devices:package:"))
|
||||
@router.callback_query(F.data.startswith("hwid_devices:renewal_package:"))
|
||||
async def hwid_devices_package_callback(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
@@ -545,8 +571,11 @@ async def hwid_devices_package_callback(
|
||||
if not config or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
_, _, tariff_key, count_raw = callback.data.split(":", 3)
|
||||
_, action, tariff_key, count_raw = callback.data.split(":", 3)
|
||||
tariff = config.require(tariff_key)
|
||||
if tariff.billing_model != "period":
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
count = int(count_raw)
|
||||
package = next(
|
||||
(
|
||||
@@ -559,16 +588,39 @@ async def hwid_devices_package_callback(
|
||||
if not package:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
||||
rub_quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
currency="rub",
|
||||
)
|
||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
currency="stars",
|
||||
)
|
||||
if not rub_quote and not stars_quote:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
markup = get_payment_method_keyboard(
|
||||
count,
|
||||
package.price,
|
||||
None,
|
||||
float(rub_quote.get("price") if rub_quote else 0),
|
||||
int(stars_quote["price"])
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0
|
||||
else None,
|
||||
settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode=f"hwid_devices@{tariff.key}",
|
||||
sale_mode=f"{sale_mode_base}@{tariff.key}",
|
||||
back_callback="hwid_devices:list",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text("choose_payment_method_hwid_devices"), reply_markup=markup
|
||||
@@ -646,7 +698,9 @@ async def tariff_change_select_callback(
|
||||
if not db_sub:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
|
||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||
session, db_sub, target
|
||||
)
|
||||
rows = []
|
||||
if options["mode"] == "period_to_period":
|
||||
rows.append(
|
||||
@@ -733,7 +787,9 @@ async def tariff_change_confirm_apply_callback(
|
||||
if not db_sub:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
|
||||
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
|
||||
session, db_sub, target
|
||||
)
|
||||
if mode == "recalc_days":
|
||||
action_text = f"после перехода останется {options.get('recalc_days', 0)} дн."
|
||||
elif mode == "convert_days_to_gb":
|
||||
@@ -839,6 +895,7 @@ async def tariff_change_pay_callback(
|
||||
settings,
|
||||
sale_mode=f"tariff_upgrade@{tariff_key}",
|
||||
back_callback=f"tariff_change:confirm_pay:{tariff_key}:{amount_raw}",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text("Выберите способ оплаты", reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -1026,12 +1083,50 @@ async def my_subscription_command_handler(
|
||||
local_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, event.from_user.id
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(
|
||||
session,
|
||||
settings,
|
||||
event.from_user.id,
|
||||
local_subscription=local_sub,
|
||||
)
|
||||
install_url = install_links.personal_url
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
text = append_install_share_link_text(text, get_text, install_share_url)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for user %s.",
|
||||
event.from_user.id,
|
||||
)
|
||||
install_share_url = None
|
||||
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
|
||||
cfg_link_val = connect_button_url or config_link_display
|
||||
if cfg_link_val:
|
||||
if install_url:
|
||||
prepend_rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
web_app=WebAppInfo(url=install_url),
|
||||
)
|
||||
]
|
||||
)
|
||||
if install_share_url:
|
||||
prepend_rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=get_text("install_guide_share_button"),
|
||||
url=install_share_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
elif cfg_link_val:
|
||||
prepend_rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
@@ -1115,7 +1210,8 @@ async def my_subscription_command_handler(
|
||||
try:
|
||||
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
|
||||
if (
|
||||
tariff_for_devices.hwid_device_packages
|
||||
tariff_for_devices.billing_model == "period"
|
||||
and tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.rub
|
||||
):
|
||||
prepend_rows.append(
|
||||
@@ -1331,7 +1427,8 @@ async def my_devices_command_handler(
|
||||
try:
|
||||
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
|
||||
if (
|
||||
tariff_for_devices.hwid_device_packages
|
||||
tariff_for_devices.billing_model == "period"
|
||||
and tariff_for_devices.hwid_device_packages
|
||||
and tariff_for_devices.hwid_device_packages.rub
|
||||
):
|
||||
devices_kb.append(
|
||||
|
||||
@@ -62,7 +62,12 @@ async def select_subscription_period_callback_handler(
|
||||
from bot.payment_providers import iter_provider_specs
|
||||
|
||||
currency_methods_enabled = any(
|
||||
spec.price_source != "stars" and spec.is_enabled(settings)
|
||||
spec.price_source != "stars"
|
||||
and spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
)
|
||||
for spec in iter_provider_specs()
|
||||
)
|
||||
if currency_methods_enabled:
|
||||
@@ -104,6 +109,7 @@ async def select_subscription_period_callback_handler(
|
||||
"traffic" if traffic_mode else "subscription", callback_context
|
||||
),
|
||||
back_callback=subscription_options_callback(callback_context),
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -14,7 +14,12 @@ from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -74,6 +79,7 @@ async def request_trial_confirmation_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -104,9 +110,23 @@ async def request_trial_confirmation_handler(
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
final_message_text_in_chat = append_install_share_link_text(
|
||||
final_message_text_in_chat,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
|
||||
# Send notification to admin about new trial
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_trial_activation(
|
||||
user_id,
|
||||
end_date_obj,
|
||||
username=db_user.username if db_user else callback.from_user.username,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
)
|
||||
# Mark ad attribution trial if exists
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
@@ -139,6 +159,7 @@ async def request_trial_confirmation_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -214,6 +235,7 @@ async def confirm_activate_trial_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -243,6 +265,13 @@ async def confirm_activate_trial_handler(
|
||||
config_link=config_link_for_trial,
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
final_message_text_in_chat = append_install_share_link_text(
|
||||
final_message_text_in_chat,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
else:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
@@ -266,6 +295,7 @@ async def confirm_activate_trial_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -293,7 +323,13 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_trial_activation(
|
||||
user_id,
|
||||
end_date_obj,
|
||||
username=db_user.username if db_user else callback.from_user.username,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
)
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
from bot.utils.install_links import bot_install_guide_url
|
||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||
from config.settings import Settings
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
@@ -54,8 +57,9 @@ def payment_methods_back_callback(
|
||||
return f"tariff:package:{tariff_key}:{value}"
|
||||
if sale_base == "premium_topup" and tariff_key:
|
||||
return f"tariff:premium_package:{tariff_key}:{value}"
|
||||
if sale_base in {"hwid_device", "hwid_devices"} and tariff_key:
|
||||
return f"hwid_devices:package:{tariff_key}:{value}"
|
||||
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
|
||||
action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package"
|
||||
return f"hwid_devices:{action}:{tariff_key}:{value}"
|
||||
if sale_base == "tariff_upgrade" and tariff_key:
|
||||
amount = str(price) if price is not None else value
|
||||
return f"tariff_change:pay:{tariff_key}:{amount}"
|
||||
@@ -76,17 +80,34 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str:
|
||||
return f"tariff:select:{tariff_key}{context_suffix}"
|
||||
if sale_base in {"topup", "premium_topup"}:
|
||||
return "tariff_topup:list"
|
||||
if sale_base in {"hwid_device", "hwid_devices"}:
|
||||
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
|
||||
return "hwid_devices:list"
|
||||
return subscription_options_callback(context)
|
||||
|
||||
|
||||
def _trial_activation_button(lang: str, i18n_instance, settings: Settings) -> InlineKeyboardButton:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
trial_url = subscription_mini_app_trial_url(settings) or settings.SUBSCRIPTION_MINI_APP_URL
|
||||
return InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"),
|
||||
web_app=WebAppInfo(url=trial_url),
|
||||
)
|
||||
return InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"),
|
||||
callback_data="main_action:request_trial",
|
||||
)
|
||||
|
||||
|
||||
def get_main_menu_inline_keyboard(
|
||||
lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if show_trial_button and settings.TRIAL_ENABLED:
|
||||
builder.row(_trial_activation_button(lang, i18n_instance, settings))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -129,11 +150,7 @@ def get_bot_interface_inline_keyboard(
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if show_trial_button and settings.TRIAL_ENABLED:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial"
|
||||
)
|
||||
)
|
||||
builder.row(_trial_activation_button(lang, i18n_instance, settings))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
@@ -231,14 +248,18 @@ def get_language_selection_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
|
||||
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",
|
||||
callback_data=f"set_lang_en{callback_suffix}",
|
||||
)
|
||||
builder.button(
|
||||
text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",
|
||||
callback_data=f"set_lang_ru{callback_suffix}",
|
||||
)
|
||||
if hasattr(i18n_instance, "language_options"):
|
||||
languages = i18n_instance.language_options()
|
||||
else:
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
|
||||
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
|
||||
for language in languages:
|
||||
lang_code = language["code"]
|
||||
checked = " ✅" if current_lang == lang_code else ""
|
||||
builder.button(
|
||||
text=f"{language['flag']} {language['label']}{checked}",
|
||||
callback_data=f"set_lang_{lang_code}{callback_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -405,6 +426,7 @@ def get_hwid_device_packages_keyboard(
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
back_callback: str = "main_action:my_subscription",
|
||||
renewal: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
@@ -417,7 +439,10 @@ def get_hwid_device_packages_keyboard(
|
||||
price=package.price,
|
||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
),
|
||||
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
|
||||
callback_data=(
|
||||
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
|
||||
f"{tariff.key}:{package.count}"
|
||||
),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -436,6 +461,8 @@ def get_payment_method_keyboard(
|
||||
settings: Settings,
|
||||
sale_mode: str = "subscription",
|
||||
back_callback: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -454,7 +481,16 @@ def get_payment_method_keyboard(
|
||||
|
||||
for method in settings.payment_methods_order:
|
||||
spec = get_provider_spec(method)
|
||||
if not spec or not spec.callback_prefix or not spec.is_enabled(settings):
|
||||
if (
|
||||
not spec
|
||||
or not spec.callback_prefix
|
||||
or not spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
require_configured=False,
|
||||
)
|
||||
):
|
||||
continue
|
||||
callback_data = spec.callback_data(
|
||||
value=value_str,
|
||||
@@ -689,13 +725,29 @@ def get_connect_and_main_keyboard(
|
||||
config_link: Optional[str],
|
||||
connect_button_url: Optional[str] = None,
|
||||
preserve_message: bool = False,
|
||||
install_share_url: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
install_url = bot_install_guide_url(settings)
|
||||
button_target = connect_button_url or config_link
|
||||
|
||||
if button_target:
|
||||
if install_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
web_app=WebAppInfo(url=install_url),
|
||||
)
|
||||
)
|
||||
if install_share_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("install_guide_share_button"),
|
||||
url=install_share_url,
|
||||
)
|
||||
)
|
||||
elif button_target:
|
||||
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
|
||||
@@ -5,20 +5,17 @@ from typing import Awaitable, Callable, Optional
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.exceptions import TelegramNetworkError
|
||||
from aiogram.types import BotCommand, MenuButtonDefault, MenuButtonWebApp, WebAppInfo
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.app.controllers.dispatcher_controller import build_dispatcher
|
||||
from bot.app.factories.build_services import build_core_services
|
||||
from bot.app.web.web_server import build_and_start_web_app
|
||||
from bot.handlers.admin.sync_admin import perform_sync
|
||||
from bot.infra.redis import close_redis
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.routers import build_root_router
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.settings_override_service import load_overrides_from_db
|
||||
from bot.services.locale_override_service import load_locale_overrides
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
from config.settings import Settings
|
||||
from db.database_setup import init_db_connection
|
||||
from db.database_setup import init_db, init_db_connection
|
||||
|
||||
TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0
|
||||
|
||||
@@ -156,6 +153,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
|
||||
async def _configure_mini_app_menu() -> None:
|
||||
menu_text = i18n_instance.gettext(
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
@@ -169,6 +167,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
|
||||
logging.info("STARTUP: Mini app domain registered and default menu button restored.")
|
||||
|
||||
await _run_telegram_startup_step(
|
||||
"registering mini app menu button",
|
||||
_configure_mini_app_menu,
|
||||
@@ -186,6 +185,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
await bot.set_my_commands(bot_commands)
|
||||
logging.info("STARTUP: bot command descriptions set.")
|
||||
|
||||
await _run_telegram_startup_step(
|
||||
"setting bot commands",
|
||||
_configure_bot_commands,
|
||||
@@ -200,42 +200,9 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to initialize message queue manager.")
|
||||
|
||||
# Automatic sync on startup — runs in background so the dispatcher can
|
||||
# start serving Telegram webhooks immediately even if the panel is slow.
|
||||
# perform_sync is single-flight, so concurrent admin-triggered runs will
|
||||
# be skipped while this one is in progress.
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
async def _background_startup_sync(
|
||||
*,
|
||||
panel_service: PanelApiService,
|
||||
session_factory: sessionmaker,
|
||||
settings: Settings,
|
||||
i18n_instance: JsonI18n,
|
||||
) -> None:
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
sync_result = await perform_sync(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
settings=settings,
|
||||
i18n_instance=i18n_instance,
|
||||
)
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "N/A")
|
||||
if status == "completed":
|
||||
logging.info(f"STARTUP: Background sync completed successfully. Details: {details}")
|
||||
elif status == "skipped":
|
||||
logging.info(f"STARTUP: Background sync skipped: {details}")
|
||||
else:
|
||||
logging.warning(
|
||||
f"STARTUP: Background sync finished with status '{status}'. Details: {details}"
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Background sync failed.")
|
||||
|
||||
|
||||
async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
|
||||
|
||||
@@ -299,9 +266,10 @@ async def run_bot(settings_param: Settings):
|
||||
if local_async_session_factory is None:
|
||||
logging.critical("Failed to initialize database connection and session factory. Exiting.")
|
||||
return
|
||||
await load_overrides_from_db(settings_param, local_async_session_factory)
|
||||
await init_db(settings_param, local_async_session_factory)
|
||||
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
|
||||
i18n_instance = extra["i18n_instance"]
|
||||
await load_locale_overrides(i18n_instance, local_async_session_factory)
|
||||
|
||||
# Get bot username for YooKassa default return URL if needed
|
||||
actual_bot_username = "your_bot_username"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update, User
|
||||
@@ -10,14 +13,306 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
LocaleOverrides = Dict[str, Dict[str, str]]
|
||||
|
||||
_LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
||||
LANGUAGE_LABELS: Dict[str, str] = {
|
||||
"ru": "Русский",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"es": "Español",
|
||||
"fr": "Français",
|
||||
"pt-br": "Português (BR)",
|
||||
"tr": "Türkçe",
|
||||
"uk": "Українська",
|
||||
}
|
||||
LANGUAGE_FLAGS: Dict[str, str] = {
|
||||
"ru": "🇷🇺",
|
||||
"en": "🇬🇧",
|
||||
"de": "🇩🇪",
|
||||
"es": "🇪🇸",
|
||||
"fr": "🇫🇷",
|
||||
"pt-br": "🇧🇷",
|
||||
"tr": "🇹🇷",
|
||||
"uk": "🇺🇦",
|
||||
}
|
||||
DEFAULT_LANGUAGE_ORDER = ("ru", "en")
|
||||
LOCALE_KEY_ALIASES: Dict[str, str] = {
|
||||
"admin_apply": "wa_apply",
|
||||
"admin_ads_col_status": "admin_status",
|
||||
"admin_ad_label_source": "admin_ads_col_source",
|
||||
"admin_back": "wa_back",
|
||||
"admin_btn_refresh": "admin_refresh",
|
||||
"admin_btn_save": "admin_save",
|
||||
"admin_btn_saving": "admin_saving",
|
||||
"admin_close": "wa_close",
|
||||
"admin_copied": "wa_copied",
|
||||
"admin_copy": "wa_copy",
|
||||
"admin_csv_amount": "admin_amount",
|
||||
"admin_csv_description": "admin_description",
|
||||
"admin_csv_payment_id": "admin_id",
|
||||
"admin_csv_status": "admin_status",
|
||||
"admin_link_copied": "wa_link_copied",
|
||||
"admin_next": "wa_next",
|
||||
"admin_payment_detail_copied": "wa_copied",
|
||||
"admin_payment_detail_provider": "admin_provider",
|
||||
"admin_payment_detail_provider_section": "admin_provider",
|
||||
"admin_payment_detail_user_section": "admin_user",
|
||||
"admin_payments_col_user_id": "admin_id",
|
||||
"admin_promo_col_code": "admin_promo_csv_code",
|
||||
"admin_promo_col_status": "admin_status",
|
||||
"admin_promo_csv_is_active": "admin_badge_active",
|
||||
"admin_promo_csv_status": "admin_status",
|
||||
"admin_promo_label_code": "admin_promo_csv_code",
|
||||
"admin_promo_unlimited_validity": "admin_promo_unlimited",
|
||||
"admin_stats_revenue_custom_range_apply": "wa_apply",
|
||||
"admin_stats_revenue_tooltip_amount": "admin_amount",
|
||||
"admin_stats_sync_status": "admin_status",
|
||||
"admin_status_active": "admin_badge_active",
|
||||
"admin_support_category": "wa_support_category",
|
||||
"admin_support_category_account": "wa_support_category_account",
|
||||
"admin_support_category_billing": "wa_support_category_billing",
|
||||
"admin_support_category_other": "wa_support_category_other",
|
||||
"admin_support_category_technical": "wa_support_category_technical",
|
||||
"admin_support_close_ticket": "wa_close",
|
||||
"admin_support_empty": "wa_support_empty",
|
||||
"admin_support_filter_active": "wa_support_filter_active",
|
||||
"admin_support_filter_all": "wa_support_filter_all",
|
||||
"admin_support_internal_note": "wa_support_internal_note",
|
||||
"admin_support_no_messages": "wa_support_no_messages",
|
||||
"admin_support_priority": "wa_support_priority",
|
||||
"admin_support_priority_high": "wa_support_priority_high",
|
||||
"admin_support_priority_low": "wa_support_priority_low",
|
||||
"admin_support_priority_normal": "wa_support_priority_normal",
|
||||
"admin_support_priority_urgent": "wa_support_priority_urgent",
|
||||
"admin_support_role_system": "wa_support_role_system",
|
||||
"admin_support_role_user": "admin_user",
|
||||
"admin_support_search": "admin_search",
|
||||
"admin_support_status": "admin_status",
|
||||
"admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin",
|
||||
"admin_support_status_awaiting_user": "wa_support_status_awaiting_user",
|
||||
"admin_support_status_closed": "wa_support_status_closed",
|
||||
"admin_support_status_open": "wa_support_status_open",
|
||||
"admin_support_status_resolved": "wa_support_status_resolved",
|
||||
"admin_support_ticket_number": "wa_support_ticket_number",
|
||||
"admin_support_user_context": "admin_user",
|
||||
"admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages",
|
||||
"admin_tariffs_stat_enabled": "admin_enabled",
|
||||
"admin_user_btn_cancel": "wa_cancel",
|
||||
"admin_user_history_until": "wa_until_date",
|
||||
"admin_user_label_provider": "admin_provider",
|
||||
"admin_user_short": "admin_user",
|
||||
"admin_user_stats_total_label": "admin_total",
|
||||
"back_to_autopay_method_choice_button": "back_to_main_menu_button",
|
||||
"back_to_payment_methods_button": "back_to_main_menu_button",
|
||||
"cancel_broadcast_button": "cancel_button",
|
||||
"csv_no": "no_button",
|
||||
"csv_yes": "yes_button",
|
||||
"user_premium_override_status_unlimited": "user_regular_override_status_unlimited",
|
||||
"user_regular_override_save": "admin_save",
|
||||
"wa_devices_disconnect_title": "wa_devices_disconnect",
|
||||
"wa_install_link_copied": "wa_link_copied",
|
||||
"wa_link_email_modal_title": "wa_settings_link_email_action",
|
||||
}
|
||||
|
||||
|
||||
def resolve_locale_key(key: object) -> str:
|
||||
value = str(key or "").strip()
|
||||
seen: Set[str] = set()
|
||||
while value in LOCALE_KEY_ALIASES and value not in seen:
|
||||
seen.add(value)
|
||||
value = LOCALE_KEY_ALIASES[value]
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_locale_language_code(value: str) -> bool:
|
||||
return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value))
|
||||
|
||||
|
||||
def normalize_locale_language_code(
|
||||
raw: object,
|
||||
valid_languages: Optional[Set[str]] = None,
|
||||
*,
|
||||
prefer_known_base: bool = True,
|
||||
) -> str:
|
||||
value = str(raw or "").strip().lower().replace("_", "-")
|
||||
if not value:
|
||||
return ""
|
||||
if prefer_known_base and valid_languages and value not in valid_languages:
|
||||
base = value.split("-", 1)[0]
|
||||
if base in valid_languages:
|
||||
return base
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str:
|
||||
return normalize_locale_language_code(raw, valid_languages)
|
||||
|
||||
|
||||
def locale_language_label(code: object) -> str:
|
||||
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||
return LANGUAGE_LABELS.get(value, value.upper())
|
||||
|
||||
|
||||
def locale_language_flag(code: object) -> str:
|
||||
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||
return LANGUAGE_FLAGS.get(value, "🏳️")
|
||||
|
||||
|
||||
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
||||
normalized = {normalize_locale_language_code(code, prefer_known_base=False) for code in codes}
|
||||
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
|
||||
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
|
||||
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
|
||||
return [*preferred, *rest]
|
||||
|
||||
|
||||
def locale_language_options(
|
||||
codes: Iterable[object],
|
||||
*,
|
||||
base_languages: Iterable[object] = (),
|
||||
) -> List[Dict[str, Any]]:
|
||||
base_set = set(sort_locale_language_codes(base_languages))
|
||||
return [
|
||||
{
|
||||
"code": code,
|
||||
"label": locale_language_label(code),
|
||||
"flag": locale_language_flag(code),
|
||||
"base": code in base_set,
|
||||
}
|
||||
for code in sort_locale_language_codes(codes)
|
||||
]
|
||||
|
||||
|
||||
def _valid_locale_keys_by_language(
|
||||
locales_data: Dict[str, Dict[str, str]],
|
||||
) -> Dict[str, Set[str]]:
|
||||
return {
|
||||
lang: {str(key) for key in messages.keys()}
|
||||
for lang, messages in locales_data.items()
|
||||
if isinstance(messages, dict)
|
||||
}
|
||||
|
||||
|
||||
def normalize_locale_overrides_payload(
|
||||
payload: object,
|
||||
*,
|
||||
valid_languages: Optional[Iterable[str]] = None,
|
||||
valid_keys_by_language: Optional[Dict[str, Set[str]]] = None,
|
||||
allow_extra_languages: bool = False,
|
||||
key_aliases: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[LocaleOverrides, Dict[str, str]]:
|
||||
"""Normalize a user/admin supplied locale override JSON payload.
|
||||
|
||||
The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``.
|
||||
For convenience, files may also wrap it as ``{"overrides": {...}}`` or
|
||||
``{"locales": {...}}``.
|
||||
"""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return {}, {"_payload": "invalid_payload"}
|
||||
|
||||
raw_payload = payload
|
||||
for wrapper_key in ("overrides", "locales"):
|
||||
wrapped = raw_payload.get(wrapper_key)
|
||||
if isinstance(wrapped, dict):
|
||||
raw_payload = wrapped
|
||||
break
|
||||
|
||||
valid_lang_set = {str(lang).lower() for lang in valid_languages or []}
|
||||
aliases = key_aliases or LOCALE_KEY_ALIASES
|
||||
|
||||
def resolve_payload_key(raw_key: str) -> str:
|
||||
value = raw_key
|
||||
seen: Set[str] = set()
|
||||
while value in aliases and value not in seen:
|
||||
seen.add(value)
|
||||
value = aliases[value]
|
||||
return value
|
||||
|
||||
all_valid_keys: Set[str] = set()
|
||||
if valid_keys_by_language:
|
||||
for keys in valid_keys_by_language.values():
|
||||
all_valid_keys.update(str(key) for key in keys)
|
||||
|
||||
overrides: LocaleOverrides = {}
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
for raw_lang, raw_messages in raw_payload.items():
|
||||
lang = normalize_locale_language_code(
|
||||
raw_lang,
|
||||
valid_lang_set or None,
|
||||
prefer_known_base=not allow_extra_languages,
|
||||
)
|
||||
error_key = str(raw_lang or "_language")
|
||||
if not lang:
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
if valid_lang_set and lang not in valid_lang_set:
|
||||
if not allow_extra_languages:
|
||||
errors[error_key] = "unknown_language"
|
||||
continue
|
||||
if not is_valid_locale_language_code(lang):
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
elif allow_extra_languages and not is_valid_locale_language_code(lang):
|
||||
errors[error_key] = "invalid_language"
|
||||
continue
|
||||
if not isinstance(raw_messages, dict):
|
||||
errors[lang] = "invalid_language_bucket"
|
||||
continue
|
||||
|
||||
lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set()
|
||||
bucket: Dict[str, str] = {}
|
||||
for raw_key, raw_value in raw_messages.items():
|
||||
raw_key_text = str(raw_key or "").strip()
|
||||
key = resolve_payload_key(raw_key_text)
|
||||
item_error_key = f"{lang}.{raw_key_text or '_key'}"
|
||||
if not raw_key_text or not key:
|
||||
errors[item_error_key] = "invalid_key"
|
||||
continue
|
||||
if all_valid_keys and key not in all_valid_keys and key not in lang_keys:
|
||||
errors[item_error_key] = "unknown_key"
|
||||
continue
|
||||
if raw_value is None:
|
||||
continue
|
||||
if not isinstance(raw_value, str):
|
||||
errors[item_error_key] = "invalid_value"
|
||||
continue
|
||||
if len(raw_value) > 20000:
|
||||
errors[item_error_key] = "value_too_long"
|
||||
continue
|
||||
if raw_key_text in aliases and key in bucket:
|
||||
continue
|
||||
bucket[key] = raw_value
|
||||
if bucket:
|
||||
overrides[lang] = dict(sorted(bucket.items()))
|
||||
|
||||
return dict(sorted(overrides.items())), errors
|
||||
|
||||
|
||||
class JsonI18n:
|
||||
def __init__(self, path: str, default: str = "en", domain: str = "bot"):
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
default: str = "en",
|
||||
domain: str = "bot",
|
||||
overrides_path: Optional[str] = None,
|
||||
):
|
||||
self.domain = domain
|
||||
self.path = path
|
||||
self.default_lang = default
|
||||
self.base_locales_data: Dict[str, Dict[str, str]] = {}
|
||||
self.locale_overrides: LocaleOverrides = {}
|
||||
self.locales_data: Dict[str, Dict[str, str]] = {}
|
||||
self._overrides_path: Optional[Path] = None
|
||||
self._overrides_file_mtime_ns: Optional[int] = None
|
||||
self._overrides_file_next_check = 0.0
|
||||
self._overrides_file_check_interval_seconds = 1.0
|
||||
self._load_locales()
|
||||
if overrides_path:
|
||||
self.configure_overrides_file(overrides_path)
|
||||
self.reload_overrides_from_file(force=True)
|
||||
logging.info(
|
||||
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
|
||||
)
|
||||
@@ -26,13 +321,26 @@ class JsonI18n:
|
||||
if not os.path.isdir(self.path):
|
||||
logging.error(f"Locales path not found or not a directory: {self.path}")
|
||||
return
|
||||
loaded: Dict[str, Dict[str, str]] = {}
|
||||
for item in os.listdir(self.path):
|
||||
if item.endswith(".json"):
|
||||
lang_code = item.split(".")[0]
|
||||
file_path = os.path.join(self.path, item)
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
self.locales_data[lang_code] = json.load(f)
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
loaded[lang_code] = {
|
||||
str(key): str(value)
|
||||
for key, value in data.items()
|
||||
if isinstance(value, str)
|
||||
}
|
||||
else:
|
||||
logging.error(
|
||||
"Locale %s from %s is not a JSON object",
|
||||
lang_code,
|
||||
file_path,
|
||||
)
|
||||
except json.JSONDecodeError as e_json_load:
|
||||
logging.error(
|
||||
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
|
||||
@@ -42,24 +350,143 @@ class JsonI18n:
|
||||
f"Error loading locale {lang_code} from {file_path}: {e_load}",
|
||||
exc_info=True,
|
||||
)
|
||||
self.base_locales_data = loaded
|
||||
self._rebuild_effective_locales()
|
||||
|
||||
def _rebuild_effective_locales(self) -> None:
|
||||
effective: Dict[str, Dict[str, str]] = {}
|
||||
for lang, messages in self.base_locales_data.items():
|
||||
merged = dict(messages)
|
||||
merged.update(self.locale_overrides.get(lang, {}))
|
||||
effective[lang] = merged
|
||||
fallback_base = (
|
||||
self.base_locales_data.get(self.default_lang)
|
||||
or self.base_locales_data.get("en")
|
||||
or next(iter(self.base_locales_data.values()), {})
|
||||
)
|
||||
for lang, messages in self.locale_overrides.items():
|
||||
if lang in effective:
|
||||
continue
|
||||
merged = dict(fallback_base)
|
||||
merged.update(messages)
|
||||
effective[lang] = merged
|
||||
self.locales_data = effective
|
||||
|
||||
def _valid_keys_by_language(self) -> Dict[str, Set[str]]:
|
||||
return _valid_locale_keys_by_language(self.base_locales_data)
|
||||
|
||||
def language_options(self) -> List[Dict[str, Any]]:
|
||||
self.reload_overrides_from_file()
|
||||
return locale_language_options(
|
||||
self.locales_data.keys(),
|
||||
base_languages=self.base_locales_data.keys(),
|
||||
)
|
||||
|
||||
def set_locale_overrides(self, overrides: object) -> Dict[str, str]:
|
||||
normalized, errors = normalize_locale_overrides_payload(
|
||||
overrides,
|
||||
valid_languages=set(self.base_locales_data.keys()),
|
||||
valid_keys_by_language=self._valid_keys_by_language(),
|
||||
allow_extra_languages=True,
|
||||
)
|
||||
if errors:
|
||||
logging.warning("Some locale overrides were skipped: %s", errors)
|
||||
self.locale_overrides = normalized
|
||||
self._rebuild_effective_locales()
|
||||
return errors
|
||||
|
||||
def configure_overrides_file(self, path: str | Path) -> None:
|
||||
self._overrides_path = Path(path)
|
||||
try:
|
||||
self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns
|
||||
except FileNotFoundError:
|
||||
self._overrides_file_mtime_ns = None
|
||||
except OSError as exc:
|
||||
logging.warning("Failed to stat locale overrides file %s: %s", path, exc)
|
||||
self._overrides_file_mtime_ns = None
|
||||
|
||||
def reload_overrides_from_file(self, *, force: bool = False) -> bool:
|
||||
if self._overrides_path is None:
|
||||
return False
|
||||
now = time.monotonic()
|
||||
if not force and now < self._overrides_file_next_check:
|
||||
return False
|
||||
self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds
|
||||
|
||||
try:
|
||||
stat = self._overrides_path.stat()
|
||||
except FileNotFoundError:
|
||||
if self._overrides_file_mtime_ns is None:
|
||||
return False
|
||||
self._overrides_file_mtime_ns = None
|
||||
logging.info(
|
||||
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||
"the DB fallback is reloaded"
|
||||
)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to stat locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
logging.warning(
|
||||
"Failed to parse locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
return False
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to read locale overrides file %s: %s",
|
||||
self._overrides_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
self.set_locale_overrides(payload)
|
||||
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||
return True
|
||||
|
||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
||||
self.reload_overrides_from_file()
|
||||
lookup_key = resolve_locale_key(key)
|
||||
|
||||
requested_lang_code = normalize_locale_language_code(
|
||||
lang_code,
|
||||
set(self.locales_data.keys()),
|
||||
prefer_known_base=False,
|
||||
)
|
||||
requested_base_lang_code = requested_lang_code.split("-", 1)[0]
|
||||
|
||||
# Determine effective language with robust fallback
|
||||
if lang_code and lang_code in self.locales_data:
|
||||
effective_lang_code = lang_code
|
||||
if requested_lang_code and requested_lang_code in self.locales_data:
|
||||
effective_lang_code = requested_lang_code
|
||||
elif requested_base_lang_code and requested_base_lang_code in self.locales_data:
|
||||
effective_lang_code = requested_base_lang_code
|
||||
elif self.default_lang in self.locales_data:
|
||||
effective_lang_code = self.default_lang
|
||||
elif "en" in self.locales_data:
|
||||
effective_lang_code = "en"
|
||||
else:
|
||||
effective_lang_code = lang_code or self.default_lang
|
||||
effective_lang_code = requested_lang_code or self.default_lang
|
||||
|
||||
lang_data = self.locales_data.get(effective_lang_code)
|
||||
if lang_data is None:
|
||||
# Try explicit fallback to English if available
|
||||
fallback_data = self.locales_data.get("en")
|
||||
if fallback_data is not None:
|
||||
text = fallback_data.get(key)
|
||||
text = fallback_data.get(lookup_key)
|
||||
if text is not None:
|
||||
try:
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
@@ -70,11 +497,11 @@ class JsonI18n:
|
||||
)
|
||||
return key.format(**kwargs) if kwargs else key
|
||||
|
||||
text = lang_data.get(key)
|
||||
text = lang_data.get(lookup_key)
|
||||
if text is None:
|
||||
if effective_lang_code != self.default_lang:
|
||||
default_lang_data = self.locales_data.get(self.default_lang, {})
|
||||
text = default_lang_data.get(key)
|
||||
text = default_lang_data.get(lookup_key)
|
||||
|
||||
if text is None:
|
||||
logging.warning(
|
||||
|
||||
@@ -8,7 +8,7 @@ from aiogram.types import User as TgUser
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
@@ -55,22 +55,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
|
||||
)
|
||||
|
||||
# Also update description on panel if linked
|
||||
# Keep panel identity fields fresh, but do not rewrite
|
||||
# description from profile changes. Remnawave may return
|
||||
# description with lossy encoding in list views.
|
||||
try:
|
||||
panel_service = data.get("panel_service")
|
||||
if panel_service and db_user.panel_user_uuid:
|
||||
description_text = "\n".join(
|
||||
[
|
||||
db_user.email or "",
|
||||
username_for_display(tg_user.username, with_at=False)
|
||||
if sanitized_username is not None
|
||||
else "",
|
||||
sanitized_first_name or "",
|
||||
sanitized_last_name or "",
|
||||
]
|
||||
).strip()
|
||||
panel_payload = {
|
||||
"description": description_text,
|
||||
"telegramId": tg_user.id,
|
||||
}
|
||||
if db_user.email:
|
||||
@@ -81,7 +72,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
)
|
||||
except Exception as e_upd_desc:
|
||||
logging.warning(
|
||||
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
|
||||
f"ProfileSyncMiddleware: Failed to update panel identity for user {tg_user.id}: {e_upd_desc}" # noqa: E501
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
|
||||
@@ -22,6 +22,7 @@ from .registry import (
|
||||
iter_unique_provider_routers,
|
||||
manifest_field_default,
|
||||
pending_statuses,
|
||||
provider_admin_only_pairs,
|
||||
provider_emoji_map,
|
||||
provider_label_map,
|
||||
provider_telegram_button_text,
|
||||
@@ -53,6 +54,7 @@ __all__ = [
|
||||
"pending_statuses",
|
||||
"provider_telegram_button_text",
|
||||
"provider_emoji_map",
|
||||
"provider_admin_only_pairs",
|
||||
"provider_label_map",
|
||||
"provider_webhook_metadata",
|
||||
"resolve_provider_presentation",
|
||||
|
||||
@@ -28,6 +28,8 @@ class ProviderEnvConfig(BaseSettings):
|
||||
env vars it consumes — no edits in the global ``Settings`` required.
|
||||
"""
|
||||
|
||||
ADMIN_ONLY_ENABLED: bool = False
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
@@ -36,6 +38,15 @@ class ProviderEnvConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
def provider_runtime_enabled(config: Any, *admin_only_attrs: str) -> bool:
|
||||
"""Return True when a provider should run for public or admin-only payments."""
|
||||
|
||||
if bool(getattr(config, "ENABLED", False)):
|
||||
return True
|
||||
attrs = admin_only_attrs or ("ADMIN_ONLY_ENABLED",)
|
||||
return any(bool(getattr(config, attr, False)) for attr in attrs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfigBundle:
|
||||
"""Functional config + presentation overrides for a single provider."""
|
||||
@@ -68,6 +79,9 @@ class ProviderManifestField:
|
||||
attr: Optional[str] = (
|
||||
None # attribute name on the target model; defaults to key without env_prefix
|
||||
)
|
||||
i18n_label_key: Optional[str] = None
|
||||
i18n_description_key: Optional[str] = None
|
||||
i18n_subsection_key: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,6 +113,11 @@ class WebAppPaymentContext:
|
||||
description: str
|
||||
sale_mode: str
|
||||
traffic_gb: Optional[float] = None
|
||||
hwid_valid_from: Optional[Any] = None
|
||||
hwid_valid_until: Optional[Any] = None
|
||||
hwid_pricing_period_months: Optional[int] = None
|
||||
hwid_proration_ratio: Optional[float] = None
|
||||
hwid_full_price: Optional[float] = None
|
||||
|
||||
|
||||
EnabledPredicate = Callable[[Any], bool]
|
||||
@@ -135,11 +154,23 @@ class PaymentProviderSpec:
|
||||
config_class: Optional[Type[ProviderEnvConfig]] = None
|
||||
presentation_class: Optional[Type[ProviderEnvConfig]] = None
|
||||
manifest_fields: Sequence[ProviderManifestField] = ()
|
||||
enabled_manifest_key: Optional[str] = None
|
||||
admin_only_manifest_key: Optional[str] = None
|
||||
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
|
||||
admin_only_enabled: Optional[EnabledPredicate] = None
|
||||
|
||||
@property
|
||||
def settings_key(self) -> str:
|
||||
return self.id.upper()
|
||||
|
||||
@property
|
||||
def enabled_field_key(self) -> str:
|
||||
return self.enabled_manifest_key or f"{self.settings_key}_ENABLED"
|
||||
|
||||
@property
|
||||
def admin_only_field_key(self) -> str:
|
||||
return self.admin_only_manifest_key or f"{self.settings_key}_ADMIN_ONLY_ENABLED"
|
||||
|
||||
@property
|
||||
def default_telegram_emoji(self) -> str:
|
||||
return self.telegram_emoji or self.emoji
|
||||
@@ -148,7 +179,7 @@ class PaymentProviderSpec:
|
||||
def method_ids(self) -> tuple[str, ...]:
|
||||
return (self.id, *tuple(self.aliases))
|
||||
|
||||
def is_enabled(self, source: Any) -> bool:
|
||||
def _predicate_value(self, predicate: EnabledPredicate, source: Any) -> bool:
|
||||
# If this spec carries a provider-local config_class, prefer the live
|
||||
# config bundle so callers can pass plain Settings without having to
|
||||
# know about provider-local env layouts.
|
||||
@@ -157,8 +188,46 @@ class PaymentProviderSpec:
|
||||
|
||||
bundle = get_provider_bundle(self.service_key)
|
||||
if bundle and bundle.config is not None:
|
||||
return bool(self.enabled(bundle.config))
|
||||
return bool(self.enabled(source))
|
||||
return bool(predicate(bundle.config))
|
||||
return bool(predicate(source))
|
||||
|
||||
def is_enabled(self, source: Any) -> bool:
|
||||
return self._predicate_value(self.enabled, source)
|
||||
|
||||
def is_admin_only_enabled(self, source: Any) -> bool:
|
||||
if self.admin_only_enabled is not None:
|
||||
return self._predicate_value(self.admin_only_enabled, source)
|
||||
if self.config_class is not None and self.service_key:
|
||||
from .registry import get_provider_bundle
|
||||
|
||||
bundle = get_provider_bundle(self.service_key)
|
||||
if bundle and bundle.config is not None:
|
||||
return bool(getattr(bundle.config, self.admin_only_config_attr, False))
|
||||
return bool(getattr(source, self.admin_only_field_key, False))
|
||||
|
||||
def is_effectively_enabled(self, source: Any) -> bool:
|
||||
return self.is_enabled(source) or self.is_admin_only_enabled(source)
|
||||
|
||||
def _is_admin_user(
|
||||
self,
|
||||
source: Any,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
) -> bool:
|
||||
if is_admin is not None:
|
||||
return bool(is_admin)
|
||||
if user_id is None:
|
||||
return False
|
||||
try:
|
||||
normalized_user_id = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
try:
|
||||
admin_ids = {int(item) for item in (getattr(source, "ADMIN_IDS", None) or [])}
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return normalized_user_id in admin_ids
|
||||
|
||||
def is_service_configured(self, app: Any) -> bool:
|
||||
if not self.requires_configured_service:
|
||||
@@ -171,6 +240,43 @@ class PaymentProviderSpec:
|
||||
def is_visible(self, source: Any, app: Any) -> bool:
|
||||
return self.is_enabled(source) and self.is_service_configured(app)
|
||||
|
||||
def is_available_to_user(
|
||||
self,
|
||||
source: Any,
|
||||
app: Any = None,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
require_configured: bool = True,
|
||||
) -> bool:
|
||||
public_enabled = self.is_enabled(source)
|
||||
admin_only_visible = self.is_admin_only_enabled(source) and self._is_admin_user(
|
||||
source,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if not (public_enabled or admin_only_visible):
|
||||
return False
|
||||
if require_configured and app is not None and not self.is_service_configured(app):
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_visible_for_user(
|
||||
self,
|
||||
source: Any,
|
||||
app: Any,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
) -> bool:
|
||||
return self.is_available_to_user(
|
||||
source,
|
||||
app,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
require_configured=True,
|
||||
)
|
||||
|
||||
def load_router(self) -> Any:
|
||||
return self.router
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
PaymentSuccessRequest,
|
||||
@@ -37,9 +38,12 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
quote_hwid_callback_parts,
|
||||
render_payment_link,
|
||||
sale_mode_base,
|
||||
sale_mode_is_traffic,
|
||||
sale_mode_tariff_key,
|
||||
)
|
||||
|
||||
@@ -119,7 +123,7 @@ class CryptoPayService:
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.config.ENABLED and self.config.TOKEN)
|
||||
return bool(provider_runtime_enabled(self.config) and self.config.TOKEN)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
@@ -154,13 +158,14 @@ class CryptoPayService:
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
@@ -176,7 +181,17 @@ class CryptoPayService:
|
||||
"provider": "cryptopay",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_gb": amounts.purchased_gb,
|
||||
"purchased_hwid_devices": amounts.purchased_hwid_devices,
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio")
|
||||
if hwid_quote
|
||||
else None,
|
||||
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -191,7 +206,7 @@ class CryptoPayService:
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if is_traffic else None,
|
||||
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -255,6 +270,14 @@ class CryptoPayService:
|
||||
referral_service: ReferralService = app["referral_service"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("CryptoPay webhook: payment %s not found.", payment_db_id)
|
||||
return
|
||||
if payment.status == "succeeded":
|
||||
logging.info("CryptoPay webhook: payment %s already succeeded.", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -349,11 +372,29 @@ async def pay_crypto_callback_handler(
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
parts = parse_payment_callback(callback.data or "")
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=cryptopay_service.subscription_service,
|
||||
currency="rub",
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
@@ -366,6 +407,7 @@ async def pay_crypto_callback_handler(
|
||||
amount=parts.price,
|
||||
description=payment_description,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
@@ -415,6 +457,15 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
description=ctx.description,
|
||||
sale_mode=ctx.sale_mode,
|
||||
url_kind="web",
|
||||
hwid_quote={
|
||||
"valid_from": ctx.hwid_valid_from,
|
||||
"valid_until": ctx.hwid_valid_until,
|
||||
"pricing_period_months": ctx.hwid_pricing_period_months,
|
||||
"proration_ratio": ctx.hwid_proration_ratio,
|
||||
"full_price": ctx.hwid_full_price,
|
||||
}
|
||||
if ctx.hwid_valid_from and ctx.hwid_valid_until
|
||||
else None,
|
||||
)
|
||||
if not url:
|
||||
return payment_failed()
|
||||
|
||||
@@ -29,6 +29,7 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
@@ -48,7 +49,9 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
)
|
||||
|
||||
@@ -152,14 +155,14 @@ class FreeKassaService(HttpClientMixin):
|
||||
logging.warning(
|
||||
"FreeKassaService initialized but not fully configured. Payments disabled."
|
||||
)
|
||||
if config.ENABLED and not self.server_ip:
|
||||
if provider_runtime_enabled(config) and not self.server_ip:
|
||||
logging.warning(
|
||||
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
|
||||
)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.config.ENABLED and self.shop_id and self.api_key)
|
||||
return bool(provider_runtime_enabled(self.config) and self.shop_id and self.api_key)
|
||||
|
||||
@property
|
||||
def shop_id(self):
|
||||
@@ -380,10 +383,10 @@ class FreeKassaService(HttpClientMixin):
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
success_prefix: Optional[str] = None
|
||||
if provider_payment_id:
|
||||
@@ -451,6 +454,14 @@ async def pay_fk_callback_handler(
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
if not freekassa_service or not freekassa_service.configured:
|
||||
logging.error("FreeKassa service is not configured or unavailable.")
|
||||
await notify_service_unavailable(callback, translator)
|
||||
@@ -461,6 +472,16 @@ async def pay_fk_callback_handler(
|
||||
logging.error("Invalid pay_fk data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=freekassa_service.subscription_service,
|
||||
currency="rub",
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = (
|
||||
getattr(freekassa_service, "default_currency", None)
|
||||
@@ -477,6 +498,7 @@ async def pay_fk_callback_handler(
|
||||
months=parts.months,
|
||||
provider="freekassa",
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -27,6 +27,7 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
@@ -48,6 +49,8 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
)
|
||||
|
||||
@@ -241,7 +244,7 @@ class HeleketService(HttpClientMixin):
|
||||
# ``False`` state from startup and the button would never appear.
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.config.ENABLED and self.merchant_id and self.api_key)
|
||||
return bool(provider_runtime_enabled(self.config) and self.merchant_id and self.api_key)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
@@ -467,10 +470,10 @@ class HeleketService(HttpClientMixin):
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
@@ -546,6 +549,14 @@ async def pay_heleket_callback_handler(
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
if not heleket_service or not heleket_service.configured:
|
||||
logging.error("Heleket service is not configured or unavailable.")
|
||||
await notify_service_unavailable(callback, translator)
|
||||
@@ -556,6 +567,16 @@ async def pay_heleket_callback_handler(
|
||||
logging.error("Invalid pay_heleket data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=heleket_service.subscription_service,
|
||||
currency="rub",
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
payment_description = describe_payment(translator, parts)
|
||||
@@ -568,6 +589,7 @@ async def pay_heleket_callback_handler(
|
||||
months=parts.months,
|
||||
provider="heleket",
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -23,6 +23,7 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
@@ -44,7 +45,9 @@ from .shared import (
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
safe_callback_answer,
|
||||
)
|
||||
@@ -66,7 +69,9 @@ class PlategaConfig(ProviderEnvConfig):
|
||||
SECRET: Optional[str] = None
|
||||
PAYMENT_METHOD: int = Field(default=2)
|
||||
SBP_ENABLED: bool = Field(default=False)
|
||||
SBP_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
||||
CRYPTO_ENABLED: bool = Field(default=False)
|
||||
CRYPTO_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
||||
SBP_METHOD: int = Field(default=2)
|
||||
CRYPTO_METHOD: int = Field(default=13)
|
||||
RETURN_URL: Optional[str] = None
|
||||
@@ -161,7 +166,15 @@ class PlategaService(HttpClientMixin):
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.config.ENABLED and self.merchant_id and self.secret)
|
||||
return bool(
|
||||
provider_runtime_enabled(
|
||||
self.config,
|
||||
"SBP_ADMIN_ONLY_ENABLED",
|
||||
"CRYPTO_ADMIN_ONLY_ENABLED",
|
||||
)
|
||||
and self.merchant_id
|
||||
and self.secret
|
||||
)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
@@ -291,10 +304,10 @@ class PlategaService(HttpClientMixin):
|
||||
if payment.status == "succeeded" and status == "CONFIRMED":
|
||||
return web.Response(text="ok")
|
||||
|
||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
if status == "CONFIRMED":
|
||||
if amount_raw is not None:
|
||||
@@ -395,17 +408,23 @@ def _resolve_platega_variant(
|
||||
) -> Optional[Tuple[str, int]]:
|
||||
"""Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled."""
|
||||
if callback_prefix == "pay_platega_crypto":
|
||||
if not config.CRYPTO_ENABLED:
|
||||
if not (config.CRYPTO_ENABLED or config.CRYPTO_ADMIN_ONLY_ENABLED):
|
||||
return None
|
||||
return "crypto", config.CRYPTO_METHOD
|
||||
if callback_prefix == "pay_platega_sbp":
|
||||
if not config.SBP_ENABLED:
|
||||
if not (config.SBP_ENABLED or config.SBP_ADMIN_ONLY_ENABLED):
|
||||
return None
|
||||
return "sbp", config.sbp_method_resolved
|
||||
# Legacy "pay_platega:" callback — keep working as SBP.
|
||||
return "sbp", config.sbp_method_resolved
|
||||
|
||||
|
||||
def _platega_spec_for_callback_prefix(callback_prefix: str) -> PaymentProviderSpec:
|
||||
if callback_prefix == "pay_platega_crypto":
|
||||
return CRYPTO_SPEC
|
||||
return SBP_SPEC
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("pay_platega_sbp:")
|
||||
| F.data.startswith("pay_platega_crypto:")
|
||||
@@ -429,6 +448,15 @@ async def pay_platega_callback_handler(
|
||||
return
|
||||
|
||||
callback_prefix, _, _ = (callback.data or "").partition(":")
|
||||
spec = _platega_spec_for_callback_prefix(callback_prefix)
|
||||
if not spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
variant = (
|
||||
_resolve_platega_variant(callback_prefix, platega_service.config)
|
||||
if platega_service
|
||||
@@ -449,6 +477,16 @@ async def pay_platega_callback_handler(
|
||||
logging.error("Invalid pay_platega data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=platega_service.subscription_service,
|
||||
currency="rub",
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
payment_description = describe_payment(translator, parts)
|
||||
@@ -461,6 +499,7 @@ async def pay_platega_callback_handler(
|
||||
months=parts.months,
|
||||
provider="platega",
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -538,11 +577,13 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
if variant == "platega_crypto":
|
||||
if not service.config.CRYPTO_ENABLED:
|
||||
if not (service.config.CRYPTO_ENABLED or service.config.CRYPTO_ADMIN_ONLY_ENABLED):
|
||||
return payment_unavailable()
|
||||
platega_method_id = service.config.CRYPTO_METHOD
|
||||
else:
|
||||
if variant == "platega_sbp" and not service.config.SBP_ENABLED:
|
||||
if variant == "platega_sbp" and not (
|
||||
service.config.SBP_ENABLED or service.config.SBP_ADMIN_ONLY_ENABLED
|
||||
):
|
||||
return payment_unavailable()
|
||||
platega_method_id = service.config.sbp_method_resolved
|
||||
|
||||
@@ -738,6 +779,8 @@ SBP_SPEC = PaymentProviderSpec(
|
||||
enabled=lambda config: bool(
|
||||
getattr(config, "ENABLED", False) and getattr(config, "SBP_ENABLED", False)
|
||||
),
|
||||
admin_only_enabled=lambda config: bool(getattr(config, "SBP_ADMIN_ONLY_ENABLED", False)),
|
||||
admin_only_config_attr="SBP_ADMIN_ONLY_ENABLED",
|
||||
service_key="platega_service",
|
||||
callback_prefix="pay_platega_sbp",
|
||||
aliases=("platega",),
|
||||
@@ -749,7 +792,7 @@ SBP_SPEC = PaymentProviderSpec(
|
||||
config_class=PlategaConfig,
|
||||
presentation_class=PlategaSbpPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST
|
||||
+ _platega_presentation_manifest("Platega SBP", "CreditCard", "PLATEGA_SBP"),
|
||||
+ _platega_presentation_manifest("Platega", "CreditCard", "PLATEGA_SBP"),
|
||||
)
|
||||
|
||||
CRYPTO_SPEC = PaymentProviderSpec(
|
||||
@@ -767,12 +810,14 @@ CRYPTO_SPEC = PaymentProviderSpec(
|
||||
enabled=lambda config: bool(
|
||||
getattr(config, "ENABLED", False) and getattr(config, "CRYPTO_ENABLED", False)
|
||||
),
|
||||
admin_only_enabled=lambda config: bool(getattr(config, "CRYPTO_ADMIN_ONLY_ENABLED", False)),
|
||||
admin_only_config_attr="CRYPTO_ADMIN_ONLY_ENABLED",
|
||||
service_key="platega_service",
|
||||
callback_prefix="pay_platega_crypto",
|
||||
create_webapp_payment=create_crypto_webapp_payment,
|
||||
config_class=PlategaConfig,
|
||||
presentation_class=PlategaCryptoPresentation,
|
||||
manifest_fields=_platega_presentation_manifest("Platega Crypto", "Bitcoin", "PLATEGA_CRYPTO"),
|
||||
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
|
||||
)
|
||||
|
||||
SPECS = (SBP_SPEC, CRYPTO_SPEC)
|
||||
|
||||
@@ -321,8 +321,13 @@ def pending_statuses() -> List[str]:
|
||||
def iter_provider_manifest_fields() -> Iterable[tuple[PaymentProviderSpec, ProviderManifestField]]:
|
||||
"""Yield (spec, manifest_field) for every fragment declared on a provider SPEC."""
|
||||
for spec in PAYMENT_PROVIDER_SPECS:
|
||||
emitted_keys: set[str] = set()
|
||||
for field in spec.manifest_fields:
|
||||
emitted_keys.add(field.key)
|
||||
yield spec, field
|
||||
admin_only_field = provider_admin_only_manifest_field(spec)
|
||||
if admin_only_field is not None and admin_only_field.key not in emitted_keys:
|
||||
yield spec, admin_only_field
|
||||
|
||||
|
||||
def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, ProviderManifestField]]:
|
||||
@@ -333,6 +338,45 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide
|
||||
return None
|
||||
|
||||
|
||||
def provider_admin_only_manifest_field(
|
||||
spec: PaymentProviderSpec,
|
||||
) -> Optional[ProviderManifestField]:
|
||||
if spec.config_class is None:
|
||||
return None
|
||||
|
||||
subsection = spec.label
|
||||
for field in spec.manifest_fields:
|
||||
if field.subsection:
|
||||
subsection = field.subsection
|
||||
break
|
||||
|
||||
return ProviderManifestField(
|
||||
spec.admin_only_field_key,
|
||||
"bool",
|
||||
"Only for admins",
|
||||
(
|
||||
"Shows this payment method only to users from ADMIN_IDS. "
|
||||
"Webhooks and provider services remain active for admin test payments."
|
||||
),
|
||||
subsection=subsection,
|
||||
attr=spec.admin_only_config_attr,
|
||||
i18n_label_key="admin_settings_provider_admin_only_label",
|
||||
i18n_description_key="admin_settings_provider_admin_only_description",
|
||||
)
|
||||
|
||||
|
||||
def provider_admin_only_pairs() -> List[tuple[str, str]]:
|
||||
pairs: List[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for spec in PAYMENT_PROVIDER_SPECS:
|
||||
pair = (spec.enabled_field_key, spec.admin_only_field_key)
|
||||
if pair in seen:
|
||||
continue
|
||||
seen.add(pair)
|
||||
pairs.append(pair)
|
||||
return pairs
|
||||
|
||||
|
||||
def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]:
|
||||
if spec.webhook_path and spec.webhook_route:
|
||||
return spec
|
||||
|
||||
@@ -25,6 +25,7 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
@@ -45,7 +46,9 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
)
|
||||
|
||||
@@ -135,7 +138,7 @@ class SeverPayService(HttpClientMixin):
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.config.ENABLED and self.mid and self.token)
|
||||
return bool(provider_runtime_enabled(self.config) and self.mid and self.token)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
@@ -268,12 +271,19 @@ class SeverPayService(HttpClientMixin):
|
||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||
|
||||
resolved_provider_id = provider_payment_id or str(payment.payment_id)
|
||||
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
if status == "success":
|
||||
if payment.status == "succeeded":
|
||||
logging.info(
|
||||
"SeverPay webhook: payment %s already succeeded.",
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -395,6 +405,14 @@ async def pay_severpay_callback_handler(
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
if not severpay_service or not severpay_service.configured:
|
||||
logging.error("SeverPay service is not configured or unavailable.")
|
||||
await notify_service_unavailable(callback, translator)
|
||||
@@ -405,6 +423,16 @@ async def pay_severpay_callback_handler(
|
||||
logging.error("Invalid pay_severpay data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=severpay_service.subscription_service,
|
||||
currency="rub",
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
payment_description = describe_payment(translator, parts)
|
||||
@@ -417,6 +445,7 @@ async def pay_severpay_callback_handler(
|
||||
months=parts.months,
|
||||
provider="severpay",
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -15,6 +15,7 @@ from .callbacks import (
|
||||
notify_service_unavailable,
|
||||
parse_payment_callback,
|
||||
payment_link_message_text,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
safe_callback_answer,
|
||||
@@ -35,10 +36,12 @@ from .common import (
|
||||
json_error,
|
||||
make_translator,
|
||||
mark_payment_failed_creation,
|
||||
parse_positive_int_units,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_is_traffic,
|
||||
@@ -55,6 +58,7 @@ from .success import (
|
||||
PaymentSuccessOutcome,
|
||||
PaymentSuccessRequest,
|
||||
SuccessMessage,
|
||||
append_hwid_renewal_note,
|
||||
build_success_message,
|
||||
finalize_successful_payment,
|
||||
is_traffic_sale_base,
|
||||
@@ -82,6 +86,7 @@ __all__ = [
|
||||
"build_payment_description",
|
||||
"build_payment_record_payload",
|
||||
"build_success_message",
|
||||
"append_hwid_renewal_note",
|
||||
"coerce_payment_db_id",
|
||||
"create_base_payment_record",
|
||||
"create_webapp_payment_record",
|
||||
@@ -100,6 +105,7 @@ __all__ = [
|
||||
"lookup_payment_by_order_or_provider_id",
|
||||
"make_translator",
|
||||
"mark_payment_failed_creation",
|
||||
"parse_positive_int_units",
|
||||
"notify_admins_payment_received",
|
||||
"notify_callback_parse_error",
|
||||
"notify_payment_gateway_failure",
|
||||
@@ -111,8 +117,10 @@ __all__ = [
|
||||
"payment_link_message_text",
|
||||
"payment_link_response",
|
||||
"payment_record_amounts",
|
||||
"payment_units_for_activation",
|
||||
"payment_unavailable",
|
||||
"post_json_request",
|
||||
"quote_hwid_callback_parts",
|
||||
"render_link_or_fail",
|
||||
"render_payment_link",
|
||||
"resolve_inviter_name",
|
||||
|
||||
@@ -20,7 +20,10 @@ from .common import (
|
||||
build_payment_description,
|
||||
format_human_units,
|
||||
mark_payment_failed_creation,
|
||||
parse_positive_int_units,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_tariff_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,6 +115,37 @@ def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str
|
||||
)
|
||||
|
||||
|
||||
async def quote_hwid_callback_parts(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
parts: PaymentCallbackParts,
|
||||
subscription_service,
|
||||
currency: str = "rub",
|
||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||
return parts, None
|
||||
device_count = parse_positive_int_units(parts.months)
|
||||
if device_count is None:
|
||||
return None, None
|
||||
quote = await subscription_service.quote_hwid_device_topup(
|
||||
session,
|
||||
user_id=user_id,
|
||||
device_count=device_count,
|
||||
tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
|
||||
currency=currency,
|
||||
)
|
||||
if not quote:
|
||||
return None, None
|
||||
quoted_parts = PaymentCallbackParts(
|
||||
months=device_count,
|
||||
price=float(quote.get("price") or 0),
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
return quoted_parts, quote
|
||||
|
||||
|
||||
def payment_link_message_text(
|
||||
translator: Translator,
|
||||
parts: PaymentCallbackParts,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from aiohttp import web
|
||||
@@ -36,6 +36,20 @@ def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
|
||||
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
|
||||
|
||||
|
||||
def parse_positive_int_units(value: Any) -> Optional[int]:
|
||||
"""Return a positive integer only when the input represents whole units exactly."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
decimal_value = Decimal(str(value).strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
if not decimal_value.is_finite() or decimal_value != decimal_value.to_integral_value():
|
||||
return None
|
||||
integer_value = int(decimal_value)
|
||||
return integer_value if integer_value > 0 else None
|
||||
|
||||
|
||||
def format_human_units(value: Any) -> str:
|
||||
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
|
||||
numeric = float(value)
|
||||
@@ -60,7 +74,7 @@ def build_payment_description(
|
||||
"payment_description_traffic",
|
||||
traffic_gb=human_value if human_value is not None else format_human_units(months),
|
||||
)
|
||||
if base in {"hwid_device", "hwid_devices"}:
|
||||
if base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
|
||||
return translator("payment_description_hwid_devices", count=int(float(months)))
|
||||
return translator("payment_description_subscription", months=int(float(months)))
|
||||
|
||||
@@ -75,6 +89,7 @@ def build_payment_record_payload(
|
||||
months: Any,
|
||||
provider: str,
|
||||
sale_mode: str,
|
||||
hwid_quote: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Assemble the payment-record dict that every callback handler used to inline.
|
||||
|
||||
@@ -85,7 +100,7 @@ def build_payment_record_payload(
|
||||
base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_mode_is_traffic(sale_mode)
|
||||
is_hwid = sale_mode_is_hwid_devices(sale_mode)
|
||||
return {
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
@@ -98,6 +113,17 @@ def build_payment_record_payload(
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
|
||||
}
|
||||
if hwid_quote and is_hwid:
|
||||
payload.update(
|
||||
{
|
||||
"hwid_valid_from": hwid_quote.get("valid_from"),
|
||||
"hwid_valid_until": hwid_quote.get("valid_until"),
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
|
||||
"hwid_full_price": hwid_quote.get("full_price"),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -119,7 +145,7 @@ def sale_mode_is_traffic(sale_mode: str) -> bool:
|
||||
|
||||
|
||||
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
|
||||
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
|
||||
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||
|
||||
|
||||
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
|
||||
@@ -152,6 +178,24 @@ def payment_record_amounts(
|
||||
)
|
||||
|
||||
|
||||
def payment_units_for_activation(payment: Any, sale_mode: str) -> Any:
|
||||
"""Resolve purchased units from a payment record for webhook activation."""
|
||||
base = sale_mode_base(sale_mode)
|
||||
if sale_mode_is_traffic(base):
|
||||
return (
|
||||
getattr(payment, "purchased_gb", None)
|
||||
or getattr(payment, "subscription_duration_months", None)
|
||||
or 1
|
||||
)
|
||||
if sale_mode_is_hwid_devices(base):
|
||||
return (
|
||||
getattr(payment, "purchased_hwid_devices", None)
|
||||
or getattr(payment, "subscription_duration_months", None)
|
||||
or 1
|
||||
)
|
||||
return getattr(payment, "subscription_duration_months", None) or 1
|
||||
|
||||
|
||||
def json_error(status: int, code: str, message: str) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
|
||||
|
||||
@@ -194,6 +238,11 @@ async def create_base_payment_record(
|
||||
tariff_key: Optional[str] = None,
|
||||
purchased_gb: Optional[float] = None,
|
||||
purchased_hwid_devices: Optional[int] = None,
|
||||
hwid_valid_from: Optional[Any] = None,
|
||||
hwid_valid_until: Optional[Any] = None,
|
||||
hwid_pricing_period_months: Optional[int] = None,
|
||||
hwid_proration_ratio: Optional[float] = None,
|
||||
hwid_full_price: Optional[float] = None,
|
||||
) -> Payment:
|
||||
payment = await payment_dal.create_payment_record(
|
||||
session,
|
||||
@@ -209,6 +258,11 @@ async def create_base_payment_record(
|
||||
"tariff_key": tariff_key,
|
||||
"purchased_gb": purchased_gb,
|
||||
"purchased_hwid_devices": purchased_hwid_devices,
|
||||
"hwid_valid_from": hwid_valid_from,
|
||||
"hwid_valid_until": hwid_valid_until,
|
||||
"hwid_pricing_period_months": hwid_pricing_period_months,
|
||||
"hwid_proration_ratio": hwid_proration_ratio,
|
||||
"hwid_full_price": hwid_full_price,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -241,6 +295,11 @@ async def create_webapp_payment_record(
|
||||
tariff_key=amounts.tariff_key,
|
||||
purchased_gb=amounts.purchased_gb,
|
||||
purchased_hwid_devices=amounts.purchased_hwid_devices,
|
||||
hwid_valid_from=ctx.hwid_valid_from,
|
||||
hwid_valid_until=ctx.hwid_valid_until,
|
||||
hwid_pricing_period_months=ctx.hwid_pricing_period_months,
|
||||
hwid_proration_ratio=ctx.hwid_proration_ratio,
|
||||
hwid_full_price=ctx.hwid_full_price,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.install_links import ensure_user_install_guide_links
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
from db.dal import payment_dal, user_dal
|
||||
from db.models import Payment, User
|
||||
@@ -18,6 +19,7 @@ from db.models import Payment, User
|
||||
from .common import Translator, format_human_units, make_translator, sale_mode_base
|
||||
|
||||
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||
|
||||
|
||||
def is_traffic_sale_base(sale_base: str) -> bool:
|
||||
@@ -70,7 +72,6 @@ class SuccessMessage:
|
||||
months: Any
|
||||
base_end_date: Optional[datetime]
|
||||
final_end_date: Optional[datetime]
|
||||
config_link_text: str
|
||||
applied_referee_bonus_days: int = 0
|
||||
applied_promo_bonus_days: int = 0
|
||||
inviter_name: Optional[str] = None
|
||||
@@ -97,7 +98,11 @@ def build_success_message(payload: SuccessMessage) -> str:
|
||||
"payment_successful_traffic_full",
|
||||
traffic_gb=format_human_units(payload.months),
|
||||
end_date=end_text,
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
if base in _HWID_DEVICE_MODES:
|
||||
return _(
|
||||
"payment_successful_hwid_devices_full",
|
||||
count=format_human_units(payload.months),
|
||||
)
|
||||
if payload.applied_referee_bonus_days and payload.final_end_date:
|
||||
base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text)
|
||||
@@ -108,7 +113,6 @@ def build_success_message(payload: SuccessMessage) -> str:
|
||||
bonus_days=payload.applied_referee_bonus_days,
|
||||
final_end_date=end_text,
|
||||
inviter_name=payload.inviter_name or _("friend_placeholder"),
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
if payload.applied_promo_bonus_days and payload.final_end_date:
|
||||
return _(
|
||||
@@ -116,16 +120,36 @@ def build_success_message(payload: SuccessMessage) -> str:
|
||||
months=payload.months,
|
||||
bonus_days=payload.applied_promo_bonus_days,
|
||||
end_date=end_text,
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
return _(
|
||||
"payment_successful_full",
|
||||
months=payload.months,
|
||||
end_date=end_text,
|
||||
config_link=payload.config_link_text,
|
||||
)
|
||||
|
||||
|
||||
def append_hwid_renewal_note(
|
||||
text: str,
|
||||
translator: Translator,
|
||||
*,
|
||||
count: Any,
|
||||
valid_until: Optional[datetime],
|
||||
) -> str:
|
||||
try:
|
||||
count_int = int(count or 0)
|
||||
except (TypeError, ValueError):
|
||||
count_int = 0
|
||||
if count_int <= 0:
|
||||
return text
|
||||
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
|
||||
note = translator(
|
||||
"payment_successful_hwid_devices_renewal_note",
|
||||
count=format_human_units(count_int),
|
||||
date=date_text,
|
||||
)
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
async def send_success_message_to_user(
|
||||
*,
|
||||
bot: Bot,
|
||||
@@ -136,6 +160,7 @@ async def send_success_message_to_user(
|
||||
settings: Any,
|
||||
config_link_display: Optional[str],
|
||||
connect_button_url: Optional[str],
|
||||
install_share_url: Optional[str] = None,
|
||||
include_keyboard: bool = True,
|
||||
log_prefix: str = "payment_providers",
|
||||
) -> None:
|
||||
@@ -148,6 +173,7 @@ async def send_success_message_to_user(
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
@@ -177,6 +203,7 @@ async def notify_admins_payment_received(
|
||||
traffic_is_premium: bool,
|
||||
tariff_key: Optional[str],
|
||||
log_prefix: str = "payment_providers",
|
||||
email: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Push the standard ``notify_payment_received`` to the admin log channel."""
|
||||
try:
|
||||
@@ -189,6 +216,7 @@ async def notify_admins_payment_received(
|
||||
traffic_gb=traffic_gb_for_admin,
|
||||
payment_provider=payment_provider,
|
||||
username=username,
|
||||
email=email,
|
||||
traffic_is_premium=traffic_is_premium,
|
||||
tariff_key=tariff_key,
|
||||
)
|
||||
@@ -299,7 +327,6 @@ async def finalize_successful_payment(
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
req.settings, raw_config_link
|
||||
)
|
||||
config_link_text = config_link_display or translator("config_link_not_available")
|
||||
|
||||
base_end_date = activation.get("end_date") if activation else None
|
||||
final_end_date = base_end_date
|
||||
@@ -323,15 +350,41 @@ async def finalize_successful_payment(
|
||||
),
|
||||
base_end_date=base_end_date,
|
||||
final_end_date=final_end_date,
|
||||
config_link_text=config_link_text,
|
||||
applied_referee_bonus_days=applied_referee_bonus_days,
|
||||
applied_promo_bonus_days=applied_promo_bonus_days,
|
||||
inviter_name=inviter_name,
|
||||
)
|
||||
)
|
||||
if is_subscription and activation:
|
||||
success_text = append_hwid_renewal_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation.get("hwid_devices_valid_until"),
|
||||
)
|
||||
if req.text_prefix:
|
||||
success_text = f"{req.text_prefix}\n{success_text}"
|
||||
|
||||
install_share_url = None
|
||||
if not req.skip_keyboard:
|
||||
install_links = await ensure_user_install_guide_links(
|
||||
req.session,
|
||||
req.settings,
|
||||
req.user_id,
|
||||
)
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
await req.session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to persist install guide share token for user %s.",
|
||||
req.log_prefix,
|
||||
req.user_id,
|
||||
)
|
||||
install_share_url = None
|
||||
|
||||
await send_success_message_to_user(
|
||||
bot=req.bot,
|
||||
user_id=req.user_id,
|
||||
@@ -341,6 +394,7 @@ async def finalize_successful_payment(
|
||||
settings=req.settings,
|
||||
config_link_display=config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
include_keyboard=not req.skip_keyboard,
|
||||
log_prefix=req.log_prefix,
|
||||
)
|
||||
@@ -359,6 +413,7 @@ async def finalize_successful_payment(
|
||||
traffic_gb_for_admin=traffic_gb_for_activation,
|
||||
payment_provider=req.provider_notification,
|
||||
username=db_user.username if db_user else None,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
traffic_is_premium=base == "premium_topup",
|
||||
tariff_key=tariff_key,
|
||||
log_prefix=req.log_prefix,
|
||||
|
||||
@@ -35,6 +35,7 @@ from .shared import (
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
quote_hwid_callback_parts,
|
||||
safe_callback_answer,
|
||||
sale_mode_base,
|
||||
sale_mode_tariff_key,
|
||||
@@ -80,9 +81,10 @@ class StarsService:
|
||||
stars_price: int,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
) -> Optional[int]:
|
||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": float(stars_price),
|
||||
@@ -93,7 +95,15 @@ class StarsService:
|
||||
"provider": "telegram_stars",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_gb": amounts.purchased_gb,
|
||||
"purchased_hwid_devices": amounts.purchased_hwid_devices,
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
|
||||
}
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
@@ -133,6 +143,14 @@ class StarsService:
|
||||
i18n_data: dict,
|
||||
sale_mode: str = "subscription",
|
||||
) -> None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("Stars: payment %s not found.", payment_db_id)
|
||||
return
|
||||
if payment.status == "succeeded":
|
||||
logging.info("Stars: payment %s already succeeded.", payment_db_id)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
@@ -152,9 +170,6 @@ class StarsService:
|
||||
else int(message.from_user.id)
|
||||
)
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error("Stars: payment %s vanished after status update.", payment_db_id)
|
||||
return
|
||||
|
||||
await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
@@ -197,11 +212,25 @@ async def pay_stars_callback_handler(
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
if not SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
parts = parse_payment_callback(callback.data or "")
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=stars_service.subscription_service,
|
||||
currency="stars",
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
@@ -217,6 +246,7 @@ async def pay_stars_callback_handler(
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
@@ -440,9 +470,8 @@ SPEC = PaymentProviderSpec(
|
||||
webapp_icon="Sparkles",
|
||||
telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"},
|
||||
pending_status="pending_stars",
|
||||
# STARS_ENABLED stays on the global Settings — subscription_options reads
|
||||
# it together with STARS_PRICE_* fields, so it has cross-cutting bizlogic
|
||||
# reach beyond just the provider flag.
|
||||
# Stars toggles stay on global Settings because stars_subscription_options
|
||||
# reads them together with STARS_PRICE_* fields.
|
||||
enabled=lambda settings: bool(getattr(settings, "STARS_ENABLED", False)),
|
||||
service_key="stars_service",
|
||||
callback_prefix="pay_stars",
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiohttp import web
|
||||
@@ -28,6 +28,7 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
@@ -48,13 +49,77 @@ from .shared import (
|
||||
notify_user_payment_failed,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
safe_callback_answer,
|
||||
sale_mode_base,
|
||||
)
|
||||
|
||||
router = Router(name="user_subscription_payments_wata_router")
|
||||
_LOG = "wata"
|
||||
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
|
||||
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
|
||||
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
|
||||
_WATA_LINK_MIN_TTL_MINUTES = 15
|
||||
_WATA_LINK_MAX_TTL_MINUTES = 30 * 24 * 60
|
||||
|
||||
|
||||
def _clamp_wata_link_ttl_minutes(value: Any, *, default: int) -> int:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
try:
|
||||
minutes = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return min(_WATA_LINK_MAX_TTL_MINUTES, max(_WATA_LINK_MIN_TTL_MINUTES, minutes))
|
||||
|
||||
|
||||
def _parse_wata_datetime(raw: Any) -> Optional[datetime]:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
iso_value = str(raw).strip()
|
||||
if iso_value.endswith("Z"):
|
||||
iso_value = iso_value[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(iso_value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _wata_success_status(status: int, _body: Any) -> bool:
|
||||
return 200 <= status < 300
|
||||
|
||||
|
||||
def _normalized_wata_status(payload: Optional[Mapping[str, Any]]) -> str:
|
||||
if not payload:
|
||||
return ""
|
||||
return (
|
||||
str(
|
||||
payload.get("transactionStatus")
|
||||
or payload.get("status")
|
||||
or payload.get("statusName")
|
||||
or ""
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
|
||||
|
||||
def _wata_transaction_id(payload: Optional[Mapping[str, Any]]) -> Optional[str]:
|
||||
return first_value(payload, "transactionId", "id")
|
||||
|
||||
|
||||
def _wata_payment_link_id(payload: Optional[Mapping[str, Any]]) -> Optional[str]:
|
||||
return first_value(payload, "paymentLinkId", "payment_link_id")
|
||||
|
||||
|
||||
class WataConfig(ProviderEnvConfig):
|
||||
@@ -70,21 +135,15 @@ class WataConfig(ProviderEnvConfig):
|
||||
BASE_URL: str = Field(default="https://api.wata.pro/api/h2h")
|
||||
RETURN_URL: Optional[str] = None
|
||||
FAILED_URL: Optional[str] = None
|
||||
PAYMENT_LINK_TTL_DAYS: int = Field(default=3)
|
||||
LINK_TTL_MINUTES: int = Field(default=_WATA_LINK_DEFAULT_TTL_MINUTES)
|
||||
WEBHOOK_VERIFY_SIGNATURE: bool = Field(default=True)
|
||||
PUBLIC_KEY: Optional[str] = None
|
||||
TRUSTED_IPS: str = Field(default="62.84.126.140,51.250.106.150")
|
||||
|
||||
@field_validator("PAYMENT_LINK_TTL_DAYS", mode="before")
|
||||
@field_validator("LINK_TTL_MINUTES", mode="before")
|
||||
@classmethod
|
||||
def _clamp_ttl(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
try:
|
||||
value = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 3
|
||||
return min(30, max(1, value))
|
||||
def _clamp_link_ttl_minutes(cls, v):
|
||||
return _clamp_wata_link_ttl_minutes(v, default=_WATA_LINK_DEFAULT_TTL_MINUTES)
|
||||
|
||||
@field_validator("API_TOKEN", "RETURN_URL", "FAILED_URL", "PUBLIC_KEY", mode="before")
|
||||
@classmethod
|
||||
@@ -141,13 +200,13 @@ class WataService(HttpClientMixin):
|
||||
self._default_return_url = default_return_url
|
||||
self._cached_public_key_pem = None # populated by webhook on first verify
|
||||
|
||||
self._init_http_client(total_timeout=20)
|
||||
self._init_http_client(total_timeout=10)
|
||||
if not self.configured:
|
||||
logging.warning("WataService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.config.ENABLED and self.api_token)
|
||||
return bool(provider_runtime_enabled(self.config) and self.api_token)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
@@ -166,8 +225,8 @@ class WataService(HttpClientMixin):
|
||||
return self.config.FAILED_URL or self.return_url
|
||||
|
||||
@property
|
||||
def payment_link_ttl_days(self) -> int:
|
||||
return self.config.PAYMENT_LINK_TTL_DAYS
|
||||
def payment_link_ttl_minutes(self) -> int:
|
||||
return self.config.LINK_TTL_MINUTES
|
||||
|
||||
@property
|
||||
def verify_webhook_signature(self) -> bool:
|
||||
@@ -200,7 +259,9 @@ class WataService(HttpClientMixin):
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
session = await self._get_session()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
|
||||
).replace(microsecond=0)
|
||||
body: Dict[str, Any] = {
|
||||
"amount": float(format_decimal_amount(amount)),
|
||||
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
|
||||
@@ -208,7 +269,7 @@ class WataService(HttpClientMixin):
|
||||
"orderId": str(payment_db_id),
|
||||
"successRedirectUrl": self.return_url,
|
||||
"failRedirectUrl": self.failed_url,
|
||||
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
|
||||
"expirationDateTime": expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
return await post_json_request(
|
||||
session,
|
||||
@@ -216,6 +277,120 @@ class WataService(HttpClientMixin):
|
||||
body=body,
|
||||
headers=self._auth_headers(),
|
||||
log_prefix="Wata create_payment_link",
|
||||
is_success=_wata_success_status,
|
||||
)
|
||||
|
||||
async def _get_json(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[Mapping[str, Any]] = None,
|
||||
log_prefix: str,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("WataService is not configured. Cannot fetch provider state.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
session = await self._get_session()
|
||||
try:
|
||||
async with session.get(
|
||||
url,
|
||||
params=dict(params or {}),
|
||||
headers=self._auth_headers(),
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
if not _wata_success_status(response.status, response_data):
|
||||
logging.error(
|
||||
"%s: API returned error (status=%s, body=%s)",
|
||||
log_prefix,
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("%s: request failed.", log_prefix)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def get_payment_link(self, payment_link_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
return await self._get_json(
|
||||
f"{self.base_url}/links/{payment_link_id}",
|
||||
log_prefix="Wata get_payment_link",
|
||||
)
|
||||
|
||||
async def try_reuse_pending_link(self, payment: Any) -> Optional[str]:
|
||||
"""Return the existing payment link URL if it's still usable; else None.
|
||||
|
||||
Used to avoid creating duplicate Wata links each time a user re-clicks
|
||||
the pay button. Repeated abandoned links inflate Wata's anti-fraud
|
||||
signals and can cause downstream bank-side rejections during the
|
||||
bank-selection step.
|
||||
"""
|
||||
if not self.configured:
|
||||
return None
|
||||
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
|
||||
if not provider_payment_id:
|
||||
return None
|
||||
|
||||
success, data = await self.get_payment_link(provider_payment_id)
|
||||
if not success or not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower()
|
||||
if status and status not in _WATA_LINK_OPENED_STATUSES:
|
||||
return None
|
||||
|
||||
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
|
||||
if expiration_raw:
|
||||
exp_dt = _parse_wata_datetime(expiration_raw)
|
||||
if exp_dt is None:
|
||||
logging.warning(
|
||||
"Wata try_reuse_pending_link: unparseable expirationDateTime %r",
|
||||
expiration_raw,
|
||||
)
|
||||
return None
|
||||
if exp_dt <= datetime.now(timezone.utc):
|
||||
return None
|
||||
|
||||
return first_value(data, "url", "paymentUrl", "payment_url")
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
return await self._get_json(
|
||||
f"{self.base_url}/transactions/{transaction_id}",
|
||||
log_prefix="Wata get_transaction",
|
||||
)
|
||||
|
||||
async def search_transactions(
|
||||
self,
|
||||
*,
|
||||
order_id: Optional[str] = None,
|
||||
payment_link_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
params: Dict[str, Any] = {
|
||||
"skipCount": 0,
|
||||
"maxResultCount": max(1, min(int(limit or 5), 1000)),
|
||||
}
|
||||
if order_id:
|
||||
params["orderId"] = order_id
|
||||
if payment_link_id:
|
||||
params["paymentLinkId"] = payment_link_id
|
||||
if status:
|
||||
params["statuses"] = status
|
||||
return await self._get_json(
|
||||
f"{self.base_url}/transactions",
|
||||
params=params,
|
||||
log_prefix="Wata search_transactions",
|
||||
)
|
||||
|
||||
async def _get_public_key_pem(self) -> Optional[str]:
|
||||
@@ -256,6 +431,287 @@ class WataService(HttpClientMixin):
|
||||
logging.exception("Wata webhook: signature verification failed.")
|
||||
return False
|
||||
|
||||
def _transaction_matches_payment(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
payment: Any,
|
||||
*,
|
||||
provider_payment_id: Optional[str],
|
||||
) -> bool:
|
||||
order_id = str(payload.get("orderId") or "").strip()
|
||||
if order_id and order_id == str(payment.payment_id):
|
||||
return True
|
||||
|
||||
payment_link_id = _wata_payment_link_id(payload)
|
||||
if payment_link_id and provider_payment_id and payment_link_id == provider_payment_id:
|
||||
return True
|
||||
|
||||
transaction_id = _wata_transaction_id(payload)
|
||||
if transaction_id and provider_payment_id and transaction_id == provider_payment_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _find_transaction_for_payment(
|
||||
self,
|
||||
payment: Any,
|
||||
*,
|
||||
status: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
|
||||
success, response_data = await self.search_transactions(
|
||||
order_id=str(payment.payment_id),
|
||||
status=status,
|
||||
limit=5,
|
||||
)
|
||||
if success:
|
||||
for item in response_data.get("items") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if _normalized_wata_status(item) != status.lower():
|
||||
continue
|
||||
if self._transaction_matches_payment(
|
||||
item,
|
||||
payment,
|
||||
provider_payment_id=provider_payment_id or None,
|
||||
):
|
||||
return item
|
||||
|
||||
return None
|
||||
|
||||
async def _mark_paid_from_payload(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
payment: Any,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
log_prefix: str,
|
||||
) -> Optional[Any]:
|
||||
current = await payment_dal.get_payment_by_db_id(session, payment.payment_id)
|
||||
if current:
|
||||
payment = current
|
||||
if payment.status == "succeeded":
|
||||
return payment
|
||||
|
||||
transaction_id = _wata_transaction_id(payload) or str(payment.payment_id)
|
||||
amount_raw = payload.get("amount")
|
||||
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
if amount_raw is not None:
|
||||
try:
|
||||
if not decimal_amounts_equal(amount_raw, payment.amount):
|
||||
logging.warning(
|
||||
"%s: amount mismatch for payment %s (expected %s, got %s)",
|
||||
log_prefix,
|
||||
payment.payment_id,
|
||||
format_decimal_amount(payment.amount),
|
||||
format_decimal_amount(amount_raw),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning(
|
||||
"%s: failed to compare amounts for %s: %s",
|
||||
log_prefix,
|
||||
payment.payment_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"succeeded",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s as succeeded.",
|
||||
log_prefix,
|
||||
transaction_id,
|
||||
)
|
||||
return None
|
||||
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_units = payment_units_for_activation(payment, sale_mode)
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
subscription_service=self.subscription_service,
|
||||
referral_service=self.referral_service,
|
||||
payment=payment,
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=str(currency),
|
||||
sale_mode=sale_mode,
|
||||
months=payment_units,
|
||||
traffic_amount=float(payment_units),
|
||||
provider_subscription="wata",
|
||||
provider_notification="wata",
|
||||
db_user=payment.user,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
)
|
||||
if outcome is None:
|
||||
return None
|
||||
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
|
||||
|
||||
async def _mark_declined_from_payload(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
payment: Any,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
log_prefix: str,
|
||||
notify_user: bool,
|
||||
) -> Optional[Any]:
|
||||
transaction_id = _wata_transaction_id(payload) or str(payment.payment_id)
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s as failed.",
|
||||
log_prefix,
|
||||
transaction_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if notify_user:
|
||||
await notify_user_payment_failed(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
payment=payment,
|
||||
)
|
||||
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
|
||||
|
||||
def _local_payment_link_ttl_expired(self, payment: Any) -> bool:
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
if isinstance(created_at, datetime):
|
||||
created_dt = (
|
||||
created_at.replace(tzinfo=timezone.utc)
|
||||
if created_at.tzinfo is None
|
||||
else created_at.astimezone(timezone.utc)
|
||||
)
|
||||
else:
|
||||
created_dt = _parse_wata_datetime(created_at)
|
||||
if created_dt is None:
|
||||
return False
|
||||
expires_at = created_dt + timedelta(minutes=self.payment_link_ttl_minutes)
|
||||
return expires_at <= datetime.now(timezone.utc)
|
||||
|
||||
async def _expired_link_payload_for_payment(self, payment: Any) -> Optional[Mapping[str, Any]]:
|
||||
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
|
||||
if not provider_payment_id:
|
||||
return None
|
||||
|
||||
success, data = await self.get_payment_link(provider_payment_id)
|
||||
if not success or not isinstance(data, dict):
|
||||
status_code = data.get("status") if isinstance(data, dict) else None
|
||||
if status_code == 404 and self._local_payment_link_ttl_expired(payment):
|
||||
return {"id": provider_payment_id}
|
||||
return None
|
||||
|
||||
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
|
||||
expiration_dt = _parse_wata_datetime(expiration_raw)
|
||||
if expiration_dt is None:
|
||||
return None
|
||||
if expiration_dt > datetime.now(timezone.utc):
|
||||
return None
|
||||
return data
|
||||
|
||||
async def _mark_expired_link(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
payment: Any,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
log_prefix: str,
|
||||
) -> Optional[Any]:
|
||||
provider_payment_id = (
|
||||
first_value(payload, "id", "paymentLinkId", "payment_link_id")
|
||||
or getattr(payment, "provider_payment_id", None)
|
||||
or str(payment.payment_id)
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
str(provider_payment_id),
|
||||
"canceled",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark expired payment link %s as canceled.",
|
||||
log_prefix,
|
||||
provider_payment_id,
|
||||
)
|
||||
return None
|
||||
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
|
||||
|
||||
async def refresh_payment_status(self, session: AsyncSession, payment: Any) -> Any:
|
||||
if str(getattr(payment, "provider", "") or "").lower() != "wata":
|
||||
return payment
|
||||
if not self.configured:
|
||||
return payment
|
||||
|
||||
current_status = str(getattr(payment, "status", "") or "").lower()
|
||||
if current_status == "succeeded" or current_status in {
|
||||
"failed",
|
||||
"canceled",
|
||||
"cancelled",
|
||||
"failed_creation",
|
||||
}:
|
||||
return payment
|
||||
|
||||
paid_payload = await self._find_transaction_for_payment(payment, status="Paid")
|
||||
if paid_payload:
|
||||
refreshed = await self._mark_paid_from_payload(
|
||||
session,
|
||||
payment,
|
||||
paid_payload,
|
||||
log_prefix="Wata status refresh",
|
||||
)
|
||||
return refreshed or payment
|
||||
|
||||
declined_payload = await self._find_transaction_for_payment(payment, status="Declined")
|
||||
if declined_payload:
|
||||
refreshed = await self._mark_declined_from_payload(
|
||||
session,
|
||||
payment,
|
||||
declined_payload,
|
||||
log_prefix="Wata status refresh",
|
||||
notify_user=False,
|
||||
)
|
||||
return refreshed or payment
|
||||
|
||||
expired_link_payload = await self._expired_link_payload_for_payment(payment)
|
||||
if expired_link_payload:
|
||||
refreshed = await self._mark_expired_link(
|
||||
session,
|
||||
payment,
|
||||
expired_link_payload,
|
||||
log_prefix="Wata status refresh",
|
||||
)
|
||||
return refreshed or payment
|
||||
|
||||
return payment
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="wata_disabled")
|
||||
@@ -287,12 +743,11 @@ class WataService(HttpClientMixin):
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
transaction_id = str(payload.get("transactionId") or "").strip()
|
||||
payment_link_id = str(payload.get("paymentLinkId") or payload.get("id") or "").strip()
|
||||
status = str(payload.get("transactionStatus") or "").strip().lower()
|
||||
order_id_raw = payload.get("orderId")
|
||||
amount_raw = payload.get("amount")
|
||||
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
if not status or not (transaction_id or order_id_raw):
|
||||
if not status or not (transaction_id or order_id_raw or payment_link_id):
|
||||
logging.error("Wata webhook: missing transaction status or ids: %s", payload)
|
||||
return web.Response(status=400, text="missing_fields")
|
||||
|
||||
@@ -302,106 +757,63 @@ class WataService(HttpClientMixin):
|
||||
order_id_raw=order_id_raw,
|
||||
provider_payment_id=transaction_id or None,
|
||||
)
|
||||
if not payment and payment_link_id:
|
||||
payment = await lookup_payment_by_order_or_provider_id(
|
||||
session,
|
||||
provider_payment_id=payment_link_id,
|
||||
)
|
||||
if not payment:
|
||||
logging.error(
|
||||
"Wata webhook: payment not found (order_id=%s, transaction_id=%s)",
|
||||
"Wata webhook: payment not found "
|
||||
"(order_id=%s, transaction_id=%s, payment_link_id=%s)",
|
||||
order_id_raw,
|
||||
transaction_id,
|
||||
payment_link_id,
|
||||
)
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded" and status == "paid":
|
||||
if payment.status == "succeeded":
|
||||
return web.Response(text="ok")
|
||||
|
||||
resolved_transaction_id = transaction_id or str(payment.payment_id)
|
||||
if status in _WATA_IN_PROGRESS_STATUSES:
|
||||
if transaction_id and payment.provider_payment_id != transaction_id:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
transaction_id,
|
||||
payment.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Wata webhook: failed to persist transaction id %s for payment %s.",
|
||||
transaction_id,
|
||||
payment.payment_id,
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status == "paid":
|
||||
if amount_raw is not None:
|
||||
try:
|
||||
if not decimal_amounts_equal(amount_raw, payment.amount):
|
||||
logging.warning(
|
||||
"Wata webhook: amount mismatch for payment %s "
|
||||
"(expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
format_decimal_amount(payment.amount),
|
||||
format_decimal_amount(amount_raw),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning(
|
||||
"Wata webhook: failed to compare amounts for %s: %s",
|
||||
payment.payment_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_transaction_id,
|
||||
"succeeded",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Wata webhook: failed to mark payment %s as succeeded.",
|
||||
resolved_transaction_id,
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
subscription_service=self.subscription_service,
|
||||
referral_service=self.referral_service,
|
||||
payment=payment,
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=str(currency),
|
||||
sale_mode=sale_mode,
|
||||
months=payment_units,
|
||||
traffic_amount=float(payment_units),
|
||||
provider_subscription="wata",
|
||||
provider_notification="wata",
|
||||
db_user=payment.user,
|
||||
log_prefix="Wata webhook",
|
||||
)
|
||||
)
|
||||
if outcome is None:
|
||||
if not await self._mark_paid_from_payload(
|
||||
session,
|
||||
payment,
|
||||
payload,
|
||||
log_prefix="Wata webhook",
|
||||
):
|
||||
return web.Response(status=500, text="processing_error")
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status == "declined":
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_transaction_id,
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Wata webhook: failed to mark payment %s as failed.",
|
||||
resolved_transaction_id,
|
||||
)
|
||||
if not await self._mark_declined_from_payload(
|
||||
session,
|
||||
payment,
|
||||
payload,
|
||||
log_prefix="Wata webhook",
|
||||
notify_user=True,
|
||||
):
|
||||
return web.Response(status=500, text="processing_error")
|
||||
await notify_user_payment_failed(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
payment=payment,
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
|
||||
logging.warning(
|
||||
@@ -409,7 +821,7 @@ class WataService(HttpClientMixin):
|
||||
status,
|
||||
transaction_id,
|
||||
)
|
||||
return web.Response(status=202, text="status_ignored")
|
||||
return web.Response(text="status_ignored")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_wata:"))
|
||||
@@ -428,6 +840,14 @@ async def pay_wata_callback_handler(
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
if not wata_service or not wata_service.configured:
|
||||
logging.error("Wata service is not configured or unavailable.")
|
||||
await notify_service_unavailable(callback, translator)
|
||||
@@ -438,9 +858,52 @@ async def pay_wata_callback_handler(
|
||||
logging.error("Invalid pay_wata data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=wata_service.subscription_service,
|
||||
currency="rub",
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
payment_description = describe_payment(translator, parts)
|
||||
|
||||
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
|
||||
months_for_lookup = (
|
||||
reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
provider="wata",
|
||||
pending_status="pending_wata",
|
||||
amount=parts.price,
|
||||
sale_mode=parts.sale_mode,
|
||||
months=months_for_lookup,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
since_minutes=wata_service.payment_link_ttl_minutes,
|
||||
)
|
||||
if reusable_payment is not None:
|
||||
reusable_url = await wata_service.try_reuse_pending_link(reusable_payment)
|
||||
if reusable_url:
|
||||
await safe_callback_answer(callback)
|
||||
await render_payment_link(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
payment_url=reusable_url,
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
return
|
||||
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
amount=parts.price,
|
||||
@@ -450,6 +913,7 @@ async def pay_wata_callback_handler(
|
||||
months=parts.months,
|
||||
provider="wata",
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -463,6 +927,8 @@ async def pay_wata_callback_handler(
|
||||
await notify_payment_record_failure(callback, translator)
|
||||
return
|
||||
|
||||
await safe_callback_answer(callback)
|
||||
|
||||
success, response_data = await wata_service.create_payment_link(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
amount=parts.price,
|
||||
@@ -478,8 +944,8 @@ async def pay_wata_callback_handler(
|
||||
session=session,
|
||||
payment=payment_record,
|
||||
api_success=success,
|
||||
payment_url=first_value(response_data, "url"),
|
||||
provider_payment_id=first_value(response_data, "id"),
|
||||
payment_url=first_value(response_data, "url", "paymentUrl", "payment_url"),
|
||||
provider_payment_id=first_value(response_data, "id", "paymentLinkId"),
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
|
||||
@@ -491,6 +957,45 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
return payment_unavailable()
|
||||
|
||||
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
)
|
||||
months_for_lookup = (
|
||||
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
|
||||
)
|
||||
try:
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
ctx.session,
|
||||
user_id=ctx.user_id,
|
||||
provider="wata",
|
||||
pending_status="pending_wata",
|
||||
amount=ctx.price,
|
||||
sale_mode=ctx.sale_mode,
|
||||
months=months_for_lookup,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
since_minutes=service.payment_link_ttl_minutes,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Wata WebApp: lookup of reusable payment failed")
|
||||
reusable_payment = None
|
||||
|
||||
if reusable_payment is not None:
|
||||
try:
|
||||
reusable_url = await service.try_reuse_pending_link(reusable_payment)
|
||||
except Exception:
|
||||
logging.exception("Wata WebApp: failed to verify reusable link")
|
||||
reusable_url = None
|
||||
if reusable_url:
|
||||
return payment_link_response(
|
||||
payment_url=reusable_url,
|
||||
payment_id=reusable_payment.payment_id,
|
||||
)
|
||||
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -514,8 +1019,10 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
session=ctx.session,
|
||||
payment=payment,
|
||||
api_success=success,
|
||||
payment_url=first_value(response_data, "url") if success else None,
|
||||
provider_payment_id=first_value(response_data, "id"),
|
||||
payment_url=first_value(response_data, "url", "paymentUrl", "payment_url")
|
||||
if success
|
||||
else None,
|
||||
provider_payment_id=first_value(response_data, "id", "paymentLinkId"),
|
||||
log_prefix="Wata",
|
||||
)
|
||||
|
||||
@@ -623,14 +1130,17 @@ _CONFIG_MANIFEST = (
|
||||
"WATA_FAILED_URL", "url", "Failed URL", subsection="Wata", attr="FAILED_URL"
|
||||
),
|
||||
ProviderManifestField(
|
||||
"WATA_PAYMENT_LINK_TTL_DAYS",
|
||||
"WATA_LINK_TTL_MINUTES",
|
||||
"int",
|
||||
"Payment link lifetime (days)",
|
||||
description="1..30; Wata defaults to 3 days and allows up to 30 days.",
|
||||
"Payment link lifetime (minutes)",
|
||||
description=(
|
||||
"15..43200; default 15 minutes. Wata requires more than 10 minutes "
|
||||
"and allows up to 30 days."
|
||||
),
|
||||
subsection="Wata",
|
||||
min=1,
|
||||
max=30,
|
||||
attr="PAYMENT_LINK_TTL_DAYS",
|
||||
min=_WATA_LINK_MIN_TTL_MINUTES,
|
||||
max=_WATA_LINK_MAX_TTL_MINUTES,
|
||||
attr="LINK_TTL_MINUTES",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"WATA_WEBHOOK_VERIFY_SIGNATURE",
|
||||
|
||||
@@ -36,6 +36,7 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.install_links import ensure_user_install_guide_links
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal, user_dal
|
||||
@@ -48,9 +49,12 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
PaymentCallbackParts,
|
||||
SuccessMessage,
|
||||
append_hwid_renewal_note,
|
||||
build_success_message,
|
||||
create_webapp_payment_record,
|
||||
format_human_units,
|
||||
@@ -59,10 +63,12 @@ from .shared import (
|
||||
make_translator,
|
||||
mark_payment_failed_creation,
|
||||
notify_admins_payment_received,
|
||||
parse_positive_int_units,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
quote_hwid_callback_parts,
|
||||
resolve_inviter_name,
|
||||
send_success_message_to_user,
|
||||
)
|
||||
@@ -139,10 +145,12 @@ class YooKassaService:
|
||||
bot_username_for_default_return: Optional[str] = None,
|
||||
settings_obj: Optional[Settings] = None,
|
||||
config: Optional[YooKassaConfig] = None,
|
||||
subscription_service: Optional[SubscriptionService] = None,
|
||||
):
|
||||
|
||||
self.settings = settings_obj
|
||||
self.config = config or YooKassaConfig()
|
||||
self.subscription_service = subscription_service
|
||||
self._bot_username_for_default_return = bot_username_for_default_return
|
||||
self._configured_return_url_override = configured_return_url
|
||||
self._sdk_configured_for = (
|
||||
@@ -150,7 +158,7 @@ class YooKassaService:
|
||||
)
|
||||
|
||||
if not self.configured:
|
||||
if not self.config.ENABLED:
|
||||
if not provider_runtime_enabled(self.config):
|
||||
logging.warning(
|
||||
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501
|
||||
)
|
||||
@@ -163,7 +171,9 @@ class YooKassaService:
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
if not (self.config.ENABLED and self.config.SHOP_ID and self.config.SECRET_KEY):
|
||||
if not (
|
||||
provider_runtime_enabled(self.config) and self.config.SHOP_ID and self.config.SECRET_KEY
|
||||
):
|
||||
return False
|
||||
self._ensure_sdk_configured()
|
||||
return self._sdk_configured_for is not None
|
||||
@@ -254,6 +264,9 @@ class YooKassaService:
|
||||
if save_payment_method:
|
||||
# Ask YooKassa to save method for off-session charges
|
||||
builder.set_save_payment_method(True)
|
||||
elif not payment_method_id:
|
||||
# Keep the Smart Payment form unrestricted for one-off payments.
|
||||
builder.set_save_payment_method(False)
|
||||
if payment_method_id:
|
||||
# Use a previously saved payment method for merchant-initiated payments
|
||||
builder.set_payment_method_id(payment_method_id)
|
||||
@@ -410,6 +423,55 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [
|
||||
"77.75.154.128/25",
|
||||
"2a02:5180::/32",
|
||||
]
|
||||
HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||
|
||||
|
||||
def _is_hwid_device_sale_base(sale_mode_base: str) -> bool:
|
||||
return sale_mode_base in HWID_DEVICE_SALE_BASES
|
||||
|
||||
|
||||
def _metadata_value_present(value: Optional[Any]) -> bool:
|
||||
return value is not None and str(value).strip() != ""
|
||||
|
||||
|
||||
def _resolve_yookassa_activation_amounts(
|
||||
*,
|
||||
sale_mode_base: str,
|
||||
subscription_months_raw: Optional[Any],
|
||||
traffic_gb_raw: Optional[Any],
|
||||
hwid_devices_raw: Optional[Any],
|
||||
) -> tuple[float, float, int, int, Optional[float]]:
|
||||
subscription_months = float(subscription_months_raw or 0)
|
||||
traffic_amount_gb = (
|
||||
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
||||
)
|
||||
hwid_devices_count = 0
|
||||
if _metadata_value_present(hwid_devices_raw):
|
||||
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
|
||||
if parsed_hwid_devices is None:
|
||||
raise ValueError("Invalid HWID device count")
|
||||
hwid_devices_count = parsed_hwid_devices
|
||||
elif _is_hwid_device_sale_base(sale_mode_base):
|
||||
parsed_hwid_devices = parse_positive_int_units(subscription_months_raw)
|
||||
if parsed_hwid_devices is None:
|
||||
raise ValueError("Invalid HWID device count")
|
||||
hwid_devices_count = parsed_hwid_devices
|
||||
|
||||
if sale_mode_base == "subscription":
|
||||
months_for_activation = int(subscription_months)
|
||||
elif _is_hwid_device_sale_base(sale_mode_base):
|
||||
months_for_activation = hwid_devices_count
|
||||
else:
|
||||
months_for_activation = int(traffic_amount_gb)
|
||||
|
||||
traffic_gb_for_activation = traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
|
||||
return (
|
||||
subscription_months,
|
||||
traffic_amount_gb,
|
||||
hwid_devices_count,
|
||||
months_for_activation,
|
||||
traffic_gb_for_activation,
|
||||
)
|
||||
|
||||
|
||||
async def process_successful_payment(
|
||||
@@ -427,6 +489,7 @@ async def process_successful_payment(
|
||||
user_id_str = metadata.get("user_id")
|
||||
subscription_months_str = metadata.get("subscription_months")
|
||||
traffic_gb_str = metadata.get("traffic_gb")
|
||||
hwid_devices_str = metadata.get("hwid_devices")
|
||||
sale_mode = metadata.get("sale_mode") or (
|
||||
"traffic" if settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
@@ -439,7 +502,11 @@ async def process_successful_payment(
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (
|
||||
not user_id_str
|
||||
or (not subscription_months_str and not traffic_gb_str)
|
||||
or not (
|
||||
_metadata_value_present(subscription_months_str)
|
||||
or _metadata_value_present(traffic_gb_str)
|
||||
or _metadata_value_present(hwid_devices_str)
|
||||
)
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
||||
):
|
||||
logging.error(
|
||||
@@ -450,8 +517,18 @@ async def process_successful_payment(
|
||||
db_user = None
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = float(subscription_months_str or 0)
|
||||
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||
(
|
||||
subscription_months,
|
||||
traffic_amount_gb,
|
||||
hwid_devices_count,
|
||||
months_for_activation,
|
||||
traffic_gb_for_activation,
|
||||
) = _resolve_yookassa_activation_amounts(
|
||||
sale_mode_base=sale_mode_base,
|
||||
subscription_months_raw=subscription_months_str,
|
||||
traffic_gb_raw=traffic_gb_str,
|
||||
hwid_devices_raw=hwid_devices_str,
|
||||
)
|
||||
payment_db_id = (
|
||||
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
||||
)
|
||||
@@ -469,6 +546,21 @@ async def process_successful_payment(
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
|
||||
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
|
||||
logging.error(
|
||||
"YooKassa HWID payment %s has invalid device count in metadata: %s",
|
||||
yk_payment_id_from_hook,
|
||||
metadata,
|
||||
)
|
||||
if payment_db_id is not None:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
"failed_metadata_error",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
return
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
@@ -613,9 +705,6 @@ async def process_successful_payment(
|
||||
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||
except Exception:
|
||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||
months_for_activation = (
|
||||
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
|
||||
)
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
@@ -625,12 +714,12 @@ async def process_successful_payment(
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa",
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_amount_gb
|
||||
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
traffic_gb=traffic_gb_for_activation,
|
||||
)
|
||||
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
if not activation_details or (
|
||||
sale_mode_base == "subscription" and not activation_details.get("end_date")
|
||||
):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
|
||||
)
|
||||
@@ -648,7 +737,7 @@ async def process_successful_payment(
|
||||
)
|
||||
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
|
||||
|
||||
base_subscription_end_date = activation_details["end_date"]
|
||||
base_subscription_end_date = activation_details.get("end_date")
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
|
||||
|
||||
@@ -683,6 +772,11 @@ async def process_successful_payment(
|
||||
if not receipt_item_name:
|
||||
if is_traffic_sale_base(sale_mode_base):
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
||||
elif _is_hwid_device_sale_base(sale_mode_base):
|
||||
receipt_item_name = _(
|
||||
"payment_description_hwid_devices",
|
||||
count=hwid_devices_count,
|
||||
)
|
||||
else:
|
||||
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
|
||||
months=int(subscription_months)
|
||||
@@ -702,7 +796,6 @@ async def process_successful_payment(
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
settings, activation_details.get("subscription_url") if activation_details else None
|
||||
)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
# Auto-renew charges show a concise message and skip the connect keyboard, so
|
||||
# they bypass the shared success-message builder.
|
||||
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
|
||||
@@ -712,7 +805,11 @@ async def process_successful_payment(
|
||||
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
|
||||
)
|
||||
include_keyboard = False
|
||||
elif not final_end_date_for_user and not is_traffic_sale_base(sale_mode_base):
|
||||
elif (
|
||||
sale_mode_base == "subscription"
|
||||
and not final_end_date_for_user
|
||||
and not is_traffic_sale_base(sale_mode_base)
|
||||
):
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
|
||||
)
|
||||
@@ -729,11 +826,14 @@ async def process_successful_payment(
|
||||
months=(
|
||||
traffic_label
|
||||
if is_traffic_sale_base(sale_mode_base)
|
||||
else int(subscription_months)
|
||||
else (
|
||||
hwid_devices_count
|
||||
if _is_hwid_device_sale_base(sale_mode_base)
|
||||
else int(subscription_months)
|
||||
)
|
||||
),
|
||||
base_end_date=base_subscription_end_date,
|
||||
final_end_date=final_end_date_for_user,
|
||||
config_link_text=config_link_text,
|
||||
applied_referee_bonus_days=applied_referee_bonus_days_from_referral or 0,
|
||||
applied_promo_bonus_days=applied_promo_bonus_days,
|
||||
inviter_name=inviter_name,
|
||||
@@ -741,6 +841,19 @@ async def process_successful_payment(
|
||||
)
|
||||
)
|
||||
include_keyboard = True
|
||||
|
||||
if sale_mode_base == "subscription" and activation_details:
|
||||
details_message = append_hwid_renewal_note(
|
||||
details_message,
|
||||
translator,
|
||||
count=activation_details.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation_details.get("hwid_devices_valid_until"),
|
||||
)
|
||||
|
||||
install_share_url = None
|
||||
if include_keyboard:
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
await send_success_message_to_user(
|
||||
bot=bot,
|
||||
user_id=user_id,
|
||||
@@ -750,6 +863,7 @@ async def process_successful_payment(
|
||||
settings=settings,
|
||||
config_link_display=config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
include_keyboard=include_keyboard,
|
||||
log_prefix="YooKassa webhook",
|
||||
)
|
||||
@@ -773,6 +887,7 @@ async def process_successful_payment(
|
||||
),
|
||||
payment_provider="yookassa",
|
||||
username=user_for_notify.username if user_for_notify else None,
|
||||
email=getattr(user_for_notify, "email", None) if user_for_notify else None,
|
||||
traffic_is_premium=sale_mode_base == "premium_topup",
|
||||
tariff_key=tariff_for_log,
|
||||
log_prefix="YooKassa webhook",
|
||||
@@ -1191,6 +1306,7 @@ async def _initiate_yk_payment(
|
||||
payment_method_id: Optional[str] = None,
|
||||
selected_method_internal_id: Optional[int] = None,
|
||||
sale_mode: str = "subscription",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
||||
if not callback.message:
|
||||
@@ -1202,7 +1318,7 @@ async def _initiate_yk_payment(
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else (
|
||||
get_text("payment_description_hwid_devices", count=int(months))
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
if sale_base in HWID_DEVICE_SALE_BASES
|
||||
else get_text("payment_description_subscription", months=int(months))
|
||||
)
|
||||
)
|
||||
@@ -1218,9 +1334,14 @@ async def _initiate_yk_payment(
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months)
|
||||
if sale_base in {"hwid_device", "hwid_devices"}
|
||||
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
@@ -1257,6 +1378,8 @@ async def _initiate_yk_payment(
|
||||
}
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if sale_base in HWID_DEVICE_SALE_BASES:
|
||||
yookassa_metadata["hwid_devices"] = str(months)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
@@ -1460,6 +1583,29 @@ async def _initiate_yk_payment(
|
||||
return False
|
||||
|
||||
|
||||
async def _yookassa_available_to_callback_user(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
get_text,
|
||||
) -> bool:
|
||||
if SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
return True
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -1479,6 +1625,9 @@ async def pay_yk_callback_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
@@ -1509,6 +1658,23 @@ async def pay_yk_callback_handler(
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
hwid_quote = None
|
||||
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency="rub",
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = "RUB"
|
||||
autopay_enabled = bool(
|
||||
@@ -1585,6 +1751,7 @@ async def pay_yk_callback_handler(
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
||||
sale_mode=sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -1611,6 +1778,9 @@ async def pay_yk_new_card_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_new.")
|
||||
try:
|
||||
@@ -1695,6 +1865,9 @@ async def pay_yk_saved_list_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
@@ -1855,6 +2028,9 @@ async def pay_yk_use_saved_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
||||
try:
|
||||
@@ -2513,6 +2689,7 @@ def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
|
||||
bot_username_for_default_return=ctx.bot_username_for_default_return,
|
||||
settings_obj=ctx.settings,
|
||||
config=config,
|
||||
subscription_service=ctx.subscription_service,
|
||||
)
|
||||
|
||||
|
||||
@@ -2557,10 +2734,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
description=ctx.description,
|
||||
metadata=metadata,
|
||||
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
|
||||
save_payment_method=bool(
|
||||
service.config.autopayments_active
|
||||
and service.config.AUTOPAYMENTS_REQUIRE_CARD_BINDING
|
||||
),
|
||||
save_payment_method=False,
|
||||
)
|
||||
payment_url = response.get("confirmation_url") if response else None
|
||||
if not payment_url:
|
||||
@@ -2700,8 +2874,8 @@ SPEC = PaymentProviderSpec(
|
||||
id="yookassa",
|
||||
provider_key="yookassa",
|
||||
label="YooKassa",
|
||||
webapp_label="Банковская карта",
|
||||
webapp_labels={"ru": "Банковская карта", "en": "Bank card"},
|
||||
webapp_label="ЮKassa",
|
||||
webapp_labels={"ru": "ЮKassa", "en": "YooKassa"},
|
||||
webapp_icon="CreditCard",
|
||||
telegram_labels={"ru": "ЮKassa", "en": "YooKassa"},
|
||||
telegram_emoji="💳",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
BACKUP_APP_ID = "remnawave-minishop"
|
||||
BACKUP_FILENAME_PREFIX = "minishop-"
|
||||
BACKUP_FORMAT_VERSION = 1
|
||||
BACKUP_MANIFEST_NAME = "manifest.json"
|
||||
|
||||
|
||||
def backup_filename_timestamp() -> str:
|
||||
return datetime.now().astimezone().strftime("%Y%m%d-%H-%M")
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_file_records(source_dir: Path) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(source_dir).as_posix()
|
||||
if relative == BACKUP_MANIFEST_NAME:
|
||||
continue
|
||||
stat = path.stat()
|
||||
records.append(
|
||||
{
|
||||
"path": relative,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"sha256": file_sha256(path),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def attach_archive_integrity(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
file_records: list[dict[str, Any]],
|
||||
) -> None:
|
||||
manifest["app"] = BACKUP_APP_ID
|
||||
manifest["format_version"] = BACKUP_FORMAT_VERSION
|
||||
manifest["archive"] = {
|
||||
"files": file_records,
|
||||
}
|
||||
|
||||
|
||||
def write_manifest(source_dir: Path, manifest: dict[str, Any]) -> None:
|
||||
(source_dir / BACKUP_MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_zip_from_directory(source_dir: Path, archive_path: Path) -> None:
|
||||
with zipfile.ZipFile(
|
||||
archive_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
) as archive:
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, path.relative_to(source_dir).as_posix())
|
||||
@@ -0,0 +1,642 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Optional
|
||||
|
||||
from bot.services.backup_archive import (
|
||||
BACKUP_APP_ID,
|
||||
BACKUP_FILENAME_PREFIX,
|
||||
BACKUP_FORMAT_VERSION,
|
||||
BACKUP_MANIFEST_NAME,
|
||||
attach_archive_integrity,
|
||||
backup_filename_timestamp,
|
||||
build_file_records,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
from bot.services.backup_worker import (
|
||||
DEFAULT_COMPOSE_EXCLUDED_DIRS,
|
||||
)
|
||||
from config.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_MEMBERS = 20_000
|
||||
BACKUP_MAX_MEMBER_BYTES = 4 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_UNCOMPRESSED_BYTES = 16 * 1024 * 1024 * 1024
|
||||
BACKUP_MAX_COMPOSE_BYTES = 1024 * 1024 * 1024
|
||||
BACKUP_MAX_COMPOSE_MEMBER_BYTES = 256 * 1024 * 1024
|
||||
BACKUP_MAX_COMPRESSION_RATIO = 200
|
||||
BACKUP_ZIP_BOMB_MIN_BYTES = 100 * 1024 * 1024
|
||||
COMPOSE_PRE_RESTORE_PREFIX = "minishop-pre-restore-"
|
||||
SAFE_ARCHIVE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,220}\.zip$")
|
||||
|
||||
|
||||
class BackupArchiveError(ValueError):
|
||||
"""The selected archive cannot be used for restore."""
|
||||
|
||||
|
||||
class BackupRestoreError(RuntimeError):
|
||||
"""Restore command failed after archive validation."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupArchiveInfo:
|
||||
name: str
|
||||
path: Path
|
||||
size_bytes: int
|
||||
modified_at: datetime
|
||||
created_at: Optional[str] = None
|
||||
created_at_local: Optional[str] = None
|
||||
has_database: bool = False
|
||||
has_compose: bool = False
|
||||
database_name: Optional[str] = None
|
||||
compose_files_count: int = 0
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
manifest: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"size_bytes": self.size_bytes,
|
||||
"modified_at": self.modified_at.isoformat(),
|
||||
"created_at": self.created_at,
|
||||
"created_at_local": self.created_at_local,
|
||||
"has_database": self.has_database,
|
||||
"has_compose": self.has_compose,
|
||||
"database_name": self.database_name,
|
||||
"compose_files_count": self.compose_files_count,
|
||||
"warnings": self.warnings,
|
||||
"manifest": self.manifest,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupRestoreResult:
|
||||
archive_name: str
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
database_restored: bool = False
|
||||
compose_files_restored: int = 0
|
||||
compose_target_dir: Optional[str] = None
|
||||
compose_pre_restore_archive: Optional[str] = None
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"archive_name": self.archive_name,
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat(),
|
||||
"database_restored": self.database_restored,
|
||||
"compose_files_restored": self.compose_files_restored,
|
||||
"compose_target_dir": self.compose_target_dir,
|
||||
"compose_pre_restore_archive": self.compose_pre_restore_archive,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class BackupRestoreService:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def backup_dir(self) -> Path:
|
||||
path = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def list_archives(self) -> list[BackupArchiveInfo]:
|
||||
backup_dir = self.backup_dir()
|
||||
archives = []
|
||||
for path in backup_dir.glob("*.zip"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
archives.append(self.inspect_archive(path))
|
||||
except BackupArchiveError as exc:
|
||||
logger.warning("Skipping invalid backup archive %s: %s", path, exc)
|
||||
return sorted(archives, key=lambda item: item.modified_at, reverse=True)
|
||||
|
||||
def archive_path_for_name(self, archive_name: str) -> Path:
|
||||
raw_name = str(archive_name or "").strip()
|
||||
safe_name = Path(raw_name).name
|
||||
if not raw_name or safe_name != raw_name or not SAFE_ARCHIVE_NAME_RE.fullmatch(safe_name):
|
||||
raise BackupArchiveError("Invalid archive name")
|
||||
|
||||
backup_dir = self.backup_dir().resolve()
|
||||
archive_path = (backup_dir / safe_name).resolve()
|
||||
try:
|
||||
archive_path.relative_to(backup_dir)
|
||||
except ValueError as exc:
|
||||
raise BackupArchiveError("Archive path escapes backup directory") from exc
|
||||
if not archive_path.is_file():
|
||||
raise BackupArchiveError("Archive does not exist")
|
||||
return archive_path
|
||||
|
||||
def inspect_archive(self, archive_path: Path) -> BackupArchiveInfo:
|
||||
if not zipfile.is_zipfile(archive_path):
|
||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||
|
||||
stat = archive_path.stat()
|
||||
warnings: list[str] = []
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
has_database = self._find_database_dump_member(archive) is not None
|
||||
compose_members = self._compose_file_members(archive)
|
||||
|
||||
manifest_warnings = manifest.get("warnings")
|
||||
if isinstance(manifest_warnings, list):
|
||||
warnings.extend(str(item) for item in manifest_warnings if item)
|
||||
|
||||
postgres = manifest.get("postgres") if isinstance(manifest.get("postgres"), dict) else {}
|
||||
compose = manifest.get("compose") if isinstance(manifest.get("compose"), dict) else {}
|
||||
return BackupArchiveInfo(
|
||||
name=archive_path.name,
|
||||
path=archive_path,
|
||||
size_bytes=int(stat.st_size),
|
||||
modified_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
created_at=str(manifest.get("created_at") or "") or None,
|
||||
created_at_local=str(manifest.get("created_at_local") or "") or None,
|
||||
has_database=has_database,
|
||||
has_compose=bool(compose_members),
|
||||
database_name=str(postgres.get("database") or "") or None,
|
||||
compose_files_count=int(compose.get("files_count") or len(compose_members)),
|
||||
warnings=warnings,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
def import_uploaded_archive(
|
||||
self,
|
||||
temp_path: Path,
|
||||
original_filename: str = "",
|
||||
) -> BackupArchiveInfo:
|
||||
self._validate_archive_for_restore(temp_path)
|
||||
digest = self._file_digest(temp_path)
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_name = f"{BACKUP_FILENAME_PREFIX}uploaded-{stamp}-{digest}.zip"
|
||||
target_path = self._unique_archive_path(archive_name)
|
||||
temp_path.replace(target_path)
|
||||
return self.inspect_archive(target_path)
|
||||
|
||||
async def restore_archive(
|
||||
self,
|
||||
archive_name: str,
|
||||
*,
|
||||
restore_database: bool,
|
||||
restore_compose: bool,
|
||||
) -> BackupRestoreResult:
|
||||
return await asyncio.to_thread(
|
||||
self.restore_archive_sync,
|
||||
archive_name,
|
||||
restore_database=restore_database,
|
||||
restore_compose=restore_compose,
|
||||
)
|
||||
|
||||
def restore_archive_sync(
|
||||
self,
|
||||
archive_name: str,
|
||||
*,
|
||||
restore_database: bool,
|
||||
restore_compose: bool,
|
||||
) -> BackupRestoreResult:
|
||||
if not restore_database and not restore_compose:
|
||||
raise BackupArchiveError("Select at least one restore target")
|
||||
|
||||
archive_path = self.archive_path_for_name(archive_name)
|
||||
self._validate_archive_for_restore(archive_path)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
warnings: list[str] = []
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"restore-{archive_path.stem}-",
|
||||
dir=self.backup_dir(),
|
||||
) as tmp:
|
||||
temp_dir = Path(tmp)
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
db_member = self._find_database_dump_member(archive) if restore_database else None
|
||||
compose_members = self._compose_file_members(archive) if restore_compose else []
|
||||
|
||||
if restore_database and db_member is None:
|
||||
raise BackupArchiveError("Archive does not contain a database dump")
|
||||
if restore_compose and not compose_members:
|
||||
raise BackupArchiveError("Archive does not contain compose files")
|
||||
|
||||
compose_target_dir: Optional[Path] = None
|
||||
compose_pre_restore_archive: Optional[Path] = None
|
||||
if restore_compose:
|
||||
compose_target_dir = self._compose_restore_target_dir()
|
||||
self._assert_compose_target_writable(compose_target_dir)
|
||||
compose_pre_restore_archive = self._snapshot_current_compose(compose_target_dir)
|
||||
|
||||
database_restored = False
|
||||
if db_member is not None:
|
||||
dump_path = self._extract_database_dump(archive, db_member, temp_dir)
|
||||
self._run_pg_restore(dump_path)
|
||||
database_restored = True
|
||||
|
||||
compose_files_restored = 0
|
||||
if compose_target_dir is not None:
|
||||
compose_files_restored = self._restore_compose_members(
|
||||
archive,
|
||||
compose_members,
|
||||
compose_target_dir,
|
||||
)
|
||||
|
||||
return BackupRestoreResult(
|
||||
archive_name=archive_path.name,
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
database_restored=database_restored,
|
||||
compose_files_restored=compose_files_restored,
|
||||
compose_target_dir=str(compose_target_dir) if compose_target_dir else None,
|
||||
compose_pre_restore_archive=str(compose_pre_restore_archive)
|
||||
if compose_pre_restore_archive
|
||||
else None,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def _run_pg_restore(self, dump_path: Path) -> None:
|
||||
pg_restore_path = str(getattr(self.settings, "BACKUP_PG_RESTORE_PATH", "pg_restore") or "")
|
||||
pg_restore_path = pg_restore_path or "pg_restore"
|
||||
if shutil.which(pg_restore_path) is None and Path(pg_restore_path).name == pg_restore_path:
|
||||
raise BackupRestoreError(
|
||||
"pg_restore executable was not found. Rebuild the backend image with "
|
||||
"PostgreSQL client tools."
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
|
||||
command = [
|
||||
pg_restore_path,
|
||||
"-h",
|
||||
self.settings.POSTGRES_HOST,
|
||||
"-p",
|
||||
str(self.settings.POSTGRES_PORT),
|
||||
"-U",
|
||||
self.settings.POSTGRES_USER,
|
||||
"-d",
|
||||
self.settings.POSTGRES_DB,
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
str(dump_path),
|
||||
]
|
||||
timeout = max(
|
||||
30,
|
||||
int(
|
||||
getattr(
|
||||
self.settings,
|
||||
"BACKUP_PG_RESTORE_TIMEOUT_SECONDS",
|
||||
self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS,
|
||||
)
|
||||
or 1800
|
||||
),
|
||||
)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or result.stdout or "").strip()
|
||||
raise BackupRestoreError(
|
||||
f"pg_restore failed with exit code {result.returncode}: {stderr[:500]}"
|
||||
)
|
||||
|
||||
def _compose_restore_target_dir(self) -> Path:
|
||||
target_raw = (
|
||||
getattr(self.settings, "BACKUP_COMPOSE_RESTORE_DIR", None)
|
||||
or self.settings.BACKUP_COMPOSE_SOURCE_DIR
|
||||
or ""
|
||||
)
|
||||
if not str(target_raw).strip():
|
||||
raise BackupArchiveError("Compose restore directory is not configured")
|
||||
return Path(str(target_raw)).expanduser()
|
||||
|
||||
def _assert_compose_target_writable(self, target_dir: Path) -> None:
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
raise BackupArchiveError(
|
||||
f"Compose restore directory is unavailable: {target_dir}. "
|
||||
"Mount the compose folder into the backend container."
|
||||
)
|
||||
probe = target_dir / f".restore-write-test-{os.getpid()}"
|
||||
try:
|
||||
probe.write_text("", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except OSError as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Compose restore directory is not writable: {target_dir}"
|
||||
) from exc
|
||||
|
||||
def _snapshot_current_compose(self, target_dir: Path) -> Optional[Path]:
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_path = self._unique_archive_path(f"{COMPOSE_PRE_RESTORE_PREFIX}{stamp}.zip")
|
||||
excluded_dirs = self._compose_excluded_dirs()
|
||||
files_count = 0
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"{archive_path.stem}-",
|
||||
dir=self.backup_dir(),
|
||||
) as tmp:
|
||||
staging_dir = Path(tmp)
|
||||
compose_dir = staging_dir / "compose"
|
||||
for path in sorted(target_dir.rglob("*")):
|
||||
relative = path.relative_to(target_dir)
|
||||
if any(part in excluded_dirs for part in relative.parts):
|
||||
continue
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
destination = compose_dir / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, destination)
|
||||
files_count += 1
|
||||
if files_count <= 0:
|
||||
return None
|
||||
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
manifest = {
|
||||
"app": BACKUP_APP_ID,
|
||||
"format_version": BACKUP_FORMAT_VERSION,
|
||||
"type": "compose-pre-restore",
|
||||
"created_at": completed_at.isoformat(),
|
||||
"created_at_local": completed_at.astimezone().isoformat(),
|
||||
"postgres": {
|
||||
"database": self.settings.POSTGRES_DB,
|
||||
"included": False,
|
||||
},
|
||||
"compose": {
|
||||
"source_dir": str(target_dir),
|
||||
"included": True,
|
||||
"files_count": files_count,
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||
try:
|
||||
write_zip_from_directory(staging_dir, tmp_archive)
|
||||
tmp_archive.replace(archive_path)
|
||||
finally:
|
||||
if tmp_archive.exists():
|
||||
try:
|
||||
tmp_archive.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove temporary snapshot %s", tmp_archive)
|
||||
return archive_path
|
||||
|
||||
def _restore_compose_members(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
members: list[zipfile.ZipInfo],
|
||||
target_dir: Path,
|
||||
) -> int:
|
||||
target_root = target_dir.resolve()
|
||||
restored = 0
|
||||
for member in members:
|
||||
relative = PurePosixPath(member.filename).relative_to("compose")
|
||||
destination = target_root.joinpath(*relative.parts).resolve()
|
||||
try:
|
||||
destination.relative_to(target_root)
|
||||
except ValueError as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Unsafe compose archive member: {member.filename}"
|
||||
) from exc
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_destination = destination.with_name(
|
||||
f".{destination.name}.restore-{os.getpid()}.tmp"
|
||||
)
|
||||
try:
|
||||
with archive.open(member) as source, temp_destination.open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
temp_destination.replace(destination)
|
||||
finally:
|
||||
if temp_destination.exists():
|
||||
try:
|
||||
temp_destination.unlink()
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Failed to remove temporary restore file %s",
|
||||
temp_destination,
|
||||
)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
def _extract_database_dump(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
member: zipfile.ZipInfo,
|
||||
temp_dir: Path,
|
||||
) -> Path:
|
||||
dump_dir = temp_dir / "database"
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
dump_path = dump_dir / Path(member.filename).name
|
||||
with archive.open(member) as source, dump_path.open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
return dump_path
|
||||
|
||||
def _find_database_dump_member(self, archive: zipfile.ZipFile) -> Optional[zipfile.ZipInfo]:
|
||||
candidates = [
|
||||
item
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir()
|
||||
and item.filename.startswith("database/")
|
||||
and PurePosixPath(item.filename).suffix.lower() in {".dump", ".backup"}
|
||||
]
|
||||
return sorted(candidates, key=lambda item: item.filename)[0] if candidates else None
|
||||
|
||||
def _compose_file_members(self, archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
||||
members = [
|
||||
item
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir() and item.filename.startswith("compose/")
|
||||
]
|
||||
self._validate_compose_members(members)
|
||||
return members
|
||||
|
||||
def _validate_zip_members(self, members: list[zipfile.ZipInfo]) -> None:
|
||||
if len(members) > BACKUP_MAX_MEMBERS:
|
||||
raise BackupArchiveError("Archive contains too many files")
|
||||
|
||||
seen: set[str] = set()
|
||||
total_size = 0
|
||||
for member in members:
|
||||
filename = member.filename
|
||||
if "\\" in filename or "\x00" in filename:
|
||||
raise BackupArchiveError(f"Unsafe archive member path: {filename}")
|
||||
path = PurePosixPath(member.filename)
|
||||
if (
|
||||
not path.parts
|
||||
or path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or any(part in {"", "."} for part in path.parts)
|
||||
):
|
||||
raise BackupArchiveError(f"Unsafe archive member path: {member.filename}")
|
||||
if member.is_dir():
|
||||
continue
|
||||
if filename in seen:
|
||||
raise BackupArchiveError(f"Duplicate archive member path: {filename}")
|
||||
seen.add(filename)
|
||||
if member.file_size > BACKUP_MAX_MEMBER_BYTES:
|
||||
raise BackupArchiveError(f"Archive member is too large: {filename}")
|
||||
total_size += int(member.file_size)
|
||||
if total_size > BACKUP_MAX_UNCOMPRESSED_BYTES:
|
||||
raise BackupArchiveError("Archive uncompressed size is too large")
|
||||
compressed = max(1, int(member.compress_size or 1))
|
||||
ratio = int(member.file_size) / compressed
|
||||
if (
|
||||
member.file_size >= BACKUP_ZIP_BOMB_MIN_BYTES
|
||||
and ratio > BACKUP_MAX_COMPRESSION_RATIO
|
||||
):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member compression ratio is too high: {filename}"
|
||||
)
|
||||
|
||||
def _validate_compose_members(self, members: list[zipfile.ZipInfo]) -> None:
|
||||
total_size = 0
|
||||
for member in members:
|
||||
if member.file_size > BACKUP_MAX_COMPOSE_MEMBER_BYTES:
|
||||
raise BackupArchiveError(f"Compose archive member is too large: {member.filename}")
|
||||
total_size += int(member.file_size)
|
||||
if total_size > BACKUP_MAX_COMPOSE_BYTES:
|
||||
raise BackupArchiveError("Compose archive contents are too large")
|
||||
|
||||
def _read_manifest(self, archive: zipfile.ZipFile) -> dict[str, Any]:
|
||||
if BACKUP_MANIFEST_NAME not in archive.namelist():
|
||||
raise BackupArchiveError("Archive does not contain manifest.json")
|
||||
try:
|
||||
manifest = json.loads(archive.read(BACKUP_MANIFEST_NAME).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BackupArchiveError("manifest.json is not valid JSON") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise BackupArchiveError("manifest.json must contain an object")
|
||||
if manifest.get("app") != BACKUP_APP_ID:
|
||||
raise BackupArchiveError("Archive manifest belongs to another application")
|
||||
try:
|
||||
format_version = int(manifest.get("format_version") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BackupArchiveError("Archive manifest format is not supported") from exc
|
||||
if format_version != BACKUP_FORMAT_VERSION:
|
||||
raise BackupArchiveError("Archive manifest format is not supported")
|
||||
return manifest
|
||||
|
||||
def _validate_archive_for_restore(self, archive_path: Path) -> None:
|
||||
if not zipfile.is_zipfile(archive_path):
|
||||
raise BackupArchiveError("Archive is not a valid ZIP file")
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
self._validate_zip_members(archive.infolist())
|
||||
manifest = self._read_manifest(archive)
|
||||
self._validate_archive_integrity(archive, manifest)
|
||||
|
||||
def _validate_archive_integrity(
|
||||
self,
|
||||
archive: zipfile.ZipFile,
|
||||
manifest: dict[str, Any],
|
||||
) -> None:
|
||||
archive_manifest = (
|
||||
manifest.get("archive") if isinstance(manifest.get("archive"), dict) else {}
|
||||
)
|
||||
file_records = archive_manifest.get("files")
|
||||
if not isinstance(file_records, list):
|
||||
raise BackupArchiveError("Archive manifest does not contain file checksums")
|
||||
|
||||
expected: dict[str, dict[str, Any]] = {}
|
||||
for record in file_records:
|
||||
if not isinstance(record, dict):
|
||||
raise BackupArchiveError("Archive manifest contains invalid file record")
|
||||
filename = str(record.get("path") or "")
|
||||
if not filename:
|
||||
raise BackupArchiveError("Archive manifest contains empty file path")
|
||||
if filename in expected:
|
||||
raise BackupArchiveError(
|
||||
f"Archive manifest contains duplicate file path: {filename}"
|
||||
)
|
||||
expected[filename] = record
|
||||
|
||||
actual = {
|
||||
item.filename
|
||||
for item in archive.infolist()
|
||||
if not item.is_dir() and item.filename != BACKUP_MANIFEST_NAME
|
||||
}
|
||||
if actual != set(expected):
|
||||
raise BackupArchiveError("Archive contents do not match manifest")
|
||||
|
||||
for info in archive.infolist():
|
||||
if info.is_dir() or info.filename == BACKUP_MANIFEST_NAME:
|
||||
continue
|
||||
record = expected[info.filename]
|
||||
try:
|
||||
expected_size = int(record.get("size_bytes") or -1)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BackupArchiveError(
|
||||
f"Archive manifest size is invalid: {info.filename}"
|
||||
) from exc
|
||||
expected_hash = str(record.get("sha256") or "")
|
||||
if expected_size != int(info.file_size):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member size does not match manifest: {info.filename}"
|
||||
)
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
|
||||
raise BackupArchiveError(f"Archive manifest checksum is invalid: {info.filename}")
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info) as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
if not hmac.compare_digest(digest.hexdigest(), expected_hash):
|
||||
raise BackupArchiveError(
|
||||
f"Archive member checksum does not match manifest: {info.filename}"
|
||||
)
|
||||
|
||||
def _compose_excluded_dirs(self) -> set[str]:
|
||||
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
|
||||
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
|
||||
|
||||
@staticmethod
|
||||
def _split_csv(value: Optional[str]) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _file_digest(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _safe_original_stem(filename: str) -> str:
|
||||
stem = Path(str(filename or "backup")).stem
|
||||
safe = re.sub(r"[^A-Za-z0-9_.+-]+", "-", stem).strip(".-")
|
||||
return (safe or "backup")[:72]
|
||||
|
||||
def _unique_archive_path(self, archive_name: str) -> Path:
|
||||
backup_dir = self.backup_dir()
|
||||
stem = Path(archive_name).stem
|
||||
suffix = Path(archive_name).suffix
|
||||
candidate = backup_dir / archive_name
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = backup_dir / f"{stem}-{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.services.backup_archive import (
|
||||
BACKUP_APP_ID,
|
||||
BACKUP_FILENAME_PREFIX,
|
||||
BACKUP_FORMAT_VERSION,
|
||||
attach_archive_integrity,
|
||||
backup_filename_timestamp,
|
||||
build_file_records,
|
||||
write_manifest,
|
||||
write_zip_from_directory,
|
||||
)
|
||||
from config.settings import Settings
|
||||
|
||||
COMPOSE_MARKER_FILES = {
|
||||
"compose.yaml",
|
||||
"compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"docker-compose.yml",
|
||||
}
|
||||
DEFAULT_COMPOSE_EXCLUDED_DIRS = {
|
||||
".git",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"backups",
|
||||
"node_modules",
|
||||
"postgres-data",
|
||||
"redis-data",
|
||||
"shop-data",
|
||||
}
|
||||
BACKUP_RUNTIME_SETTING_KEYS = {
|
||||
"BACKUP_ENABLED",
|
||||
"BACKUP_CHAT_ID",
|
||||
"BACKUP_THREAD_ID",
|
||||
"BACKUP_INTERVAL_SECONDS",
|
||||
"BACKUP_LOCAL_RETENTION",
|
||||
"BACKUP_POSTGRES_DUMP_ENABLED",
|
||||
"BACKUP_PG_DUMP_PATH",
|
||||
"BACKUP_PG_DUMP_TIMEOUT_SECONDS",
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
"BACKUP_COMPOSE_SOURCE_DIR",
|
||||
"BACKUP_COMPOSE_EXCLUDE_DIRS",
|
||||
}
|
||||
TELEGRAM_DOCUMENT_CAPTION_LIMIT = 1024
|
||||
TELEGRAM_WARNING_DETAIL_LIMIT = 6
|
||||
TELEGRAM_WARNING_LINE_LIMIT = 220
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupResult:
|
||||
archive_path: Path
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
db_dump_included: bool
|
||||
compose_files_count: int
|
||||
size_bytes: int
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"archive_name": self.archive_path.name,
|
||||
"archive_path": str(self.archive_path),
|
||||
"started_at": self.started_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat(),
|
||||
"db_dump_included": self.db_dump_included,
|
||||
"compose_files_count": self.compose_files_count,
|
||||
"size_bytes": self.size_bytes,
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class BackupWorker:
|
||||
SETTINGS_REFRESH_SECONDS = 60
|
||||
|
||||
def __init__(self, settings: Settings, bot: Bot, session_factory=None):
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
await asyncio.sleep(self.SETTINGS_REFRESH_SECONDS)
|
||||
continue
|
||||
|
||||
interval = self._interval_seconds()
|
||||
delay_seconds = self._seconds_until_next_slot(interval)
|
||||
if delay_seconds > 0:
|
||||
should_run = await self._sleep_until_next_slot(delay_seconds, interval)
|
||||
if not should_run:
|
||||
continue
|
||||
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
continue
|
||||
|
||||
try:
|
||||
ttl_seconds = max(
|
||||
60,
|
||||
int(getattr(self.settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200),
|
||||
)
|
||||
async with redis_lock(
|
||||
self.settings,
|
||||
"backup-worker",
|
||||
ttl_seconds=ttl_seconds,
|
||||
) as acquired:
|
||||
if acquired:
|
||||
started = time.monotonic()
|
||||
result = await self.create_and_send_backup()
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f worker=backup size_bytes=%s",
|
||||
time.monotonic() - started,
|
||||
result.size_bytes,
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"Backup worker tick skipped because another worker holds the lock"
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("Backup worker tick failed")
|
||||
await self._notify_failure(exc)
|
||||
|
||||
async def create_and_send_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||
result = await self.create_backup(backup_type=backup_type)
|
||||
try:
|
||||
await self.send_backup(result)
|
||||
finally:
|
||||
self.prune_old_backups()
|
||||
return result
|
||||
|
||||
async def create_backup(self, *, backup_type: str = "scheduled") -> BackupResult:
|
||||
started_at = datetime.now(timezone.utc)
|
||||
stamp = backup_filename_timestamp()
|
||||
archive_name = f"{BACKUP_FILENAME_PREFIX}{stamp}.zip"
|
||||
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = self._unique_archive_path(backup_dir / archive_name)
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=f"{BACKUP_FILENAME_PREFIX}{stamp}-",
|
||||
dir=backup_dir,
|
||||
) as tmp:
|
||||
staging_dir = Path(tmp)
|
||||
warnings: list[str] = []
|
||||
db_dump_included = False
|
||||
compose_files_count = 0
|
||||
|
||||
if self.settings.BACKUP_POSTGRES_DUMP_ENABLED:
|
||||
dump_dir = staging_dir / "database"
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
dump_path = dump_dir / f"{self.settings.POSTGRES_DB}.dump"
|
||||
await self._dump_database(dump_path)
|
||||
db_dump_included = True
|
||||
|
||||
if self.settings.BACKUP_COMPOSE_ENABLED:
|
||||
compose_files_count = self._stage_compose_source(staging_dir / "compose", warnings)
|
||||
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
manifest = {
|
||||
"app": BACKUP_APP_ID,
|
||||
"format_version": BACKUP_FORMAT_VERSION,
|
||||
"type": str(backup_type or "scheduled"),
|
||||
"created_at": completed_at.isoformat(),
|
||||
"created_at_local": completed_at.astimezone().isoformat(),
|
||||
"postgres": {
|
||||
"host": self.settings.POSTGRES_HOST,
|
||||
"port": self.settings.POSTGRES_PORT,
|
||||
"database": self.settings.POSTGRES_DB,
|
||||
"user": self.settings.POSTGRES_USER,
|
||||
"dump_format": "pg_dump custom",
|
||||
"included": db_dump_included,
|
||||
},
|
||||
"compose": {
|
||||
"source_dir": self.settings.BACKUP_COMPOSE_SOURCE_DIR,
|
||||
"included": compose_files_count > 0,
|
||||
"files_count": compose_files_count,
|
||||
},
|
||||
"warnings": warnings,
|
||||
}
|
||||
attach_archive_integrity(
|
||||
manifest,
|
||||
file_records=build_file_records(staging_dir),
|
||||
)
|
||||
write_manifest(staging_dir, manifest)
|
||||
|
||||
tmp_archive = archive_path.with_name(f"{archive_path.name}.tmp")
|
||||
write_zip_from_directory(staging_dir, tmp_archive)
|
||||
tmp_archive.replace(archive_path)
|
||||
|
||||
return BackupResult(
|
||||
archive_path=archive_path,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
db_dump_included=db_dump_included,
|
||||
compose_files_count=compose_files_count,
|
||||
size_bytes=archive_path.stat().st_size,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def _unique_archive_path(self, archive_path: Path) -> Path:
|
||||
if not archive_path.exists():
|
||||
return archive_path
|
||||
for index in range(2, 1000):
|
||||
candidate = archive_path.with_name(f"{archive_path.stem}-{index}{archive_path.suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise RuntimeError("Could not allocate a unique backup archive filename")
|
||||
|
||||
async def _dump_database(self, dump_path: Path) -> None:
|
||||
await asyncio.to_thread(self._run_pg_dump, dump_path)
|
||||
|
||||
def _run_pg_dump(self, dump_path: Path) -> None:
|
||||
pg_dump_path = str(self.settings.BACKUP_PG_DUMP_PATH or "pg_dump")
|
||||
if shutil.which(pg_dump_path) is None and Path(pg_dump_path).name == pg_dump_path:
|
||||
raise RuntimeError(
|
||||
"pg_dump executable was not found. Rebuild the worker image with "
|
||||
"PostgreSQL client tools."
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PGPASSWORD"] = self.settings.POSTGRES_PASSWORD
|
||||
command = [
|
||||
pg_dump_path,
|
||||
"-h",
|
||||
self.settings.POSTGRES_HOST,
|
||||
"-p",
|
||||
str(self.settings.POSTGRES_PORT),
|
||||
"-U",
|
||||
self.settings.POSTGRES_USER,
|
||||
"-d",
|
||||
self.settings.POSTGRES_DB,
|
||||
"--format=custom",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--file",
|
||||
str(dump_path),
|
||||
]
|
||||
timeout = max(30, int(self.settings.BACKUP_PG_DUMP_TIMEOUT_SECONDS or 1800))
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(f"pg_dump failed with exit code {result.returncode}: {stderr[:500]}")
|
||||
|
||||
def _stage_compose_source(self, target_dir: Path, warnings: list[str]) -> int:
|
||||
source_raw = (self.settings.BACKUP_COMPOSE_SOURCE_DIR or "").strip()
|
||||
if not source_raw:
|
||||
warnings.append(
|
||||
"Compose source directory is not configured. Set "
|
||||
"BACKUP_COMPOSE_SOURCE_DIR or mount the compose folder into the backup container."
|
||||
)
|
||||
return 0
|
||||
|
||||
source_dir = Path(source_raw).expanduser()
|
||||
if not source_dir.exists() or not source_dir.is_dir():
|
||||
warnings.append(
|
||||
"If manual backup includes compose but scheduled backup does not, recreate "
|
||||
"the worker service with the compose-source mount. Compose source directory "
|
||||
f"is unavailable in this container: {source_dir}"
|
||||
)
|
||||
return 0
|
||||
|
||||
if not any((source_dir / marker).is_file() for marker in COMPOSE_MARKER_FILES):
|
||||
warnings.append(
|
||||
"Check that COMPOSE_BACKUP_SOURCE points to the folder with docker-compose.yml. "
|
||||
f"Compose source directory has no compose file marker: {source_dir}"
|
||||
)
|
||||
|
||||
excluded_dirs = self._compose_excluded_dirs()
|
||||
files_count = 0
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for path in source_dir.rglob("*"):
|
||||
relative = path.relative_to(source_dir)
|
||||
if any(part in excluded_dirs for part in relative.parts):
|
||||
continue
|
||||
if path.is_dir() or path.is_symlink():
|
||||
continue
|
||||
if path.name.startswith(f"{BACKUP_FILENAME_PREFIX}") and path.suffix == ".zip":
|
||||
continue
|
||||
destination = target_dir / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(path, destination)
|
||||
files_count += 1
|
||||
except OSError as exc:
|
||||
warnings.append(f"Skipped compose file {relative.as_posix()}: {exc}")
|
||||
|
||||
return files_count
|
||||
|
||||
def _compose_excluded_dirs(self) -> set[str]:
|
||||
configured = self._split_csv(self.settings.BACKUP_COMPOSE_EXCLUDE_DIRS)
|
||||
return DEFAULT_COMPOSE_EXCLUDED_DIRS | set(configured)
|
||||
|
||||
@staticmethod
|
||||
def _split_csv(value: Optional[str]) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
async def send_backup(self, result: BackupResult) -> None:
|
||||
chat_id = self._target_chat_id()
|
||||
if chat_id is None:
|
||||
logging.warning(
|
||||
"Backup archive created at %s but BACKUP_CHAT_ID/LOG_CHAT_ID is not configured",
|
||||
result.archive_path,
|
||||
)
|
||||
return
|
||||
|
||||
kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"document": FSInputFile(result.archive_path),
|
||||
"caption": self._caption(result),
|
||||
}
|
||||
thread_id = self._target_thread_id()
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
await self.bot.send_document(**kwargs)
|
||||
|
||||
def prune_old_backups(self) -> None:
|
||||
retention = int(getattr(self.settings, "BACKUP_LOCAL_RETENTION", 3) or 0)
|
||||
if retention <= 0:
|
||||
return
|
||||
|
||||
backup_dir = Path(self.settings.BACKUP_DIR).expanduser()
|
||||
archives = sorted(
|
||||
backup_dir.glob(f"{BACKUP_FILENAME_PREFIX}*.zip"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for archive in archives[retention:]:
|
||||
try:
|
||||
archive.unlink()
|
||||
except OSError:
|
||||
logging.exception("Failed to delete old backup archive %s", archive)
|
||||
|
||||
def _target_chat_id(self) -> Optional[int]:
|
||||
return self.settings.BACKUP_CHAT_ID or self.settings.LOG_CHAT_ID
|
||||
|
||||
def _target_thread_id(self) -> Optional[int]:
|
||||
return self.settings.BACKUP_THREAD_ID or self.settings.LOG_THREAD_ID
|
||||
|
||||
def _caption(self, result: BackupResult) -> str:
|
||||
completed_at = result.completed_at.astimezone()
|
||||
lines = [
|
||||
"Remnawave Minishop backup",
|
||||
f"Created: {completed_at.strftime('%Y-%m-%d %H:%M:%S %Z')}",
|
||||
f"Database dump: {'yes' if result.db_dump_included else 'no'}",
|
||||
f"Compose files: {result.compose_files_count}",
|
||||
f"Archive size: {self._human_size(result.size_bytes)}",
|
||||
]
|
||||
if result.warnings:
|
||||
lines.append(f"Warnings ({len(result.warnings)}):")
|
||||
for index, warning in enumerate(
|
||||
result.warnings[:TELEGRAM_WARNING_DETAIL_LIMIT],
|
||||
start=1,
|
||||
):
|
||||
lines.append(f"{index}. {self._caption_warning(warning)}")
|
||||
hidden_count = len(result.warnings) - TELEGRAM_WARNING_DETAIL_LIMIT
|
||||
if hidden_count > 0:
|
||||
lines.append(f"... and {hidden_count} more warning(s)")
|
||||
return self._fit_caption(lines)
|
||||
|
||||
@staticmethod
|
||||
def _caption_warning(warning: str) -> str:
|
||||
text = " ".join(str(warning or "").split())
|
||||
if len(text) <= TELEGRAM_WARNING_LINE_LIMIT:
|
||||
return text
|
||||
return f"{text[: TELEGRAM_WARNING_LINE_LIMIT - 1].rstrip()}..."
|
||||
|
||||
@staticmethod
|
||||
def _fit_caption(lines: list[str]) -> str:
|
||||
caption = "\n".join(lines)
|
||||
if len(caption) <= TELEGRAM_DOCUMENT_CAPTION_LIMIT:
|
||||
return caption
|
||||
suffix = "\n... caption truncated"
|
||||
return f"{caption[: TELEGRAM_DOCUMENT_CAPTION_LIMIT - len(suffix)].rstrip()}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def _human_size(size_bytes: int) -> str:
|
||||
units: Iterable[str] = ("B", "KB", "MB", "GB")
|
||||
size = float(size_bytes)
|
||||
unit = "B"
|
||||
for unit in units:
|
||||
if size < 1024 or unit == "GB":
|
||||
break
|
||||
size /= 1024
|
||||
if unit == "B":
|
||||
return f"{int(size)} {unit}"
|
||||
return f"{size:.1f} {unit}"
|
||||
|
||||
async def refresh_settings(self) -> None:
|
||||
await self._refresh_settings()
|
||||
|
||||
async def _refresh_settings(self) -> None:
|
||||
if self.session_factory is None:
|
||||
return
|
||||
try:
|
||||
from bot.services.settings_override_service import refresh_overrides_from_db
|
||||
|
||||
await refresh_overrides_from_db(
|
||||
self.settings,
|
||||
self.session_factory,
|
||||
keys=BACKUP_RUNTIME_SETTING_KEYS,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to refresh backup settings from DB")
|
||||
|
||||
def _interval_seconds(self) -> int:
|
||||
try:
|
||||
interval = int(self.settings.BACKUP_INTERVAL_SECONDS or 0)
|
||||
except (TypeError, ValueError):
|
||||
interval = 0
|
||||
return max(60, interval)
|
||||
|
||||
def _seconds_until_next_slot(self, interval_seconds: int) -> float:
|
||||
now = datetime.now().astimezone()
|
||||
if interval_seconds <= 0:
|
||||
return 0.0
|
||||
if interval_seconds <= 24 * 60 * 60:
|
||||
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elapsed = (now - midnight).total_seconds()
|
||||
remainder = elapsed % interval_seconds
|
||||
else:
|
||||
remainder = time.time() % interval_seconds
|
||||
if remainder < 0.5:
|
||||
return 0.0
|
||||
return max(0.0, interval_seconds - remainder)
|
||||
|
||||
async def _sleep_until_next_slot(self, delay_seconds: float, interval_seconds: int) -> bool:
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
|
||||
while True:
|
||||
remaining = (deadline - datetime.now(timezone.utc)).total_seconds()
|
||||
if remaining <= 0:
|
||||
return True
|
||||
await asyncio.sleep(min(remaining, self.SETTINGS_REFRESH_SECONDS))
|
||||
await self._refresh_settings()
|
||||
if not self.settings.BACKUP_ENABLED:
|
||||
return False
|
||||
if self._interval_seconds() != interval_seconds:
|
||||
return False
|
||||
|
||||
async def _notify_failure(self, exc: Exception) -> None:
|
||||
chat_id = self._target_chat_id()
|
||||
if chat_id is None:
|
||||
return
|
||||
kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"text": f"Remnawave Minishop backup failed: {type(exc).__name__}. Check worker logs.",
|
||||
}
|
||||
thread_id = self._target_thread_id()
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
try:
|
||||
await self.bot.send_message(**kwargs)
|
||||
except Exception:
|
||||
logging.exception("Failed to send backup failure notification")
|
||||
@@ -15,6 +15,7 @@ from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
@@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona
|
||||
|
||||
|
||||
class EmailAuthService:
|
||||
def __init__(self, settings: Settings):
|
||||
def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None):
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
|
||||
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
||||
attempts: list[SmtpAttempt] = []
|
||||
@@ -465,6 +467,7 @@ class EmailAuthService:
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
i18n=self.i18n,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
|
||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
|
||||
from config.settings import Settings
|
||||
|
||||
_BG = "#05070a"
|
||||
@@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str:
|
||||
|
||||
|
||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
return normalize_locale_language_code(
|
||||
language_code or settings.DEFAULT_LANGUAGE or "ru",
|
||||
prefer_known_base=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
"""Load, persist and apply runtime overrides for localization strings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import (
|
||||
JsonI18n,
|
||||
LocaleOverrides,
|
||||
is_valid_locale_language_code,
|
||||
normalize_locale_language_code,
|
||||
normalize_locale_overrides_payload,
|
||||
resolve_locale_key,
|
||||
)
|
||||
from db.dal import locale_overrides_dal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALE_OVERRIDES_PATH = APP_ROOT / "data" / "locales-overrides.json"
|
||||
|
||||
LOCALE_GROUPS = [
|
||||
{
|
||||
"id": "admin_navigation",
|
||||
"title": "Admin navigation and shared UI",
|
||||
"description": "Sidebar, section headers, toolbar actions, filters, and shared controls.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_nav_",
|
||||
"admin_section_",
|
||||
"admin_panel_title",
|
||||
"admin_back_to_panel",
|
||||
"admin_sidebar_",
|
||||
"admin_exit",
|
||||
"admin_menu",
|
||||
"admin_language",
|
||||
"admin_page_",
|
||||
"admin_close",
|
||||
"admin_collapse",
|
||||
"admin_expand",
|
||||
"admin_show",
|
||||
"admin_hide",
|
||||
"admin_loading",
|
||||
"admin_btn_",
|
||||
"admin_filter_",
|
||||
"admin_sort_",
|
||||
"admin_status_",
|
||||
"admin_badge_",
|
||||
"admin_backups_",
|
||||
"admin_aria_",
|
||||
"admin_search",
|
||||
"admin_clear",
|
||||
"admin_save",
|
||||
"admin_saving",
|
||||
"admin_add",
|
||||
"admin_apply",
|
||||
"admin_reset",
|
||||
"admin_copy",
|
||||
"admin_copied",
|
||||
"admin_error",
|
||||
"admin_unknown_action",
|
||||
"back_to_admin_panel_button",
|
||||
"back_to_ads_list_button",
|
||||
"back_to_stats_monitoring_button",
|
||||
"back_to_user_management_button",
|
||||
"prev_page_button",
|
||||
"next_page_button",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_dashboard",
|
||||
"title": "Admin dashboard and stats",
|
||||
"description": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_stats_",
|
||||
"admin_financial_",
|
||||
"admin_enhanced_",
|
||||
"admin_panel_stats_",
|
||||
"admin_panel_traffic_",
|
||||
"admin_queue_",
|
||||
"admin_sync_status_",
|
||||
"admin_stats_button",
|
||||
"admin_sync_panel_button",
|
||||
"admin_sync_initiated_from_panel",
|
||||
"admin_total",
|
||||
"error_displaying_statistics",
|
||||
"inline_admin_",
|
||||
"inline_user_stats_",
|
||||
"inline_financial_",
|
||||
"inline_system_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_users",
|
||||
"title": "Admin users",
|
||||
"description": (
|
||||
"User lists, user cards, bans, grants, premium overrides, and direct messages."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_user_",
|
||||
"admin_users_",
|
||||
"admin_ban_",
|
||||
"admin_unban_",
|
||||
"admin_banned_",
|
||||
"admin_premium_override_",
|
||||
"admin_traffic_grant_",
|
||||
"admin_view_banned_",
|
||||
"user_card_",
|
||||
"user_premium_",
|
||||
"user_regular_",
|
||||
"user_traffic_",
|
||||
"user_override_",
|
||||
"premium_override_",
|
||||
"regular_override_",
|
||||
"traffic_grant_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_payments",
|
||||
"title": "Admin payments",
|
||||
"description": (
|
||||
"Payment tables, payment details, exports, provider labels, and payment stats."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_payment_",
|
||||
"admin_payments_",
|
||||
"admin_no_payments",
|
||||
"admin_view_payments",
|
||||
"admin_refresh_payments",
|
||||
"admin_export_payments",
|
||||
"admin_export_sent",
|
||||
"admin_amount",
|
||||
"admin_provider",
|
||||
"admin_description",
|
||||
"admin_date",
|
||||
"admin_csv_payment_",
|
||||
"admin_csv_amount",
|
||||
"admin_csv_currency",
|
||||
"admin_csv_provider",
|
||||
"admin_csv_status",
|
||||
"admin_csv_description",
|
||||
"admin_csv_units",
|
||||
"admin_csv_months",
|
||||
"admin_csv_created_at",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_promos_marketing",
|
||||
"title": "Admin promos, ads, and broadcasts",
|
||||
"description": "Promo management, ad campaigns, marketing tools, and broadcast workflows.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_promo_",
|
||||
"admin_promos_",
|
||||
"admin_bulk_promo_",
|
||||
"admin_ads_",
|
||||
"admin_ad_",
|
||||
"admin_broadcast_",
|
||||
"admin_create_promo_",
|
||||
"admin_create_bulk_promo_",
|
||||
"admin_active_promos_",
|
||||
"broadcast_",
|
||||
"confirm_broadcast_",
|
||||
"cancel_broadcast_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_tariffs",
|
||||
"title": "Admin tariffs",
|
||||
"description": (
|
||||
"Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_tariff_", "admin_tariffs_", "admin_trial"),
|
||||
},
|
||||
{
|
||||
"id": "admin_support",
|
||||
"title": "Admin support inbox",
|
||||
"description": "Support ticket inbox, ticket filters, admin replies, and support statuses.",
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_support_",),
|
||||
},
|
||||
{
|
||||
"id": "admin_appearance",
|
||||
"title": "Admin appearance",
|
||||
"description": "Theme catalog, branding, logo, favicon, and public page links.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_themes_",
|
||||
"admin_appearance",
|
||||
"admin_settings_icon_",
|
||||
"admin_settings_field_webapp_",
|
||||
"admin_settings_field_subscription_mini_app_url",
|
||||
"admin_settings_field_support_link",
|
||||
"admin_settings_field_server_status_url",
|
||||
"admin_settings_field_terms_",
|
||||
"admin_settings_field_privacy_",
|
||||
"admin_settings_field_user_agreement_",
|
||||
"appearance_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings_payments",
|
||||
"title": "Admin payment settings",
|
||||
"description": (
|
||||
"Payment method toggles, prices, provider credentials, and webhook settings."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_default_currency_",
|
||||
"admin_settings_field_month_",
|
||||
"admin_settings_field_rub_",
|
||||
"admin_settings_field_stars_",
|
||||
"admin_settings_field_traffic_packages_",
|
||||
"admin_settings_field_payment_methods_",
|
||||
"admin_settings_field_subscription_purchase_",
|
||||
"admin_settings_field_yookassa_",
|
||||
"admin_settings_field_freekassa_",
|
||||
"admin_settings_field_platega_",
|
||||
"admin_settings_field_severpay_",
|
||||
"admin_settings_field_cryptopay_",
|
||||
"admin_settings_field_wata_",
|
||||
"admin_settings_field_heleket_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings_subscriptions",
|
||||
"title": "Admin subscription settings",
|
||||
"description": (
|
||||
"Panel connection, default squads, trials, referrals, device limits, and guides."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_panel_",
|
||||
"admin_settings_field_user_",
|
||||
"admin_settings_field_trial_",
|
||||
"admin_settings_field_referral_",
|
||||
"admin_settings_field_legacy_refs",
|
||||
"admin_settings_field_my_devices_",
|
||||
"admin_settings_field_subscription_guides_",
|
||||
"admin_settings_field_subscription_page_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings_notifications",
|
||||
"title": "Admin notifications and logs",
|
||||
"description": "Logging, required channel, subscription notifications, and support limits.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_settings_field_log_",
|
||||
"admin_settings_field_backup_",
|
||||
"admin_settings_field_support_",
|
||||
"admin_settings_field_subscription_notifications_",
|
||||
"admin_settings_field_subscription_notify_",
|
||||
"admin_settings_field_required_",
|
||||
"admin_settings_field_disable_welcome_",
|
||||
"admin_settings_field_start_command_",
|
||||
"admin_settings_field_default_language_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_settings",
|
||||
"title": "Admin settings",
|
||||
"description": (
|
||||
"Settings screen groups, subsections, helper text, and uncategorized settings."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_settings_",),
|
||||
},
|
||||
{
|
||||
"id": "admin_translations",
|
||||
"title": "Admin translations",
|
||||
"description": "Translation override screen, language controls, and locale group labels.",
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_translations_",),
|
||||
},
|
||||
{
|
||||
"id": "admin_logs",
|
||||
"title": "Admin logs and exports",
|
||||
"description": "Activity logs, log exports, CSV headers, and event detail labels.",
|
||||
"audience": "internal",
|
||||
"prefixes": (
|
||||
"admin_logs_",
|
||||
"admin_log_",
|
||||
"admin_all_logs_",
|
||||
"admin_view_logs_",
|
||||
"admin_export_logs_",
|
||||
"admin_no_logs",
|
||||
"admin_csv_header_",
|
||||
"admin_event",
|
||||
"admin_content",
|
||||
"csv_yes",
|
||||
"csv_no",
|
||||
"error_displaying_logs_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "admin_misc",
|
||||
"title": "Admin miscellaneous",
|
||||
"description": (
|
||||
"Older bot-admin labels and admin-only strings that do not fit another section."
|
||||
),
|
||||
"audience": "internal",
|
||||
"prefixes": ("admin_",),
|
||||
},
|
||||
{
|
||||
"id": "webapp",
|
||||
"title": "Mini App",
|
||||
"description": "User-facing Mini App screens, navigation, settings, and toasts.",
|
||||
"audience": "user",
|
||||
"prefixes": ("wa_",),
|
||||
},
|
||||
{
|
||||
"id": "bot_menu",
|
||||
"title": "Telegram bot menu",
|
||||
"description": "Start menu, inline buttons, language selector, and bot-only flows.",
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"main_menu_",
|
||||
"menu_",
|
||||
"bot_interface_",
|
||||
"choose_language",
|
||||
"language_",
|
||||
"back_",
|
||||
"cancel_",
|
||||
"connect_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "subscriptions",
|
||||
"title": "Subscriptions and devices",
|
||||
"description": (
|
||||
"Subscription status, install guides, traffic packages, trials, and devices."
|
||||
),
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"subscription_",
|
||||
"trial_",
|
||||
"tariff_",
|
||||
"traffic_",
|
||||
"device_",
|
||||
"devices_",
|
||||
"my_devices_",
|
||||
"install_",
|
||||
"config_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "payments",
|
||||
"title": "Payments",
|
||||
"description": "Payment provider flows, invoices, payment methods, and checkout messages.",
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"payment_",
|
||||
"pay_",
|
||||
"yookassa_",
|
||||
"free_kassa_",
|
||||
"freekassa_",
|
||||
"wata_",
|
||||
"heleket_",
|
||||
"cryptopay_",
|
||||
"platega_",
|
||||
"stars_",
|
||||
"autorenew_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"title": "Support",
|
||||
"description": "Support links, ticket inbox copy, ticket statuses, and notifications.",
|
||||
"audience": "user",
|
||||
"prefixes": ("support_", "ticket_"),
|
||||
},
|
||||
{
|
||||
"id": "referrals_promos",
|
||||
"title": "Referrals and promos",
|
||||
"description": "Referral program, invite copy, promo codes, and bonuses.",
|
||||
"audience": "user",
|
||||
"prefixes": ("referral_", "promo_", "invite_", "inline_referral_"),
|
||||
},
|
||||
{
|
||||
"id": "auth_security",
|
||||
"title": "Auth and security",
|
||||
"description": "Login, email verification, account linking, and security messages.",
|
||||
"audience": "user",
|
||||
"prefixes": (
|
||||
"auth_",
|
||||
"login_",
|
||||
"password_",
|
||||
"security_",
|
||||
"webapp_auth_",
|
||||
"channel_subscription_",
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "emails",
|
||||
"title": "Emails",
|
||||
"description": "Transactional emails sent to users: login codes, payments, and reminders.",
|
||||
"audience": "user",
|
||||
"prefixes": ("email_",),
|
||||
},
|
||||
{
|
||||
"id": "notifications_sync",
|
||||
"title": "Notifications and sync",
|
||||
"description": "Admin notifications, panel sync, logs, and background status messages.",
|
||||
"audience": "internal",
|
||||
"prefixes": ("notification_", "notifications_", "sync_", "log_", "panel_"),
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_LOCALE_GROUP = {
|
||||
"id": "common",
|
||||
"title": "Common",
|
||||
"description": "Shared buttons, statuses, validation errors, and uncategorized strings.",
|
||||
"audience": "user",
|
||||
"prefixes": (),
|
||||
}
|
||||
|
||||
INTERNAL_LOCALE_KEY_PREFIXES = (
|
||||
"admin_",
|
||||
"log_",
|
||||
"notification_",
|
||||
"notifications_",
|
||||
"panel_",
|
||||
"sync_",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocaleOverridesFileState:
|
||||
exists: bool
|
||||
readable: bool
|
||||
overrides: LocaleOverrides
|
||||
|
||||
|
||||
def _valid_languages(i18n: JsonI18n) -> set[str]:
|
||||
return set((i18n.base_locales_data or i18n.locales_data or {}).keys())
|
||||
|
||||
|
||||
def _valid_keys_by_language(i18n: JsonI18n) -> Dict[str, set[str]]:
|
||||
source = i18n.base_locales_data or i18n.locales_data or {}
|
||||
return {
|
||||
lang: {str(key) for key in messages}
|
||||
for lang, messages in source.items()
|
||||
if isinstance(messages, dict)
|
||||
}
|
||||
|
||||
|
||||
def _normalize_for_i18n(i18n: JsonI18n, payload: object) -> tuple[LocaleOverrides, Dict[str, str]]:
|
||||
return normalize_locale_overrides_payload(
|
||||
payload,
|
||||
valid_languages=_valid_languages(i18n),
|
||||
valid_keys_by_language=_valid_keys_by_language(i18n),
|
||||
allow_extra_languages=True,
|
||||
)
|
||||
|
||||
|
||||
def _flatten(overrides: LocaleOverrides) -> Iterable[Tuple[str, str, str]]:
|
||||
for lang, messages in overrides.items():
|
||||
for key, value in messages.items():
|
||||
yield lang, key, value
|
||||
|
||||
|
||||
def _flat_map(overrides: LocaleOverrides) -> Dict[Tuple[str, str], str]:
|
||||
return {(lang, key): value for lang, key, value in _flatten(overrides)}
|
||||
|
||||
|
||||
def _count_overrides(overrides: LocaleOverrides) -> int:
|
||||
return sum(len(messages) for messages in overrides.values())
|
||||
|
||||
|
||||
def _read_locale_overrides_file_state(
|
||||
i18n: JsonI18n,
|
||||
*,
|
||||
path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> LocaleOverridesFileState:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return LocaleOverridesFileState(exists=False, readable=False, overrides={})
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read locale overrides from %s: %s", path, exc)
|
||||
return LocaleOverridesFileState(exists=True, readable=False, overrides={})
|
||||
|
||||
overrides, errors = _normalize_for_i18n(i18n, payload)
|
||||
if errors:
|
||||
logger.warning("Skipping invalid locale override entries from %s: %s", path, errors)
|
||||
return LocaleOverridesFileState(exists=True, readable=True, overrides=overrides)
|
||||
|
||||
|
||||
def read_locale_overrides_file(
|
||||
i18n: JsonI18n,
|
||||
*,
|
||||
path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> LocaleOverrides:
|
||||
return _read_locale_overrides_file_state(i18n, path=path).overrides
|
||||
|
||||
|
||||
def write_locale_overrides_file(
|
||||
overrides: LocaleOverrides,
|
||||
*,
|
||||
path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> bool:
|
||||
payload = {
|
||||
lang: dict(sorted(messages.items()))
|
||||
for lang, messages in sorted(overrides.items())
|
||||
if messages
|
||||
}
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write locale overrides to %s: %s", path, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def _replace_db_overrides(
|
||||
session: AsyncSession,
|
||||
desired_overrides: LocaleOverrides,
|
||||
*,
|
||||
updated_by: Optional[int] = None,
|
||||
) -> int:
|
||||
current = _flat_map(await locale_overrides_dal.get_all_overrides(session))
|
||||
desired = _flat_map(desired_overrides)
|
||||
changes: Dict[Tuple[str, str], Tuple[bool, str]] = {}
|
||||
|
||||
for identity, value in desired.items():
|
||||
if current.get(identity) != value:
|
||||
changes[identity] = (True, value)
|
||||
for identity in current:
|
||||
if identity not in desired:
|
||||
changes[identity] = (False, "")
|
||||
|
||||
if changes:
|
||||
await locale_overrides_dal.bulk_apply(session, updates=changes, updated_by=updated_by)
|
||||
return len(changes)
|
||||
|
||||
|
||||
async def load_locale_overrides(
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
overrides_path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> int:
|
||||
"""Load locale overrides and keep the DB mirror in sync.
|
||||
|
||||
A valid JSON file is the source of truth. The DB is used as a fallback only
|
||||
when the file is missing or cannot be read/parsed.
|
||||
"""
|
||||
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
if file_state.readable:
|
||||
async with session.begin():
|
||||
changed = await _replace_db_overrides(
|
||||
session,
|
||||
file_state.overrides,
|
||||
updated_by=None,
|
||||
)
|
||||
i18n.set_locale_overrides(file_state.overrides)
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
logger.info(
|
||||
"Applied %s locale overrides from %s and synced %s DB rows",
|
||||
_count_overrides(file_state.overrides),
|
||||
overrides_path,
|
||||
changed,
|
||||
)
|
||||
return _count_overrides(file_state.overrides)
|
||||
|
||||
db_overrides = await locale_overrides_dal.get_all_overrides(session)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not load locale overrides from DB: %s", exc)
|
||||
if file_state.readable:
|
||||
i18n.set_locale_overrides(file_state.overrides)
|
||||
return _count_overrides(file_state.overrides)
|
||||
i18n.set_locale_overrides({})
|
||||
return 0
|
||||
|
||||
normalized, errors = _normalize_for_i18n(i18n, db_overrides)
|
||||
if errors:
|
||||
logger.warning("Skipping invalid DB locale override entries: %s", errors)
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
changed = await _replace_db_overrides(session, normalized, updated_by=None)
|
||||
if changed:
|
||||
logger.info("Canonicalized %s DB locale override rows", changed)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not canonicalize DB locale overrides: %s", exc)
|
||||
i18n.set_locale_overrides(normalized)
|
||||
if not file_state.exists:
|
||||
logger.info(
|
||||
"Locale overrides file %s is missing; trying to bootstrap it from DB state",
|
||||
overrides_path,
|
||||
)
|
||||
file_written = write_locale_overrides_file(normalized, path=overrides_path)
|
||||
if file_written:
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
logger.info("Created locale overrides file %s from DB state", overrides_path)
|
||||
logger.info("Applied %s locale overrides from DB fallback", _count_overrides(normalized))
|
||||
return _count_overrides(normalized)
|
||||
|
||||
|
||||
async def update_locale_overrides(
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
updates: Dict[str, Dict[str, Any]],
|
||||
deletes: Optional[List[Dict[str, str]]] = None,
|
||||
actor_id: Optional[int] = None,
|
||||
overrides_path: Path = LOCALE_OVERRIDES_PATH,
|
||||
) -> Dict[str, Any]:
|
||||
deletes = list(deletes or [])
|
||||
normalized_updates, errors = _normalize_for_i18n(i18n, updates)
|
||||
|
||||
normalized_deletes: List[Tuple[str, str]] = []
|
||||
valid_languages = _valid_languages(i18n)
|
||||
valid_keys = {key for keys in _valid_keys_by_language(i18n).values() for key in keys}
|
||||
for item in deletes:
|
||||
if not isinstance(item, dict):
|
||||
errors.setdefault("_deletes", "invalid_delete")
|
||||
continue
|
||||
lang = normalize_locale_language_code(
|
||||
item.get("lang"),
|
||||
valid_languages=None,
|
||||
prefer_known_base=False,
|
||||
)
|
||||
key = resolve_locale_key(item.get("key"))
|
||||
error_key = f"{lang or '_language'}.{key or '_key'}"
|
||||
if lang not in valid_languages and not is_valid_locale_language_code(lang):
|
||||
errors.setdefault(error_key, "invalid_language")
|
||||
continue
|
||||
if key not in valid_keys:
|
||||
errors.setdefault(error_key, "unknown_key")
|
||||
continue
|
||||
normalized_deletes.append((lang, key))
|
||||
|
||||
if errors:
|
||||
return {"ok": False, "errors": errors}
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_overrides = await locale_overrides_dal.get_all_overrides(session)
|
||||
|
||||
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
|
||||
source_overrides = file_state.overrides if file_state.readable else db_overrides
|
||||
desired, source_errors = _normalize_for_i18n(i18n, source_overrides)
|
||||
if source_errors:
|
||||
logger.warning("Skipping invalid locale override entries before update: %s", source_errors)
|
||||
|
||||
for lang, messages in normalized_updates.items():
|
||||
desired.setdefault(lang, {}).update(messages)
|
||||
for lang, key in normalized_deletes:
|
||||
if lang in desired:
|
||||
desired[lang].pop(key, None)
|
||||
if not desired[lang]:
|
||||
desired.pop(lang, None)
|
||||
desired = {
|
||||
lang: dict(sorted(messages.items()))
|
||||
for lang, messages in sorted(desired.items())
|
||||
if messages
|
||||
}
|
||||
|
||||
file_written = write_locale_overrides_file(desired, path=overrides_path)
|
||||
if not file_written and file_state.exists and file_state.readable:
|
||||
return {"ok": False, "errors": {"_file": "write_failed"}}
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
await _replace_db_overrides(session, desired, updated_by=actor_id)
|
||||
|
||||
i18n.set_locale_overrides(desired)
|
||||
if file_written:
|
||||
i18n.configure_overrides_file(overrides_path)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"applied": sum(len(messages) for messages in normalized_updates.values()),
|
||||
"reverted": len(normalized_deletes),
|
||||
"file_written": file_written,
|
||||
}
|
||||
|
||||
|
||||
def group_id_for_locale_key(key: str) -> str:
|
||||
for group in LOCALE_GROUPS:
|
||||
if any(key.startswith(prefix) or key == prefix for prefix in group["prefixes"]):
|
||||
return str(group["id"])
|
||||
return str(DEFAULT_LOCALE_GROUP["id"])
|
||||
|
||||
|
||||
def audience_for_locale_key(key: str) -> str:
|
||||
if key.startswith(INTERNAL_LOCALE_KEY_PREFIXES):
|
||||
return "internal"
|
||||
group_id = group_id_for_locale_key(key)
|
||||
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
|
||||
if group["id"] == group_id:
|
||||
return str(group.get("audience") or "user")
|
||||
return "user"
|
||||
|
||||
|
||||
def locale_group_catalog() -> List[Dict[str, Any]]:
|
||||
catalog: List[Dict[str, Any]] = []
|
||||
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
|
||||
item = {key: value for key, value in group.items() if key != "prefixes"}
|
||||
item["title_key"] = f"translations_group_{item['id']}"
|
||||
item["description_key"] = f"translations_group_{item['id']}_hint"
|
||||
catalog.append(item)
|
||||
return catalog
|
||||
@@ -56,11 +56,16 @@ class NotificationService:
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> str:
|
||||
base_display = display_name_or_fallback(first_name, f"ID {user_id}")
|
||||
if username:
|
||||
base_display = f"{base_display} ({username_for_display(username)})"
|
||||
return base_display
|
||||
safe_display = hd.quote(base_display)
|
||||
clean_email = str(email or "").strip()
|
||||
if clean_email:
|
||||
safe_display = f"{safe_display} · <code>{hd.quote(clean_email)}</code>"
|
||||
return safe_display
|
||||
|
||||
@staticmethod
|
||||
def _build_profile_keyboard(
|
||||
@@ -254,8 +259,13 @@ class NotificationService:
|
||||
part for part in [user.first_name, getattr(user, "last_name", None)] if part
|
||||
)
|
||||
if user.username:
|
||||
return f"{name or user.username} (@{user.username})"
|
||||
return name or getattr(user, "email", None) or f"ID {user.user_id}"
|
||||
display = f"{name or user.username} (@{user.username})"
|
||||
else:
|
||||
display = name or f"ID {user.user_id}"
|
||||
email = str(getattr(user, "email", None) or "").strip()
|
||||
if email:
|
||||
return f"{display} · {email}" if display and display != email else email
|
||||
return display
|
||||
|
||||
@staticmethod
|
||||
def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]:
|
||||
@@ -540,6 +550,7 @@ class NotificationService:
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
referred_by_id: Optional[int] = None,
|
||||
):
|
||||
"""Send notification about new user registration"""
|
||||
@@ -553,6 +564,7 @@ class NotificationService:
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
email=email,
|
||||
)
|
||||
|
||||
referral_text = ""
|
||||
@@ -640,6 +652,7 @@ class NotificationService:
|
||||
user_id=telegram_id or user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
email=email,
|
||||
)
|
||||
|
||||
message = _(
|
||||
@@ -676,6 +689,7 @@ class NotificationService:
|
||||
user_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
email=email,
|
||||
)
|
||||
|
||||
message = _(
|
||||
@@ -715,6 +729,7 @@ class NotificationService:
|
||||
user_id=display_user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
email=email,
|
||||
)
|
||||
|
||||
message = _(
|
||||
@@ -761,6 +776,7 @@ class NotificationService:
|
||||
months: int,
|
||||
payment_provider: str,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
traffic_gb: Optional[float] = None,
|
||||
*,
|
||||
traffic_is_premium: bool = False,
|
||||
@@ -776,6 +792,7 @@ class NotificationService:
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
email=email,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -827,7 +844,12 @@ class NotificationService:
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_promo_activation(
|
||||
self, user_id: int, promo_code: str, bonus_days: int, username: Optional[str] = None
|
||||
self,
|
||||
user_id: int,
|
||||
promo_code: str,
|
||||
bonus_days: int,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
):
|
||||
"""Send notification about promo code activation"""
|
||||
if not self.settings.LOG_PROMO_ACTIVATIONS:
|
||||
@@ -839,6 +861,7 @@ class NotificationService:
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
email=email,
|
||||
)
|
||||
|
||||
message = _(
|
||||
@@ -854,7 +877,11 @@ class NotificationService:
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_trial_activation(
|
||||
self, user_id: int, end_date: datetime, username: Optional[str] = None
|
||||
self,
|
||||
user_id: int,
|
||||
end_date: datetime,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
):
|
||||
"""Send notification about trial activation"""
|
||||
if not self.settings.LOG_TRIAL_ACTIVATIONS:
|
||||
@@ -866,6 +893,7 @@ class NotificationService:
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
email=email,
|
||||
)
|
||||
|
||||
message = _(
|
||||
@@ -918,6 +946,7 @@ class NotificationService:
|
||||
suspicious_input: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
):
|
||||
"""Send notification about a suspicious promo code attempt."""
|
||||
if not self.settings.LOG_SUSPICIOUS_ACTIVITY:
|
||||
@@ -930,11 +959,12 @@ class NotificationService:
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
email=email,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_suspicious_promo",
|
||||
user_display=hd.quote(user_display),
|
||||
user_display=user_display,
|
||||
user_id=user_id,
|
||||
suspicious_input=hd.quote(suspicious_input),
|
||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
|
||||
|
||||
@@ -597,6 +597,54 @@ class PanelApiService:
|
||||
return f"{base_sub_url}/{client_type.lower()}"
|
||||
return base_sub_url
|
||||
|
||||
async def get_subscription_page_config_by_short_uuid(
|
||||
self,
|
||||
short_uuid: str,
|
||||
request_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not short_uuid:
|
||||
return None
|
||||
endpoint = f"/subscriptions/subpage-config/{short_uuid}"
|
||||
payload = {"requestHeaders": request_headers or {}}
|
||||
response_data = await self._request(
|
||||
"GET",
|
||||
endpoint,
|
||||
json=payload,
|
||||
log_full_response=False,
|
||||
)
|
||||
if response_data and not response_data.get("error"):
|
||||
return response_data.get("response", response_data)
|
||||
logging.error(
|
||||
f"Failed to get subscription page config for short UUID {short_uuid}. Response: {response_data}" # noqa: E501
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_subscription_page_config_list(self) -> Optional[Dict[str, Any]]:
|
||||
endpoint = "/subscription-page-configs"
|
||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||
if response_data and not response_data.get("error"):
|
||||
return response_data.get("response", response_data)
|
||||
logging.error(
|
||||
f"Failed to get subscription page config list from panel. Response: {response_data}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_subscription_page_config_by_uuid(
|
||||
self,
|
||||
config_uuid: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
config_uuid = str(config_uuid or "").strip()
|
||||
if not config_uuid:
|
||||
return None
|
||||
endpoint = f"/subscription-page-configs/{config_uuid}"
|
||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||
if response_data and not response_data.get("error"):
|
||||
return response_data.get("response", response_data)
|
||||
logging.error(
|
||||
f"Failed to get subscription page config {config_uuid} from panel. Response: {response_data}" # noqa: E501
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
|
||||
if self._devices_cache.ttl_seconds <= 0:
|
||||
return await self._get_user_devices_uncached(user_uuid)
|
||||
|
||||
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from db.dal import tariff_dal, user_dal
|
||||
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_subscription_expiring
|
||||
@@ -63,13 +63,44 @@ class PanelWebhookService:
|
||||
**kwargs,
|
||||
):
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
extra_text = str(kwargs.pop("extra_text", "") or "").strip()
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||
)
|
||||
text = _(message_key, **kwargs)
|
||||
if extra_text:
|
||||
text = f"{text}\n\n{extra_text}"
|
||||
await self.bot.send_message(user_id, text, reply_markup=reply_markup)
|
||||
except Exception:
|
||||
logging.exception("Failed to send notification to %s", user_id)
|
||||
|
||||
async def _hwid_renewal_note(self, internal_user_id: int, lang: str) -> str:
|
||||
try:
|
||||
from db.dal import subscription_dal
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, internal_user_id
|
||||
)
|
||||
if not sub:
|
||||
return ""
|
||||
summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
)
|
||||
count = int(summary.get("active_devices") or sub.extra_hwid_devices or 0)
|
||||
if count <= 0:
|
||||
return ""
|
||||
active_until = summary.get("active_until") or sub.end_date
|
||||
date_text = active_until.strftime("%Y-%m-%d") if active_until else ""
|
||||
except Exception:
|
||||
logging.exception("Failed to build HWID renewal note for user %s", internal_user_id)
|
||||
return ""
|
||||
return self.i18n.gettext(
|
||||
lang,
|
||||
"subscription_hwid_renewal_reminder",
|
||||
count=count,
|
||||
date=date_text,
|
||||
)
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
if not telegram_id:
|
||||
@@ -97,6 +128,7 @@ class PanelWebhookService:
|
||||
|
||||
if event_name in EVENT_MAP:
|
||||
days_left, msg_key = EVENT_MAP[event_name]
|
||||
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
|
||||
if days_left == 1:
|
||||
# Trigger auto-renew via SubscriptionService (wired in at factory)
|
||||
try:
|
||||
@@ -148,6 +180,7 @@ class PanelWebhookService:
|
||||
"autorenew_48h_charge_tomorrow_notice",
|
||||
reply_markup=cancel_kb,
|
||||
user_name=first_name,
|
||||
extra_text=hwid_renewal_note,
|
||||
)
|
||||
return
|
||||
await self._send_message(
|
||||
@@ -157,6 +190,7 @@ class PanelWebhookService:
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
extra_text=hwid_renewal_note,
|
||||
)
|
||||
if days_left == 3 and user_email:
|
||||
await self._send_subscription_expiring_email(
|
||||
@@ -206,8 +240,9 @@ class PanelWebhookService:
|
||||
days_left=days_left,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||
i18n=self.i18n,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
email_service = EmailAuthService(self.settings, self.i18n)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||
|
||||
@@ -112,6 +112,7 @@ class PromoCodeService:
|
||||
promo_code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
username=user.username if user else None,
|
||||
email=getattr(user, "email", None) if user else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send promo activation notification: {e}")
|
||||
|
||||
@@ -127,6 +127,33 @@ def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int:
|
||||
return applied
|
||||
|
||||
|
||||
def _normalize_exclusive_provider_toggles(
|
||||
updates: Dict[str, Any],
|
||||
deletes: list,
|
||||
) -> tuple[Dict[str, Any], list]:
|
||||
"""When a provider is enabled for admins only, turn off its public toggle."""
|
||||
|
||||
from bot.payment_providers import provider_admin_only_pairs
|
||||
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
for public_key, admin_key in provider_admin_only_pairs()
|
||||
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
|
||||
}
|
||||
if not exclusive_map:
|
||||
return updates, deletes
|
||||
|
||||
normalized = dict(updates)
|
||||
normalized_deletes = list(deletes)
|
||||
for key, value in updates.items():
|
||||
if value is not True or key not in exclusive_map:
|
||||
continue
|
||||
opposite = exclusive_map[key]
|
||||
normalized[opposite] = False
|
||||
normalized_deletes = [item for item in normalized_deletes if item != opposite]
|
||||
return normalized, normalized_deletes
|
||||
|
||||
|
||||
def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
|
||||
snapshot: Dict[str, Any] = {}
|
||||
logo_url = getattr(settings, "WEBAPP_LOGO_URL", None)
|
||||
@@ -236,6 +263,35 @@ async def load_overrides_from_db(settings: Settings, async_session_factory: sess
|
||||
return applied
|
||||
|
||||
|
||||
async def refresh_overrides_from_db(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
keys: Optional[set[str]] = None,
|
||||
) -> int:
|
||||
"""Refresh already-known runtime overrides without startup restore side effects."""
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
overrides = await app_settings_dal.get_all_overrides(session)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not refresh setting overrides from DB: %s", exc)
|
||||
return 0
|
||||
if keys is not None:
|
||||
try:
|
||||
env_only = Settings()
|
||||
for key in keys:
|
||||
if key in overrides:
|
||||
continue
|
||||
attr_name = _resolve_attribute_name(env_only, key)
|
||||
if attr_name and hasattr(env_only, attr_name):
|
||||
setattr(settings, attr_name, getattr(env_only, attr_name))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore env defaults while refreshing overrides: %s", exc)
|
||||
overrides = {key: value for key, value in overrides.items() if key in keys}
|
||||
return apply_overrides(settings, overrides)
|
||||
|
||||
|
||||
async def update_overrides(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
@@ -270,6 +326,11 @@ async def update_overrides(
|
||||
if errors:
|
||||
return {"ok": False, "errors": errors}
|
||||
|
||||
coerced_updates, valid_deletes = _normalize_exclusive_provider_toggles(
|
||||
coerced_updates,
|
||||
valid_deletes,
|
||||
)
|
||||
|
||||
async with async_session_factory() as session: # type: AsyncSession
|
||||
async with session.begin():
|
||||
for key, value in coerced_updates.items():
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, sessionmaker
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from config.settings import Settings
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription
|
||||
|
||||
SUBSCRIPTION_NOTIFICATION_LOCK = "subscription-notification-worker"
|
||||
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS = 300
|
||||
EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationStage:
|
||||
key: str
|
||||
message_key: str
|
||||
hours_before: Optional[int] = None
|
||||
|
||||
|
||||
class SubscriptionNotificationWorker:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
session_factory: sessionmaker,
|
||||
bot: Bot,
|
||||
i18n: JsonI18n,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.session_factory = session_factory
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self._stopped = asyncio.Event()
|
||||
|
||||
async def run(self) -> None:
|
||||
while not self._stopped.is_set():
|
||||
try:
|
||||
async with redis_lock(
|
||||
self.settings,
|
||||
SUBSCRIPTION_NOTIFICATION_LOCK,
|
||||
ttl_seconds=max(60, self._tick_seconds() - 10),
|
||||
) as acquired:
|
||||
if not acquired:
|
||||
logging.info(
|
||||
"SubscriptionNotificationWorker tick skipped: Redis lock is held"
|
||||
)
|
||||
else:
|
||||
started = time.monotonic()
|
||||
async with self.session_factory() as session:
|
||||
await self.expiry_tick(session)
|
||||
await self.trial_traffic_tick(session)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f "
|
||||
"worker=subscription_notification",
|
||||
time.monotonic() - started,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("SubscriptionNotificationWorker tick failed")
|
||||
try:
|
||||
await asyncio.wait_for(self._stopped.wait(), timeout=self._tick_seconds())
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
|
||||
def _tick_seconds(self) -> int:
|
||||
return int(
|
||||
getattr(
|
||||
self.settings,
|
||||
"SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS",
|
||||
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS,
|
||||
)
|
||||
or DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS
|
||||
)
|
||||
|
||||
async def expiry_tick(self, session: AsyncSession) -> None:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
lower = now - EXPIRED_AFTER_NOTIFICATION_WINDOW
|
||||
upper = now + self._max_before_window()
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.end_date >= lower,
|
||||
Subscription.end_date <= upper,
|
||||
)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
stage = self.stage_for_subscription(sub, now)
|
||||
if stage is None:
|
||||
continue
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
):
|
||||
continue
|
||||
if not await self._send_expiry_notification(sub, stage):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
def stage_for_subscription(
|
||||
self,
|
||||
sub: Subscription,
|
||||
now: datetime,
|
||||
) -> Optional[SubscriptionNotificationStage]:
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
if end_date is None:
|
||||
return None
|
||||
|
||||
seconds_left = (end_date - now).total_seconds()
|
||||
if seconds_left > 0:
|
||||
hours_before = int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0)
|
||||
if 0 < hours_before <= 23 and seconds_left <= hours_before * 3600:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{hours_before}h",
|
||||
message_key="subscription_hours_notification",
|
||||
hours_before=hours_before,
|
||||
)
|
||||
|
||||
days_before_limit = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
||||
)
|
||||
day_stages = (
|
||||
(1, "subscription_24h_notification"),
|
||||
(2, "subscription_48h_notification"),
|
||||
(3, "subscription_72h_notification"),
|
||||
)
|
||||
for days_before, message_key in day_stages:
|
||||
if days_before > days_before_limit:
|
||||
continue
|
||||
if seconds_left <= days_before * 24 * 3600:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{days_before}d",
|
||||
message_key=message_key,
|
||||
)
|
||||
return None
|
||||
|
||||
expired_for = now - end_date
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_ON_EXPIRE", True)
|
||||
and expired_for <= EXPIRED_NOTIFICATION_WINDOW
|
||||
):
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
)
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||
and EXPIRED_NOTIFICATION_WINDOW < expired_for <= EXPIRED_AFTER_NOTIFICATION_WINDOW
|
||||
):
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
)
|
||||
return None
|
||||
|
||||
async def trial_traffic_tick(self, session: AsyncSession) -> None:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.traffic_limit_bytes.is_not(None),
|
||||
Subscription.traffic_limit_bytes > 0,
|
||||
or_(
|
||||
Subscription.provider == "trial",
|
||||
Subscription.status_from_panel == "TRIAL",
|
||||
Subscription.duration_months == 0,
|
||||
),
|
||||
)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
):
|
||||
continue
|
||||
|
||||
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
|
||||
limit = int(getattr(sub, "traffic_limit_bytes", 0) or 0)
|
||||
panel_data = await self._panel_user(sub)
|
||||
if panel_data:
|
||||
panel_used, panel_limit, _ = (
|
||||
self.subscription_service._extract_panel_traffic_details(panel_data)
|
||||
)
|
||||
if panel_used is not None:
|
||||
used = int(panel_used)
|
||||
sub.traffic_used_bytes = used
|
||||
if panel_limit is not None:
|
||||
limit = int(panel_limit)
|
||||
sub.traffic_limit_bytes = limit
|
||||
panel_status = str(panel_data.get("status") or "").upper()
|
||||
if panel_status:
|
||||
sub.status_from_panel = panel_status
|
||||
|
||||
if limit <= 0 or used < limit:
|
||||
continue
|
||||
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
|
||||
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
|
||||
if not panel_uuid:
|
||||
return None
|
||||
try:
|
||||
data = await self.panel_service.get_user_by_uuid(panel_uuid, log_response=False)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"SubscriptionNotificationWorker: failed to fetch panel user %s",
|
||||
panel_uuid,
|
||||
)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def _send_expiry_notification(
|
||||
self,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
) -> bool:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
user_name = getattr(user, "first_name", None) or f"User {user_id}"
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
kwargs = {"user_name": user_name, "end_date": end_date_text}
|
||||
if stage.hours_before is not None:
|
||||
kwargs["hours"] = stage.hours_before
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(stage.message_key, **kwargs),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to user %s",
|
||||
stage.key,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_traffic_depleted(
|
||||
self,
|
||||
sub: Subscription,
|
||||
*,
|
||||
used: int,
|
||||
limit: int,
|
||||
) -> bool:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
remaining = max(0, limit - used)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(
|
||||
"trial_traffic_depleted_notification",
|
||||
used=hd.quote(self._fmt_bytes(used)),
|
||||
remaining=hd.quote(self._fmt_bytes(remaining)),
|
||||
limit_total=hd.quote(self._fmt_bytes(limit)),
|
||||
),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
|
||||
return False
|
||||
|
||||
def _max_before_window(self) -> timedelta:
|
||||
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
|
||||
hours_before = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0),
|
||||
)
|
||||
return max(timedelta(days=min(days_before, 3)), timedelta(hours=hours_before))
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _fmt_bytes(value: int) -> str:
|
||||
size = float(max(0, int(value or 0)))
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
@@ -25,5 +25,6 @@ from db.models import Subscription, User
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import render_payment_success
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.utils.text_sanitizer import panel_description_from_profile
|
||||
|
||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||
|
||||
@@ -3,6 +3,190 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
class HwidDeviceMixin:
|
||||
@staticmethod
|
||||
def _as_aware_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
async def _active_hwid_extra_devices_for_sub(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
at: Optional[datetime] = None,
|
||||
) -> int:
|
||||
try:
|
||||
return await tariff_dal.sum_active_hwid_devices(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=at or datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to recalculate active HWID devices for subscription %s",
|
||||
getattr(sub, "subscription_id", None),
|
||||
)
|
||||
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
|
||||
|
||||
async def _hwid_topup_validity_window(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
renewal: bool,
|
||||
now: datetime,
|
||||
) -> Optional[Tuple[datetime, datetime, Dict[str, Any]]]:
|
||||
valid_until = self._as_aware_utc(getattr(sub, "end_date", None))
|
||||
if not valid_until or valid_until <= now:
|
||||
return None
|
||||
|
||||
summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=now,
|
||||
)
|
||||
valid_from = now
|
||||
if renewal:
|
||||
active_until = self._as_aware_utc(summary.get("active_until"))
|
||||
if active_until and now < active_until < valid_until:
|
||||
valid_from = active_until
|
||||
elif active_until and active_until >= valid_until:
|
||||
return None
|
||||
return valid_from, valid_until, summary
|
||||
|
||||
@staticmethod
|
||||
def _round_hwid_price(value: float, *, currency: str) -> float:
|
||||
if value <= 0:
|
||||
return 0.0
|
||||
if currency == "stars":
|
||||
return float(math.ceil(value))
|
||||
return math.ceil(float(value) * 100) / 100
|
||||
|
||||
@staticmethod
|
||||
def _find_hwid_package(tariff: Tariff, device_count: int, currency: str) -> Optional[Any]:
|
||||
package_set = tariff.hwid_device_packages
|
||||
if not package_set:
|
||||
return None
|
||||
packages = package_set.for_currency("stars" if currency == "stars" else "rub")
|
||||
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
||||
|
||||
def _quote_hwid_package_price(
|
||||
self,
|
||||
*,
|
||||
sub: Subscription,
|
||||
package: Any,
|
||||
valid_from: datetime,
|
||||
valid_until: datetime,
|
||||
now: datetime,
|
||||
currency: str,
|
||||
) -> Dict[str, Any]:
|
||||
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
|
||||
full_price = float(package.price_for_period(period_months))
|
||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
||||
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
||||
inferred_period_start = add_months(period_end, -period_months)
|
||||
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
||||
period_start = inferred_period_start
|
||||
|
||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
||||
billable_start = max(now, valid_from)
|
||||
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
|
||||
ratio = billable_seconds / basis_seconds
|
||||
raw_price = full_price * ratio
|
||||
price = self._round_hwid_price(raw_price, currency=currency)
|
||||
min_price = getattr(package, "min_price", None)
|
||||
if raw_price > 0 and min_price is not None:
|
||||
price = max(price, self._round_hwid_price(float(min_price), currency=currency))
|
||||
if currency == "stars":
|
||||
price = float(int(math.ceil(price)))
|
||||
|
||||
return {
|
||||
"price": price,
|
||||
"full_price": full_price,
|
||||
"pricing_period_months": period_months,
|
||||
"proration_ratio": ratio,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
"billable_seconds": billable_seconds,
|
||||
"period_seconds": basis_seconds,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
async def quote_hwid_device_topup(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
device_count: int,
|
||||
tariff_key: Optional[str] = None,
|
||||
renewal: bool = False,
|
||||
currency: str = "rub",
|
||||
now: Optional[datetime] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
purchased_devices = int(device_count)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if purchased_devices <= 0:
|
||||
return None
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
return None
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, db_user.panel_user_uuid
|
||||
)
|
||||
if not sub:
|
||||
return None
|
||||
|
||||
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
|
||||
if not tariff or tariff.billing_model != "period":
|
||||
return None
|
||||
base_hwid_limit = (
|
||||
int(sub.hwid_device_limit)
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
if base_hwid_limit == 0:
|
||||
return None
|
||||
|
||||
package = self._find_hwid_package(tariff, purchased_devices, currency)
|
||||
if not package:
|
||||
return None
|
||||
|
||||
now = now or datetime.now(timezone.utc)
|
||||
window = await self._hwid_topup_validity_window(
|
||||
session,
|
||||
sub,
|
||||
renewal=renewal,
|
||||
now=now,
|
||||
)
|
||||
if not window:
|
||||
return None
|
||||
valid_from, valid_until, entitlement_summary = window
|
||||
quote = self._quote_hwid_package_price(
|
||||
sub=sub,
|
||||
package=package,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
now=now,
|
||||
currency="stars" if currency == "stars" else "rub",
|
||||
)
|
||||
quote.update(
|
||||
{
|
||||
"subscription_id": sub.subscription_id,
|
||||
"tariff_key": tariff.key,
|
||||
"device_count": purchased_devices,
|
||||
"renewal": renewal,
|
||||
"active_extra_devices": int(entitlement_summary.get("active_devices") or 0),
|
||||
"active_until": entitlement_summary.get("active_until"),
|
||||
}
|
||||
)
|
||||
return quote
|
||||
|
||||
async def activate_hwid_device_topup(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -12,6 +196,7 @@ class HwidDeviceMixin:
|
||||
payment_db_id: int,
|
||||
provider: str = "yookassa",
|
||||
tariff_key: Optional[str] = None,
|
||||
renewal: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
purchased_devices = int(device_count)
|
||||
@@ -33,6 +218,14 @@ class HwidDeviceMixin:
|
||||
tariff = None
|
||||
if self._tariffs_config():
|
||||
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
|
||||
if tariff.billing_model != "period":
|
||||
logging.info(
|
||||
"Skipping HWID top-up for user %s because tariff %s is %s",
|
||||
user_id,
|
||||
tariff.key,
|
||||
tariff.billing_model,
|
||||
)
|
||||
return None
|
||||
packages = (
|
||||
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
|
||||
if tariff.hwid_device_packages
|
||||
@@ -57,19 +250,69 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return {
|
||||
"subscription_id": sub.subscription_id,
|
||||
"end_date": sub.end_date,
|
||||
"is_active": True,
|
||||
"panel_user_uuid": db_user.panel_user_uuid,
|
||||
"panel_short_uuid": getattr(sub, "panel_subscription_uuid", None),
|
||||
"hwid_device_limit": 0,
|
||||
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
|
||||
"purchased_hwid_devices": 0,
|
||||
}
|
||||
|
||||
new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices
|
||||
now = datetime.now(timezone.utc)
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=now,
|
||||
)
|
||||
valid_from = self._as_aware_utc(getattr(payment, "hwid_valid_from", None))
|
||||
valid_until = self._as_aware_utc(getattr(payment, "hwid_valid_until", None))
|
||||
if valid_from and valid_until:
|
||||
if valid_until <= now or valid_from >= valid_until:
|
||||
logging.error(
|
||||
"Frozen HWID quote is no longer valid for user %s "
|
||||
"(payment_id=%s, valid_from=%s, valid_until=%s)",
|
||||
user_id,
|
||||
payment_db_id,
|
||||
valid_from,
|
||||
valid_until,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
window = await self._hwid_topup_validity_window(
|
||||
session,
|
||||
sub,
|
||||
renewal=renewal,
|
||||
now=now,
|
||||
)
|
||||
if window:
|
||||
valid_from, valid_until, entitlement_summary = window
|
||||
if not valid_from or not valid_until:
|
||||
logging.error(
|
||||
"HWID top-up has no valid subscription window for user %s "
|
||||
"(subscription_id=%s, renewal=%s)",
|
||||
user_id,
|
||||
sub.subscription_id,
|
||||
renewal,
|
||||
)
|
||||
return None
|
||||
|
||||
active_extra_devices = int(entitlement_summary.get("active_devices") or 0)
|
||||
starts_now = valid_from <= now < valid_until
|
||||
new_extra_devices = active_extra_devices + (purchased_devices if starts_now else 0)
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices)
|
||||
await self._record_payment_context(
|
||||
session,
|
||||
payment_db_id,
|
||||
sale_mode="hwid_devices",
|
||||
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
|
||||
tariff_key=tariff.key if tariff else sub.tariff_key,
|
||||
purchased_hwid_devices=purchased_devices,
|
||||
hwid_valid_from=valid_from,
|
||||
hwid_valid_until=valid_until,
|
||||
hwid_pricing_period_months=getattr(payment, "hwid_pricing_period_months", None),
|
||||
hwid_proration_ratio=getattr(payment, "hwid_proration_ratio", None),
|
||||
hwid_full_price=getattr(payment, "hwid_full_price", None),
|
||||
)
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
@@ -102,16 +345,30 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return None
|
||||
|
||||
final_subscription_url = updated_panel.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel.get(
|
||||
"shortUuid", getattr(updated_sub, "panel_subscription_uuid", None)
|
||||
)
|
||||
await tariff_dal.create_hwid_device_purchase(
|
||||
session,
|
||||
subscription_id=updated_sub.subscription_id,
|
||||
payment_id=payment_db_id,
|
||||
purchased_devices=purchased_devices,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
)
|
||||
return {
|
||||
"subscription_id": updated_sub.subscription_id,
|
||||
"end_date": updated_sub.end_date,
|
||||
"is_active": True,
|
||||
"panel_user_uuid": db_user.panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url,
|
||||
"hwid_device_limit": effective_hwid_limit,
|
||||
"extra_hwid_devices": new_extra_devices,
|
||||
"purchased_hwid_devices": purchased_devices,
|
||||
"tariff_key": tariff.key if tariff else sub.tariff_key,
|
||||
"hwid_devices_valid_from": valid_from,
|
||||
"hwid_devices_valid_until": valid_until,
|
||||
"hwid_devices_renewal": renewal,
|
||||
}
|
||||
|
||||
@@ -23,8 +23,15 @@ class SubscriptionLifecycleMixin:
|
||||
if not sub:
|
||||
return None
|
||||
before_tariff_key = sub.tariff_key
|
||||
options = self.calculate_tariff_switch_options(sub, target)
|
||||
now = datetime.now(timezone.utc)
|
||||
options = await self.calculate_tariff_switch_options_with_hwid(session, sub, target)
|
||||
converted_hwid_purchase_ids = list(options.get("convertible_hwid_purchase_ids") or [])
|
||||
if converted_hwid_purchase_ids:
|
||||
await tariff_dal.expire_hwid_device_purchases(
|
||||
session,
|
||||
purchase_ids=converted_hwid_purchase_ids,
|
||||
at=now,
|
||||
)
|
||||
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
|
||||
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||
premium_baseline = target.premium_monthly_bytes
|
||||
@@ -44,8 +51,20 @@ class SubscriptionLifecycleMixin:
|
||||
}
|
||||
converted_bytes = None
|
||||
base_hwid_limit = self._base_hwid_limit_for_tariff(target)
|
||||
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
|
||||
try:
|
||||
extra_hwid_devices = await tariff_dal.sum_active_hwid_devices(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=now,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to recalculate HWID devices during tariff switch for user %s",
|
||||
user_id,
|
||||
)
|
||||
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
|
||||
update_data["hwid_device_limit"] = base_hwid_limit
|
||||
update_data["extra_hwid_devices"] = extra_hwid_devices
|
||||
|
||||
if target.billing_model == "period":
|
||||
update_data["tier_baseline_bytes"] = target.monthly_bytes
|
||||
@@ -154,6 +173,8 @@ class SubscriptionLifecycleMixin:
|
||||
if updated.end_date and target.billing_model == "period"
|
||||
else None,
|
||||
"converted_bytes": converted_bytes,
|
||||
"converted_hwid_value_rub": options.get("converted_hwid_value_rub"),
|
||||
"converted_hwid_days": options.get("converted_hwid_days"),
|
||||
"eff_price_before": sub.effective_monthly_price_rub,
|
||||
"eff_price_after": updated.effective_monthly_price_rub,
|
||||
},
|
||||
@@ -236,7 +257,7 @@ class SubscriptionLifecycleMixin:
|
||||
payment_db_id=payment_db_id,
|
||||
provider=provider,
|
||||
)
|
||||
if sale_mode_base in {"hwid_device", "hwid_devices"}:
|
||||
if sale_mode_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
|
||||
target_devices = int(traffic_gb if traffic_gb is not None else months)
|
||||
return await self.activate_hwid_device_topup(
|
||||
session=session,
|
||||
@@ -246,6 +267,7 @@ class SubscriptionLifecycleMixin:
|
||||
payment_db_id=payment_db_id,
|
||||
provider=provider,
|
||||
tariff_key=tariff_key,
|
||||
renewal=sale_mode_base == "hwid_devices_renewal",
|
||||
)
|
||||
if sale_mode_base == "tariff_upgrade":
|
||||
if not tariff_key:
|
||||
@@ -378,7 +400,23 @@ class SubscriptionLifecycleMixin:
|
||||
)
|
||||
|
||||
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
|
||||
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
|
||||
extra_hwid_devices = 0
|
||||
hwid_devices_valid_until = None
|
||||
if current_active_sub:
|
||||
try:
|
||||
hwid_summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=current_active_sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
)
|
||||
extra_hwid_devices = int(hwid_summary.get("active_devices") or 0)
|
||||
hwid_devices_valid_until = hwid_summary.get("active_until")
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to recalculate active HWID devices for renewal of user %s",
|
||||
user_id,
|
||||
)
|
||||
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
|
||||
premium_topup_balance_bytes = int(
|
||||
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
|
||||
)
|
||||
@@ -497,6 +535,8 @@ class SubscriptionLifecycleMixin:
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days,
|
||||
"tariff_key": tariff.key if tariff else None,
|
||||
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
|
||||
"hwid_devices_valid_until": hwid_devices_valid_until,
|
||||
}
|
||||
|
||||
async def extend_active_subscription_days(
|
||||
@@ -760,9 +800,48 @@ class SubscriptionLifecycleMixin:
|
||||
if local_active_sub
|
||||
else False
|
||||
)
|
||||
hwid_entitlement_summary: Dict[str, Any] = {}
|
||||
active_extra_hwid_devices = (
|
||||
int(local_active_sub.extra_hwid_devices or 0) if local_active_sub else 0
|
||||
)
|
||||
if local_active_sub:
|
||||
try:
|
||||
hwid_entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=local_active_sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
)
|
||||
active_extra_hwid_devices = int(hwid_entitlement_summary.get("active_devices") or 0)
|
||||
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
local_active_sub.subscription_id,
|
||||
{"extra_hwid_devices": active_extra_hwid_devices},
|
||||
)
|
||||
local_active_sub.extra_hwid_devices = active_extra_hwid_devices
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to load HWID entitlement summary for subscription %s",
|
||||
local_active_sub.subscription_id,
|
||||
)
|
||||
base_hwid_limit_for_payload = (
|
||||
local_active_sub.hwid_device_limit
|
||||
if local_active_sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
expected_hwid_limit = self._effective_hwid_limit(
|
||||
base_hwid_limit_for_payload,
|
||||
active_extra_hwid_devices,
|
||||
)
|
||||
if expected_hwid_limit is not None:
|
||||
hwid_limit = expected_hwid_limit
|
||||
|
||||
return {
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
|
||||
or panel_user_data.get("shortUuid")
|
||||
or (local_active_sub.panel_subscription_uuid if local_active_sub else None),
|
||||
"panel_short_uuid": panel_user_data.get("shortUuid"),
|
||||
"end_date": panel_end_date,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": display_link,
|
||||
@@ -816,9 +895,9 @@ class SubscriptionLifecycleMixin:
|
||||
"base_hwid_device_limit": local_active_sub.hwid_device_limit
|
||||
if local_active_sub
|
||||
else None,
|
||||
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0)
|
||||
if local_active_sub
|
||||
else 0,
|
||||
"extra_hwid_devices": active_extra_hwid_devices,
|
||||
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
|
||||
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"),
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
|
||||
@@ -57,18 +57,14 @@ class PanelIdentityMixin:
|
||||
return f"em_{referral_code}"
|
||||
|
||||
def _panel_description_for_user(self, db_user: User) -> str:
|
||||
lines = [
|
||||
db_user.email or "",
|
||||
db_user.username or "",
|
||||
db_user.first_name or "",
|
||||
db_user.last_name or "",
|
||||
]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
return panel_description_from_profile(
|
||||
db_user.username,
|
||||
db_user.first_name,
|
||||
db_user.last_name,
|
||||
)
|
||||
|
||||
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"description": self._panel_description_for_user(db_user),
|
||||
}
|
||||
payload: Dict[str, Any] = {}
|
||||
telegram_id = self._telegram_id_for_panel(db_user)
|
||||
if telegram_id:
|
||||
payload["telegramId"] = telegram_id
|
||||
|
||||
@@ -26,6 +26,11 @@ class PaymentContextMixin:
|
||||
tariff_key: Optional[str],
|
||||
purchased_gb: Optional[float] = None,
|
||||
purchased_hwid_devices: Optional[int] = None,
|
||||
hwid_valid_from: Optional[datetime] = None,
|
||||
hwid_valid_until: Optional[datetime] = None,
|
||||
hwid_pricing_period_months: Optional[int] = None,
|
||||
hwid_proration_ratio: Optional[float] = None,
|
||||
hwid_full_price: Optional[float] = None,
|
||||
) -> None:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
@@ -34,6 +39,16 @@ class PaymentContextMixin:
|
||||
payment.tariff_key = tariff_key
|
||||
payment.purchased_gb = purchased_gb
|
||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||
if hwid_valid_from is not None:
|
||||
payment.hwid_valid_from = hwid_valid_from
|
||||
if hwid_valid_until is not None:
|
||||
payment.hwid_valid_until = hwid_valid_until
|
||||
if hwid_pricing_period_months is not None:
|
||||
payment.hwid_pricing_period_months = hwid_pricing_period_months
|
||||
if hwid_proration_ratio is not None:
|
||||
payment.hwid_proration_ratio = hwid_proration_ratio
|
||||
if hwid_full_price is not None:
|
||||
payment.hwid_full_price = hwid_full_price
|
||||
await session.flush()
|
||||
|
||||
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
|
||||
@@ -95,6 +110,7 @@ class PaymentContextMixin:
|
||||
except Exception:
|
||||
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
|
||||
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
|
||||
i18n = getattr(self, "i18n", None)
|
||||
|
||||
try:
|
||||
content = render_payment_success(
|
||||
@@ -108,8 +124,9 @@ class PaymentContextMixin:
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=dashboard_url,
|
||||
provider_label=provider_label,
|
||||
i18n=i18n,
|
||||
)
|
||||
email_service = EmailAuthService(self.settings)
|
||||
email_service = EmailAuthService(self.settings, i18n)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception("Failed to send payment success email to user %s", db_user.user_id)
|
||||
|
||||
@@ -370,3 +370,89 @@ class TariffMixin:
|
||||
}
|
||||
|
||||
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
|
||||
|
||||
@staticmethod
|
||||
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
async def _hwid_conversion_credit(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
at: datetime,
|
||||
) -> Dict[str, Any]:
|
||||
entries = await tariff_dal.get_hwid_device_value_entries(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=at,
|
||||
)
|
||||
value_rub = 0.0
|
||||
purchase_ids: List[int] = []
|
||||
skipped_devices = 0
|
||||
for entry in entries:
|
||||
currency = str(entry.get("currency") or "").upper()
|
||||
if currency in {"XTR", "STARS", "STAR"}:
|
||||
skipped_devices += int(entry.get("purchased_devices") or 0)
|
||||
continue
|
||||
amount = float(entry.get("amount") or 0)
|
||||
if amount <= 0:
|
||||
continue
|
||||
valid_from = (
|
||||
self._aware_utc(entry.get("valid_from"))
|
||||
or self._aware_utc(entry.get("created_at"))
|
||||
or at
|
||||
)
|
||||
valid_until = self._aware_utc(entry.get("valid_until"))
|
||||
if not valid_until or valid_until <= at or valid_from >= valid_until:
|
||||
continue
|
||||
total_seconds = max(1.0, (valid_until - valid_from).total_seconds())
|
||||
remaining_start = max(at, valid_from)
|
||||
remaining_seconds = max(0.0, (valid_until - remaining_start).total_seconds())
|
||||
if remaining_seconds <= 0:
|
||||
continue
|
||||
value_rub += amount * (remaining_seconds / total_seconds)
|
||||
purchase_ids.append(int(entry["purchase_id"]))
|
||||
return {
|
||||
"value_rub": value_rub,
|
||||
"purchase_ids": purchase_ids,
|
||||
"skipped_devices": skipped_devices,
|
||||
}
|
||||
|
||||
async def calculate_tariff_switch_options_with_hwid(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
target_tariff: Tariff,
|
||||
) -> Dict[str, Any]:
|
||||
options = dict(self.calculate_tariff_switch_options(sub, target_tariff))
|
||||
now = datetime.now(timezone.utc)
|
||||
credit = await self._hwid_conversion_credit(session, sub, at=now)
|
||||
value_rub = float(credit.get("value_rub") or 0)
|
||||
options["converted_hwid_value_rub"] = round(value_rub, 2)
|
||||
options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
|
||||
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
|
||||
if value_rub <= 0:
|
||||
return options
|
||||
|
||||
if options.get("mode") == "period_to_period":
|
||||
target_monthly = float(options.get("target_monthly_rub") or 0)
|
||||
hwid_days = math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
|
||||
options["converted_hwid_days"] = max(0, hwid_days)
|
||||
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
|
||||
options["paid_diff_rub"] = max(
|
||||
0,
|
||||
math.ceil(float(options.get("paid_diff_rub") or 0) - value_rub),
|
||||
)
|
||||
return options
|
||||
|
||||
if options.get("mode") == "period_to_traffic":
|
||||
rub_per_gb = float(options.get("rub_per_gb") or 0)
|
||||
hwid_gb = math.floor(value_rub / rub_per_gb) if rub_per_gb > 0 else 0
|
||||
options["converted_hwid_gb"] = max(0, hwid_gb)
|
||||
options["converted_gb"] = int(options.get("converted_gb") or 0) + max(0, hwid_gb)
|
||||
return options
|
||||
|
||||
@@ -53,7 +53,9 @@ class TrafficMixin:
|
||||
current_used = active_sub.traffic_used_bytes
|
||||
|
||||
purchase_bytes = self.gb_to_bytes(traffic_gb)
|
||||
extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0)
|
||||
extra_hwid_devices = (
|
||||
await self._active_hwid_extra_devices_for_sub(session, active_sub) if active_sub else 0
|
||||
)
|
||||
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0))
|
||||
@@ -222,10 +224,8 @@ class TrafficMixin:
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
effective_hwid_limit = self._effective_hwid_limit(
|
||||
base_hwid_limit,
|
||||
int(sub.extra_hwid_devices or 0),
|
||||
)
|
||||
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
@@ -235,6 +235,7 @@ class TrafficMixin:
|
||||
"is_throttled": False,
|
||||
"tariff_key": tariff.key,
|
||||
"hwid_device_limit": base_hwid_limit,
|
||||
"extra_hwid_devices": extra_hwid_devices,
|
||||
},
|
||||
)
|
||||
panel_payload = self._build_panel_update_payload(
|
||||
@@ -349,24 +350,23 @@ class TrafficMixin:
|
||||
},
|
||||
)
|
||||
|
||||
panel_payload = {
|
||||
"uuid": db_user.panel_user_uuid,
|
||||
"activeInternalSquads": self._panel_squads_for_tariff(
|
||||
tariff,
|
||||
include_premium=not premium_is_limited,
|
||||
),
|
||||
}
|
||||
updated_panel = await self.panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid, panel_payload
|
||||
desired_squads = self._panel_squads_for_tariff(
|
||||
tariff,
|
||||
include_premium=not premium_is_limited,
|
||||
)
|
||||
if not updated_panel or updated_panel.get("error"):
|
||||
panel_updated = await self._sync_panel_squads_if_needed(
|
||||
db_user.panel_user_uuid,
|
||||
desired_squads,
|
||||
user_id=user_id,
|
||||
source="premium_topup",
|
||||
)
|
||||
if not panel_updated:
|
||||
# Otherwise the user pays for premium top-up but the panel never
|
||||
# re-grants premium squad access (the most common case here is
|
||||
# transitioning from premium_is_limited=True back to False).
|
||||
logging.warning(
|
||||
"Panel user details update FAILED for premium top-up user %s. Response: %s",
|
||||
"Panel user details update FAILED for premium top-up user %s.",
|
||||
user_id,
|
||||
updated_panel,
|
||||
)
|
||||
return None
|
||||
await tariff_dal.create_traffic_topup(
|
||||
@@ -432,11 +432,17 @@ class TrafficMixin:
|
||||
|
||||
squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited)
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_updated = await self._sync_panel_squads_if_needed(
|
||||
db_user.panel_user_uuid,
|
||||
{"uuid": db_user.panel_user_uuid, "activeInternalSquads": squads},
|
||||
log_response=False,
|
||||
squads,
|
||||
user_id=user_id,
|
||||
source="admin_premium_override",
|
||||
)
|
||||
if not panel_updated:
|
||||
logging.warning(
|
||||
"sync_premium_squad_access_to_panel: panel update failed for user %s",
|
||||
user_id,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"sync_premium_squad_access_to_panel: failed to push squads for user %s", user_id
|
||||
@@ -494,10 +500,8 @@ class TrafficMixin:
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
effective_hwid_limit = self._effective_hwid_limit(
|
||||
base_hwid_limit,
|
||||
int(sub.extra_hwid_devices or 0),
|
||||
)
|
||||
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
@@ -506,6 +510,7 @@ class TrafficMixin:
|
||||
"traffic_limit_bytes": new_limit,
|
||||
"is_throttled": False,
|
||||
"hwid_device_limit": base_hwid_limit,
|
||||
"extra_hwid_devices": extra_hwid_devices,
|
||||
},
|
||||
)
|
||||
panel_payload = self._build_panel_update_payload(
|
||||
@@ -575,10 +580,9 @@ class TrafficMixin:
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
effective_hwid_limit = self._effective_hwid_limit(
|
||||
base_hwid_limit,
|
||||
int(sub.extra_hwid_devices or 0),
|
||||
)
|
||||
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
|
||||
sub.extra_hwid_devices = extra_hwid_devices
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
panel_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=db_user.panel_user_uuid,
|
||||
expire_at=sub.end_date,
|
||||
@@ -679,17 +683,22 @@ class TrafficMixin:
|
||||
"premium_period_start_at": premium_period_start,
|
||||
},
|
||||
)
|
||||
panel_payload = {
|
||||
"uuid": db_user.panel_user_uuid,
|
||||
"activeInternalSquads": self._panel_squads_for_tariff(
|
||||
tariff,
|
||||
include_premium=not premium_is_limited,
|
||||
),
|
||||
}
|
||||
desired_squads = self._panel_squads_for_tariff(
|
||||
tariff,
|
||||
include_premium=not premium_is_limited,
|
||||
)
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid, panel_payload
|
||||
panel_updated = await self._sync_panel_squads_if_needed(
|
||||
db_user.panel_user_uuid,
|
||||
desired_squads,
|
||||
user_id=user_id,
|
||||
source="admin_premium_topup",
|
||||
)
|
||||
if not panel_updated:
|
||||
logging.warning(
|
||||
"admin_grant_premium_topup: panel update failed for user %s",
|
||||
user_id,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"admin_grant_premium_topup: failed to push panel update for user %s",
|
||||
@@ -710,3 +719,128 @@ class TrafficMixin:
|
||||
"premium_is_limited": premium_is_limited,
|
||||
"granted_bytes": purchase_bytes,
|
||||
}
|
||||
|
||||
async def _sync_panel_squads_if_needed(
|
||||
self,
|
||||
panel_user_uuid: str,
|
||||
desired_squads: List[str],
|
||||
*,
|
||||
user_id: int,
|
||||
source: str,
|
||||
) -> bool:
|
||||
match, current_set = await self._panel_squads_match(panel_user_uuid, desired_squads)
|
||||
if match is True:
|
||||
return True
|
||||
|
||||
desired_set = self._panel_squad_uuid_set(desired_squads)
|
||||
self._log_panel_squad_patch(
|
||||
source=source,
|
||||
user_id=user_id,
|
||||
panel_uuid=panel_user_uuid,
|
||||
current_set=current_set,
|
||||
desired_set=desired_set,
|
||||
)
|
||||
updated_panel = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid,
|
||||
{"uuid": panel_user_uuid, "activeInternalSquads": desired_squads},
|
||||
log_response=False,
|
||||
)
|
||||
if not updated_panel:
|
||||
return False
|
||||
return not (isinstance(updated_panel, dict) and updated_panel.get("error"))
|
||||
|
||||
async def _panel_squads_match(
|
||||
self,
|
||||
panel_user_uuid: str,
|
||||
desired_squads: List[str],
|
||||
) -> tuple[Optional[bool], Optional[set[str]]]:
|
||||
try:
|
||||
panel_user = await self.panel_service.get_user_by_uuid(
|
||||
panel_user_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to fetch panel user %s before premium squad update",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None, None
|
||||
current_known, current_set = self._panel_active_squad_uuid_set(panel_user)
|
||||
if not current_known:
|
||||
return None, current_set
|
||||
return current_set == self._panel_squad_uuid_set(desired_squads), current_set
|
||||
|
||||
@classmethod
|
||||
def _panel_active_squad_uuid_set(
|
||||
cls,
|
||||
panel_user: Optional[dict],
|
||||
) -> tuple[bool, set[str]]:
|
||||
if not isinstance(panel_user, dict):
|
||||
return False, set()
|
||||
for key in (
|
||||
"activeInternalSquads",
|
||||
"active_internal_squads",
|
||||
"activeInternalSquadUuids",
|
||||
"active_internal_squad_uuids",
|
||||
):
|
||||
if key in panel_user:
|
||||
return True, cls._panel_squad_uuid_set(panel_user.get(key))
|
||||
return False, set()
|
||||
|
||||
@staticmethod
|
||||
def _panel_squad_uuid_set(raw) -> set[str]:
|
||||
if not isinstance(raw, (list, tuple, set)):
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
nested_squad = item.get("internalSquad") or item.get("squad")
|
||||
if not isinstance(nested_squad, dict):
|
||||
nested_squad = {}
|
||||
squad_uuid = (
|
||||
item.get("uuid")
|
||||
or item.get("internalSquadUuid")
|
||||
or item.get("squadUuid")
|
||||
or nested_squad.get("uuid")
|
||||
)
|
||||
if squad_uuid:
|
||||
out.add(str(squad_uuid))
|
||||
elif item:
|
||||
out.add(str(item))
|
||||
return out
|
||||
|
||||
def _log_panel_squad_patch(
|
||||
self,
|
||||
*,
|
||||
source: str,
|
||||
user_id: int,
|
||||
panel_uuid: str,
|
||||
current_set: Optional[set[str]],
|
||||
desired_set: set[str],
|
||||
) -> None:
|
||||
logging.info(
|
||||
"Sync panel PATCH: source=%s user_id=%s telegram_id=%s panel_uuid=%s "
|
||||
"panel_view=full_fetch reasons=activeInternalSquads_mismatch "
|
||||
"fields=activeInternalSquads payload_fields=activeInternalSquads changes=%s",
|
||||
source,
|
||||
user_id,
|
||||
user_id,
|
||||
panel_uuid,
|
||||
"activeInternalSquads:%s->%s"
|
||||
% (
|
||||
self._format_panel_squad_set(current_set),
|
||||
self._format_panel_squad_set(desired_set),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_panel_squad_set(value: Optional[set[str]]) -> str:
|
||||
if value is None:
|
||||
return "missing"
|
||||
values = sorted(str(item) for item in value)
|
||||
preview = ",".join(values[:4])
|
||||
suffix = ",..." if len(values) > 4 else ""
|
||||
text = f"[{len(values)}:{preview}{suffix}]"
|
||||
if len(text) > 96:
|
||||
return f"{text[:93]}..."
|
||||
return text
|
||||
|
||||
@@ -62,6 +62,7 @@ class TrialSubscriptionMixin:
|
||||
"status_from_panel": "TRIAL",
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
"provider": "trial",
|
||||
}
|
||||
try:
|
||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||
@@ -83,7 +84,15 @@ class TrialSubscriptionMixin:
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
include_default_squads=False,
|
||||
)
|
||||
trial_squads = self.settings.parsed_trial_squad_uuids
|
||||
if trial_squads:
|
||||
panel_update_payload["activeInternalSquads"] = trial_squads
|
||||
if self.settings.parsed_user_external_squad_uuid:
|
||||
panel_update_payload["externalSquadUuid"] = (
|
||||
self.settings.parsed_user_external_squad_uuid
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -125,7 +126,7 @@ class SupportService:
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.email_auth_service = email_auth_service or EmailAuthService(settings)
|
||||
self.email_auth_service = email_auth_service or EmailAuthService(settings, i18n)
|
||||
self.notification_service = notification_service or NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
@@ -134,6 +135,16 @@ class SupportService:
|
||||
email_auth_service=self.email_auth_service,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _schedule_notification(coro, error_message: str, *error_args: Any) -> None:
|
||||
async def _runner():
|
||||
try:
|
||||
await coro
|
||||
except Exception:
|
||||
logger.exception(error_message, *error_args)
|
||||
|
||||
asyncio.create_task(_runner(), name="support-notification")
|
||||
|
||||
async def _ensure_user_allowed(self, session, user_id: int) -> User:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
|
||||
@@ -218,8 +229,8 @@ class SupportService:
|
||||
await session.commit()
|
||||
|
||||
if notification_decision.send_telegram or notification_decision.send_email:
|
||||
try:
|
||||
await self.notification_service.notify_support_user_reply(
|
||||
self._schedule_notification(
|
||||
self.notification_service.notify_support_user_reply(
|
||||
ticket,
|
||||
message,
|
||||
user,
|
||||
@@ -227,9 +238,10 @@ class SupportService:
|
||||
unread_count=int(ticket.unread_admin_count or 0),
|
||||
send_telegram=notification_decision.send_telegram,
|
||||
send_email=notification_decision.send_email,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify about support user reply %s", ticket_id)
|
||||
),
|
||||
"Failed to notify about support user reply %s",
|
||||
ticket_id,
|
||||
)
|
||||
return ticket, message
|
||||
|
||||
async def reply_as_admin(
|
||||
@@ -275,10 +287,11 @@ class SupportService:
|
||||
await session.commit()
|
||||
|
||||
if user and not is_internal_note:
|
||||
try:
|
||||
await self.notification_service.notify_support_admin_reply(ticket, message, user)
|
||||
except Exception:
|
||||
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
|
||||
self._schedule_notification(
|
||||
self.notification_service.notify_support_admin_reply(ticket, message, user),
|
||||
"Failed to notify user about support admin reply %s",
|
||||
ticket_id,
|
||||
)
|
||||
return ticket, message
|
||||
|
||||
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
@@ -30,6 +30,7 @@ PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100
|
||||
TARIFF_WORKER_BATCH_SIZE = 50
|
||||
TARIFF_WORKER_PANEL_CONCURRENCY = 10
|
||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
|
||||
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
|
||||
|
||||
|
||||
class TariffTrafficWorker:
|
||||
@@ -51,6 +52,7 @@ class TariffTrafficWorker:
|
||||
self._stopped = asyncio.Event()
|
||||
self._premium_nodes_cache = {}
|
||||
self._premium_node_usage_tick_cache = {}
|
||||
self._premium_squad_match_cache = {}
|
||||
|
||||
async def _user_lang(self, session: AsyncSession, user_id: int) -> str:
|
||||
try:
|
||||
@@ -148,6 +150,7 @@ class TariffTrafficWorker:
|
||||
return
|
||||
|
||||
panel_users_by_uuid = await self._prefetch_panel_users_by_uuid(subs)
|
||||
panel_view = "list" if panel_users_by_uuid is not None else "full_fetch"
|
||||
semaphore = asyncio.Semaphore(TARIFF_WORKER_PANEL_CONCURRENCY)
|
||||
|
||||
async def _fetch_panel(sub: Subscription) -> dict:
|
||||
@@ -212,6 +215,7 @@ class TariffTrafficWorker:
|
||||
|
||||
if tariff.billing_model == "period":
|
||||
await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy)
|
||||
await self._sync_hwid_device_limit(session, sub, tariff, panel_data)
|
||||
await self._maybe_warn_or_throttle(
|
||||
session,
|
||||
sub,
|
||||
@@ -230,6 +234,7 @@ class TariffTrafficWorker:
|
||||
now,
|
||||
panel_username=panel_username,
|
||||
panel_user_dict=panel_data,
|
||||
panel_view=panel_view,
|
||||
)
|
||||
|
||||
async def _prefetch_panel_users_by_uuid(
|
||||
@@ -321,8 +326,7 @@ class TariffTrafficWorker:
|
||||
sub.skip_notifications = True
|
||||
sub.status_from_panel = "PANEL_USER_NOT_FOUND"
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: deactivated subscription %s because panel user %s "
|
||||
"is missing",
|
||||
"TariffTrafficWorker: deactivated subscription %s because panel user %s is missing",
|
||||
sub.subscription_id,
|
||||
current_uuid,
|
||||
)
|
||||
@@ -374,6 +378,64 @@ class TariffTrafficWorker:
|
||||
sub.panel_user_uuid, payload, log_response=False
|
||||
)
|
||||
|
||||
async def _sync_hwid_device_limit(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
tariff,
|
||||
panel_data: dict,
|
||||
) -> None:
|
||||
base_hwid_limit = (
|
||||
int(sub.hwid_device_limit)
|
||||
if sub.hwid_device_limit is not None
|
||||
else self.subscription_service._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
active_extra = await tariff_dal.sum_active_hwid_devices(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
)
|
||||
update_data = {}
|
||||
if sub.hwid_device_limit != base_hwid_limit:
|
||||
update_data["hwid_device_limit"] = base_hwid_limit
|
||||
if int(sub.extra_hwid_devices or 0) != active_extra:
|
||||
update_data["extra_hwid_devices"] = active_extra
|
||||
if update_data:
|
||||
for key, value in update_data.items():
|
||||
setattr(sub, key, value)
|
||||
|
||||
effective_limit = self.subscription_service._effective_hwid_limit(
|
||||
base_hwid_limit,
|
||||
active_extra,
|
||||
)
|
||||
if effective_limit is None:
|
||||
return
|
||||
try:
|
||||
panel_limit = panel_data.get("hwidDeviceLimit")
|
||||
panel_limit_int = int(panel_limit) if panel_limit is not None else None
|
||||
except (TypeError, ValueError):
|
||||
panel_limit_int = None
|
||||
if panel_limit_int == effective_limit:
|
||||
return
|
||||
|
||||
payload = self.subscription_service._build_panel_update_payload(
|
||||
panel_user_uuid=sub.panel_user_uuid,
|
||||
expire_at=sub.end_date,
|
||||
hwid_device_limit=effective_limit,
|
||||
include_default_squads=False,
|
||||
)
|
||||
updated_panel = await self.panel_service.update_user_details_on_panel(
|
||||
sub.panel_user_uuid,
|
||||
payload,
|
||||
log_response=False,
|
||||
)
|
||||
if not updated_panel or updated_panel.get("error"):
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: failed to sync HWID limit for subscription %s: %s",
|
||||
sub.subscription_id,
|
||||
updated_panel,
|
||||
)
|
||||
|
||||
async def _maybe_warn_or_throttle(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -462,6 +524,7 @@ class TariffTrafficWorker:
|
||||
*,
|
||||
panel_username: Optional[str] = None,
|
||||
panel_user_dict: Optional[dict] = None,
|
||||
panel_view: str = "unknown",
|
||||
) -> None:
|
||||
if not getattr(tariff, "premium_squad_uuids", None):
|
||||
if (
|
||||
@@ -482,12 +545,22 @@ class TariffTrafficWorker:
|
||||
return
|
||||
|
||||
premium_period_start = month_start(now)
|
||||
same_period = bool(getattr(sub, "premium_period_start_at", None) == premium_period_start)
|
||||
same_period = self._same_premium_period(
|
||||
getattr(sub, "premium_period_start_at", None),
|
||||
premium_period_start,
|
||||
)
|
||||
premium_baseline = int(tariff.premium_monthly_bytes or 0)
|
||||
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
|
||||
premium_topup_used = (
|
||||
int(getattr(sub, "premium_topup_used_bytes", 0) or 0) if same_period else 0
|
||||
)
|
||||
premium_topup_balance = await self._repair_premium_topup_balance_from_ledger(
|
||||
session,
|
||||
sub,
|
||||
premium_period_start,
|
||||
premium_topup_balance,
|
||||
premium_topup_used,
|
||||
)
|
||||
# Admin-side overrides for free gifted premium traffic.
|
||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||
premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0))
|
||||
@@ -532,22 +605,59 @@ class TariffTrafficWorker:
|
||||
should_limit = False
|
||||
else:
|
||||
should_limit = premium_used >= premium_limit
|
||||
panel_needs_update = bool(sub.premium_is_limited) != should_limit
|
||||
access_state_changed = bool(sub.premium_is_limited) != should_limit
|
||||
desired_squads = self.subscription_service._panel_squads_for_tariff(
|
||||
tariff,
|
||||
include_premium=not should_limit,
|
||||
)
|
||||
desired_set = self._internal_squad_uuid_set(desired_squads)
|
||||
if isinstance(panel_user_dict, dict):
|
||||
current_known = False
|
||||
current_raw = None
|
||||
for key in ("activeInternalSquads", "active_internal_squads"):
|
||||
if key in panel_user_dict:
|
||||
current_raw = panel_user_dict.get(key)
|
||||
current_known = True
|
||||
break
|
||||
if current_known and desired_set != self._internal_squad_uuid_set(current_raw):
|
||||
squad_match_cache_key = self._premium_squad_match_cache_key(
|
||||
sub.panel_user_uuid,
|
||||
desired_set,
|
||||
)
|
||||
panel_needs_update = access_state_changed
|
||||
panel_user_for_report = panel_user_dict
|
||||
panel_view_for_report = panel_view
|
||||
panel_update_reasons: list[str] = []
|
||||
if access_state_changed:
|
||||
panel_update_reasons.append(
|
||||
"premium_access_limited" if should_limit else "premium_access_restored"
|
||||
)
|
||||
|
||||
current_known, current_set = self._panel_active_squad_uuid_set(panel_user_dict)
|
||||
if current_known:
|
||||
current_mismatch = desired_set != current_set
|
||||
if not current_mismatch:
|
||||
panel_needs_update = False
|
||||
elif panel_view == "list":
|
||||
if self._premium_squad_match_cache_is_fresh(squad_match_cache_key):
|
||||
panel_needs_update = False
|
||||
else:
|
||||
full_panel_user = await self._get_full_panel_user_for_squad_confirmation(
|
||||
sub.panel_user_uuid,
|
||||
)
|
||||
full_known, full_set = self._panel_active_squad_uuid_set(full_panel_user)
|
||||
if full_known:
|
||||
panel_user_for_report = full_panel_user
|
||||
panel_view_for_report = "full_fetch"
|
||||
if desired_set != full_set:
|
||||
panel_needs_update = True
|
||||
panel_update_reasons.append("activeInternalSquads_mismatch")
|
||||
else:
|
||||
self._remember_premium_squad_match(squad_match_cache_key)
|
||||
panel_needs_update = False
|
||||
elif not access_state_changed:
|
||||
panel_needs_update = False
|
||||
else:
|
||||
panel_needs_update = True
|
||||
panel_update_reasons.append("activeInternalSquads_mismatch")
|
||||
if (
|
||||
not panel_needs_update
|
||||
and current_known
|
||||
and desired_set == current_set
|
||||
and panel_view != "list"
|
||||
):
|
||||
self._remember_premium_squad_match(squad_match_cache_key)
|
||||
sub.premium_baseline_bytes = premium_baseline
|
||||
sub.premium_topup_balance_bytes = premium_topup_balance
|
||||
sub.premium_topup_used_bytes = premium_topup_used
|
||||
@@ -567,11 +677,21 @@ class TariffTrafficWorker:
|
||||
return
|
||||
|
||||
squads = desired_squads
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
self._log_premium_squad_panel_patch(
|
||||
sub=sub,
|
||||
panel_uuid=sub.panel_user_uuid,
|
||||
update_payload={"uuid": sub.panel_user_uuid, "activeInternalSquads": squads},
|
||||
current_panel_user=panel_user_for_report,
|
||||
reasons=panel_update_reasons or ["premium_squad_sync"],
|
||||
panel_view=panel_view_for_report,
|
||||
)
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
sub.panel_user_uuid,
|
||||
{"uuid": sub.panel_user_uuid, "activeInternalSquads": squads},
|
||||
log_response=False,
|
||||
)
|
||||
if updated_panel_user:
|
||||
self._remember_premium_squad_match(squad_match_cache_key)
|
||||
logging.info(
|
||||
"Premium squad access %s for user %s tariff %s: %s/%s bytes",
|
||||
"limited" if should_limit else "restored",
|
||||
@@ -581,20 +701,193 @@ class TariffTrafficWorker:
|
||||
premium_limit,
|
||||
)
|
||||
|
||||
async def _get_full_panel_user_for_squad_confirmation(
|
||||
self,
|
||||
panel_user_uuid: str,
|
||||
) -> Optional[dict]:
|
||||
try:
|
||||
return await self.panel_service.get_user_by_uuid(
|
||||
panel_user_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"TariffTrafficWorker: failed to confirm panel squads for user %s",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _same_premium_period(value: Optional[datetime], premium_period_start: datetime) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
return month_start(value) == premium_period_start
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _repair_premium_topup_balance_from_ledger(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
premium_period_start: datetime,
|
||||
premium_topup_balance: int,
|
||||
premium_topup_used: int,
|
||||
) -> int:
|
||||
ledger_total = await self._premium_topup_ledger_total(
|
||||
session,
|
||||
int(getattr(sub, "subscription_id", 0) or 0),
|
||||
premium_period_start,
|
||||
)
|
||||
if ledger_total is None:
|
||||
return premium_topup_balance
|
||||
|
||||
tracked_total = max(0, int(premium_topup_balance or 0)) + max(
|
||||
0,
|
||||
int(premium_topup_used or 0),
|
||||
)
|
||||
if ledger_total <= tracked_total:
|
||||
return premium_topup_balance
|
||||
|
||||
repaired_bytes = ledger_total - tracked_total
|
||||
logging.warning(
|
||||
"Premium top-up balance repaired from ledger for user %s subscription %s: "
|
||||
"tracked=%s ledger=%s repaired=%s",
|
||||
getattr(sub, "user_id", None),
|
||||
getattr(sub, "subscription_id", None),
|
||||
tracked_total,
|
||||
ledger_total,
|
||||
repaired_bytes,
|
||||
)
|
||||
return premium_topup_balance + repaired_bytes
|
||||
|
||||
async def _premium_topup_ledger_total(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
subscription_id: int,
|
||||
premium_period_start: datetime,
|
||||
) -> Optional[int]:
|
||||
if not subscription_id or not isinstance(session, AsyncSession):
|
||||
return None
|
||||
try:
|
||||
return await tariff_dal.sum_traffic_topups(
|
||||
session,
|
||||
subscription_id=subscription_id,
|
||||
kinds=["premium_topup", "admin_premium_topup"],
|
||||
created_at_gte=premium_period_start,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"TariffTrafficWorker: failed to read premium top-up ledger for subscription %s",
|
||||
subscription_id,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _premium_squad_match_cache_key(
|
||||
panel_user_uuid: str,
|
||||
desired_set: set[str],
|
||||
) -> tuple[str, tuple[str, ...]]:
|
||||
return str(panel_user_uuid), tuple(sorted(desired_set))
|
||||
|
||||
def _premium_squad_match_cache_is_fresh(self, cache_key: tuple[str, tuple[str, ...]]) -> bool:
|
||||
cached_at = self._premium_squad_match_cache.get(cache_key)
|
||||
if not cached_at:
|
||||
return False
|
||||
return (
|
||||
time.monotonic() - float(cached_at) < TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
|
||||
)
|
||||
|
||||
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
|
||||
self._premium_squad_match_cache[cache_key] = time.monotonic()
|
||||
|
||||
@classmethod
|
||||
def _panel_active_squad_uuid_set(
|
||||
cls,
|
||||
panel_user_dict: Optional[dict],
|
||||
) -> tuple[bool, set[str]]:
|
||||
current_known, current_raw = cls._panel_active_squads_raw(panel_user_dict)
|
||||
return current_known, cls._internal_squad_uuid_set(current_raw)
|
||||
|
||||
@staticmethod
|
||||
def _panel_active_squads_raw(panel_user_dict: Optional[dict]) -> tuple[bool, Any]:
|
||||
if not isinstance(panel_user_dict, dict):
|
||||
return False, None
|
||||
for key in (
|
||||
"activeInternalSquads",
|
||||
"active_internal_squads",
|
||||
"activeInternalSquadUuids",
|
||||
"active_internal_squad_uuids",
|
||||
):
|
||||
if key in panel_user_dict:
|
||||
return True, panel_user_dict.get(key)
|
||||
return False, None
|
||||
|
||||
def _log_premium_squad_panel_patch(
|
||||
self,
|
||||
*,
|
||||
sub: Subscription,
|
||||
panel_uuid: str,
|
||||
update_payload: dict[str, Any],
|
||||
current_panel_user: Optional[dict],
|
||||
reasons: list[str],
|
||||
panel_view: str,
|
||||
) -> None:
|
||||
current_known, current_set = self._panel_active_squad_uuid_set(current_panel_user)
|
||||
desired_set = self._internal_squad_uuid_set(update_payload.get("activeInternalSquads"))
|
||||
fields = "none" if current_known and current_set == desired_set else "activeInternalSquads"
|
||||
logging.info(
|
||||
"Sync panel PATCH: source=%s user_id=%s telegram_id=%s panel_uuid=%s "
|
||||
"panel_view=%s reasons=%s fields=%s payload_fields=%s changes=%s",
|
||||
"premium_squad_limit",
|
||||
getattr(sub, "user_id", None),
|
||||
getattr(sub, "user_id", None),
|
||||
panel_uuid,
|
||||
panel_view,
|
||||
",".join(reasons),
|
||||
fields,
|
||||
"activeInternalSquads",
|
||||
"activeInternalSquads:%s->%s"
|
||||
% (
|
||||
self._format_squad_uuid_set(current_set if current_known else None),
|
||||
self._format_squad_uuid_set(desired_set),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _internal_squad_uuid_set(raw) -> set[str]:
|
||||
if not isinstance(raw, list):
|
||||
if not isinstance(raw, (list, tuple, set)):
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
u = item.get("uuid") or item.get("internalSquadUuid") or item.get("squadUuid")
|
||||
nested_squad = item.get("internalSquad") or item.get("squad")
|
||||
if not isinstance(nested_squad, dict):
|
||||
nested_squad = {}
|
||||
u = (
|
||||
item.get("uuid")
|
||||
or item.get("internalSquadUuid")
|
||||
or item.get("squadUuid")
|
||||
or nested_squad.get("uuid")
|
||||
)
|
||||
if u:
|
||||
out.add(str(u))
|
||||
elif item:
|
||||
out.add(str(item))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _format_squad_uuid_set(value: Optional[set[str]]) -> str:
|
||||
if value is None:
|
||||
return "missing"
|
||||
values = sorted(str(item) for item in value)
|
||||
preview = ",".join(values[:4])
|
||||
suffix = ",..." if len(values) > 4 else ""
|
||||
text = f"[{len(values)}:{preview}{suffix}]"
|
||||
if len(text) > 96:
|
||||
return f"{text[:93]}..."
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _fmt_bytes(value: int) -> str:
|
||||
size = float(max(0, int(value or 0)))
|
||||
|
||||
@@ -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}"
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal.subscription_dal import normalize_install_share_token
|
||||
|
||||
|
||||
def append_query_params(base_url: str, params: dict[str, str]) -> str:
|
||||
@@ -31,3 +32,36 @@ def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[s
|
||||
return None
|
||||
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
|
||||
return append_query_params(base, {"topup": normalized})
|
||||
|
||||
|
||||
def subscription_mini_app_path_url(settings: Settings, path: str) -> Optional[str]:
|
||||
"""Return a Mini App URL with ``path`` appended to the configured app base."""
|
||||
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
normalized_path = f"/{str(path or '').lstrip('/')}"
|
||||
return f"{base.rstrip('/')}{normalized_path}"
|
||||
|
||||
|
||||
def subscription_mini_app_install_url(settings: Settings) -> Optional[str]:
|
||||
"""Return the personal embedded install guide URL."""
|
||||
return subscription_mini_app_path_url(settings, "/install")
|
||||
|
||||
|
||||
def subscription_mini_app_trial_url(settings: Settings) -> Optional[str]:
|
||||
"""Return the trial activation URL inside the Mini App."""
|
||||
return subscription_mini_app_path_url(settings, "/trial")
|
||||
|
||||
|
||||
def subscription_public_install_url(settings: Settings, share_token: str) -> Optional[str]:
|
||||
"""Return the public install guide URL for a normalized share token."""
|
||||
token = normalize_install_share_token(share_token)
|
||||
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
|
||||
if not token or not base:
|
||||
return None
|
||||
parts = urlsplit(base)
|
||||
if parts.scheme and parts.netloc:
|
||||
public_base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
else:
|
||||
public_base = base.rstrip("/")
|
||||
return f"{public_base.rstrip('/')}/s/{quote(token)}"
|
||||
|
||||