Compare commits

..
86 Commits
Author SHA1 Message Date
3252a8 f30e729f1f Merge branch 'dev' into 'main'
Install guides, payment fixes, and runtime translations

See merge request 3252a8/remnawave-minshop!1
2026-05-26 10:39:29 +00:00
3252a8 1e63431ae3 chore: merge main into dev 2026-05-26 12:51:00 +03:00
3252a8 072e7273c4 docs: standardize MIT license file 2026-05-26 12:48:58 +03:00
3252a8 df079138ee docs: standardize MIT license file 2026-05-26 12:44:27 +03:00
3252a8 87f32114d7 docs: update license file attribution 2026-05-26 12:29:58 +03:00
3252a8 b330e604f6 chore: use Docker Hub images in compose files 2026-05-26 11:15:10 +03:00
3252a8 827d69231c feat: add Docker Hub image script 2026-05-26 08:58:09 +03:00
3252a8 0623850c3f fix: open Telegram Stars invoices inside Mini App 2026-05-25 22:13:11 +03:00
3252a8 4c77d129e7 fix: update support page content without page refresh 2026-05-25 22:03:30 +03:00
3252a8 c68cb97964 fix: bind HWID top-ups to subscription periods 2026-05-25 19:33:50 +03:00
3252a8 59fa301344 feat: add runtime locale overrides 2026-05-25 17:47:45 +03:00
3252a8 23784d00fe chore: update custom themes styles 2026-05-25 13:52:25 +03:00
3252a8 17cff74b7c chore: tune sub activate modal view 2026-05-25 13:11:48 +03:00
3252a8 2a3a3c21ba chore: adjust webapp payment layout 2026-05-25 12:42:17 +03:00
3252a8 ba7811621e fix: preserve premium topup squad access 2026-05-25 12:18:58 +03:00
3252a8 340afe1b80 fix: stop premium squad sync churn 2026-05-25 11:54:03 +03:00
3252a8 11b7823188 fix: expire Wata payment links sooner 2026-05-25 11:34:10 +03:00
3252a8 de1763cfe1 feat: enable payment provider for admin only 2026-05-25 11:19:54 +03:00
3252a8 d30b069876 fix: reuse pending Wata payment links on retry 2026-05-25 10:49:57 +03:00
3252a8 0d68da9624 fix: handle Wata payment links 2026-05-25 09:17:39 +03:00
3252a8 4d857b386b fix: remove inline startup panel sync 2026-05-25 00:40:24 +03:00
3252a8 be7a3bc153 fix: queue bot panel sync requests 2026-05-25 00:36:15 +03:00
3252a8 5c71fc0de2 fix: hide links from payment success message 2026-05-25 00:23:48 +03:00
3252a8 82cc33587c fix: stop panel description churn 2026-05-25 00:06:29 +03:00
3252a8 121f3c6ddf feat: add compact panel sync update diagnostics 2026-05-24 23:44:29 +03:00
3252a8 3410eeaec1 fix(admin): delete Remnawave user with bot account 2026-05-24 23:35:19 +03:00
3252a8 85ecd644b8 fix: refresh webapp profile after activation 2026-05-24 23:25:21 +03:00
3252a8 7cffb4667f fix: expose iOS home screen icons for web app 2026-05-24 23:13:15 +03:00
3252a8 0449ded505 feat: render webapp preview metadata 2026-05-24 22:54:01 +03:00
3252a8 e15cbffdb1 feat: expose webapp title in admin settings 2026-05-24 22:53:50 +03:00
3252a8 9674c674a2 feat: resume activation handoff after payments 2026-05-24 22:45:54 +03:00
3252a8 bafc5f4709 feat: show linked emails in telegram logs 2026-05-24 22:32:35 +03:00
3252a8 a60de46173 feat: guide users after subscription activation 2026-05-24 22:24:02 +03:00
3252a8 1165dc3fa6 chore: verify formatting checks 2026-05-24 22:04:12 +03:00
3252a8 f5ab18a67b feat: redesign webapp trial offer 2026-05-24 22:02:22 +03:00
3252a8 65db4aaff6 fix: clean legacy emails from panel descriptions 2026-05-24 21:45:38 +03:00
3252a8 d9a23235e5 fix: stop syncing email in panel descriptions 2026-05-24 21:36:00 +03:00
3252a8 00b54e1f15 feat: improve admin trial settings controls 2026-05-24 19:19:39 +03:00
3252a8 3c4ff66150 feat: streamline admin tariff settings 2026-05-24 19:10:36 +03:00
3252a8 2b3078fcce chore: fix formatting checks 2026-05-24 18:51:37 +03:00
3252a8 6f4074e8d6 feat: surface trial activation in mini app 2026-05-24 18:48:01 +03:00
3252a8 235ee25d9f fix: refresh YooKassa webapp payments 2026-05-24 18:47:37 +03:00
3252a8 d1b9990bac feat: manage trial settings on tariffs page 2026-05-24 18:46:23 +03:00
3252a8 60d1ba4efc fix: handle Wata prepayment webhooks 2026-05-24 14:04:21 +03:00
3252a8 29b6e6eb24 fix: keep payment user cards in payments 2026-05-24 09:32:47 +03:00
3252a8 aa65972483 feat: add admin payment detail view 2026-05-24 09:30:58 +03:00
3252a8 ad275a7c83 feat: polish admin settings layout 2026-05-24 09:11:43 +03:00
3252a8 02b57ead00 feat: separate legacy tariff settings 2026-05-24 09:11:34 +03:00
3252a8 7f484fa7b3 feat: warn about legacy tariff settings 2026-05-24 00:05:16 +03:00
3252a8 3ea906a74d feat: group Platega admin settings 2026-05-24 00:01:58 +03:00
3252a8 939c40ccbf feat: collapse admin settings by default 2026-05-24 00:01:18 +03:00
3252a8 f0eb291f56 feat: tune visual of install instructions page 2026-05-23 23:55:06 +03:00
3252a8 46391b10e2 feat: tune deeplink fallback page 2026-05-23 23:26:10 +03:00
3252a8 5918a6cc71 fix: open install guide deeplinks via external app gateway 2026-05-23 23:07:42 +03:00
3252a8 0eeabc7b3a fix: process yookassa hwid device topups 2026-05-23 22:28:05 +03:00
3252a8 31eb5c06ad fix: repair linked panel email during sync 2026-05-23 22:27:51 +03:00
3252a8 60d8c297f9 docs: update deploy examples 2026-05-23 22:01:40 +03:00
3252a8 dab70d5a97 docs: update install guides documentation 2026-05-23 16:22:57 +03:00
3252a8 520a6289a8 Merge branch 'feature/install-page' into dev 2026-05-23 16:16:46 +03:00
3252a8 7adca53116 feat: enable bot install guides by default 2026-05-23 16:05:41 +03:00
3252a8 fbc3e6c084 chore: fix frontend formatting checks 2026-05-23 16:01:07 +03:00
3252a8 19ba5c8f11 feat: support install guide app deeplinks 2026-05-23 09:52:26 +03:00
3252a8 ce0d4dccf7 feat: open bot install guides in mini app 2026-05-23 09:48:30 +03:00
3252a8 7c874cd4aa feat: support install guides in custom themes 2026-05-22 23:39:17 +03:00
3252a8 17224b4f74 feat: harden public install guide loading 2026-05-22 23:29:58 +03:00
3252a8 40414264be feat: polish install guide loading state 2026-05-22 23:24:59 +03:00
3252a8 d9a4a007e2 feat: add install guide share tokens and animations 2026-05-22 23:20:25 +03:00
3252a8 d3925cad22 Merge branch 'dev' into feature/install-page
# Conflicts:
#	backend/bot/app/web/admin_api_impl/settings.py
#	backend/bot/app/web/webapp/cache_helpers.py
#	frontend/src/admin/sections/SettingsSection.svelte
#	tests/test_admin_settings_manifest_i18n.py
2026-05-22 22:57:24 +03:00
3252a8 443e2e62db fix: auto-merge duplicate panel identities 2026-05-22 22:39:45 +03:00
3252a8andGitHub 4b7c58a6c9 Merge pull request #1 from 3252a8/dev
Stabilize account linking, auth, and admin settings
2026-05-22 22:24:07 +03:00
3252a8 19aa2ec9c9 fix: log account merge notifications 2026-05-22 22:02:28 +03:00
3252a8 c4c5b8e3a0 fix: merge active sub email account with expired sub telegram account 2026-05-22 18:23:38 +03:00
3252a8 33a7dbc0e6 fix: app version display pattern in admin panel sidebar 2026-05-22 18:23:11 +03:00
3252a8 72921b9a8f fix: telegram oauth in logged in email account 2026-05-22 17:48:17 +03:00
3252a8 835436fa1a fix: ensure web app pay button is spawning when enable payment provider 2026-05-22 16:29:59 +03:00
3252a8 648f4ba4bc feat: show payment provider webhook urls 2026-05-22 15:57:49 +03:00
3252a8 f5023dc46b fix: repair missing panel user references 2026-05-22 15:39:51 +03:00
3252a8 4c0a798050 fix: load admin assets from stable paths 2026-05-22 15:39:45 +03:00
3252a8 cfe7c4ec5f fix: style mobile admin button 2026-05-22 15:11:28 +03:00
3252a8 c82779e15b fix: restore telegram mini app auth 2026-05-22 14:47:01 +03:00
3252a8 9e56715e77 fix: cover email telegram account linking 2026-05-22 14:41:56 +03:00
3252a8 2254b9ad19 fix: avoid repeated panel identity syncs 2026-05-22 14:11:38 +03:00
3252a8 81707d9c7c fix: clean merged panel identities 2026-05-22 14:11:31 +03:00
3252a8 9e61a3d8a8 feat: install instruction inside web app 2026-05-22 13:58:34 +03:00
3252a8 8a38524774 docs: add url to remnawave 2026-05-21 23:09:22 +03:00
3252a8 87d3f1b410 docs: add nginx example 2026-05-21 22:40:53 +03:00
228 changed files with 28714 additions and 1508 deletions
+1
View File
@@ -16,6 +16,7 @@ frontend/node_modules/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
# CI
+1
View File
@@ -63,3 +63,4 @@ locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
+18 -4
View File
@@ -1,7 +1,21 @@
Copyright 2025 machka-pasla
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+15 -7
View File
@@ -2,7 +2,7 @@
![Remnawave Minishop](docs/remnawave-minishop.webp)
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
@@ -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 и внешняя ссылка на поддержку;
@@ -26,6 +27,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональный JSON-override и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью.
## Документация
@@ -34,7 +36,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- [Переменные `.env`](docs/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход, инструкции установки и реферальные ссылки.
- [Поддержка](docs/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
@@ -86,7 +88,7 @@ docker compose logs -f backend worker frontend
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- остальные настройки удобнее задать в Web App админке.
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку и уведомления через UI. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/env-vars.md](docs/env-vars.md).
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
@@ -94,6 +96,7 @@ docker compose logs -f backend worker frontend
```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,13 +110,18 @@ docker compose up -d --build
# Логи приложения
docker compose logs -f backend worker frontend
# Запуск с Caddy
docker compose -f deploy/compose/docker-compose-caddy.yml up -d
# Готовые production-примеры
cd deploy/examples/caddy # или nginx, newt, no-proxy
cp .env.example .env
nano .env
docker compose up -d
# Запуск из готового образа
IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d
# Запуск из готового образа с конкретным тегом
IMAGE_TAG=3.1.0 docker compose up -d
```
Для production-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples): там отдельно собраны варианты для Caddy, Nginx, Newt/Pangolin и прямой публикации портов без reverse proxy. В каждой папке рядом лежат `docker-compose.yml`, `.env.example`, README и нужный proxy-конфиг.
GHCR image names for releases:
- `ghcr.io/3252a8/remnawave-minishop-backend`
+1 -1
View File
@@ -30,7 +30,7 @@ def build_core_services(
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
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,
+2
View File
@@ -19,6 +19,7 @@ from bot.app.web.admin_api_impl import (
sync as _sync,
tariffs as _tariffs,
themes as _themes,
translations as _translations,
users as _users,
)
@@ -38,6 +39,7 @@ _MODULES = (
_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,
@@ -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)
+37 -22
View File
@@ -1,5 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_admin_config_json,
)
async def admin_settings_get_route(request: web.Request) -> web.Response:
@@ -13,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload()
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
sections: Dict[str, Dict[str, Any]] = {}
for field in fields:
key = field["key"]
@@ -26,14 +33,35 @@ 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()
if webhook_path:
if not webhook_path.startswith("/"):
webhook_path = f"/{webhook_path}"
response_field["webhook_path"] = webhook_path
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
if webhook_base_url:
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
@@ -51,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
return _error(400, "invalid_updates")
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,
@@ -65,26 +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"] = {}
if (
"WEBAPP_LOGO_URL" in updates
or "WEBAPP_LOGO_URL" in deletes
or "WEBAPP_LOGO_USE_EMOJI" in updates
or "WEBAPP_LOGO_USE_EMOJI" in deletes
or "WEBAPP_FAVICON_URL" in updates
or "WEBAPP_FAVICON_URL" in deletes
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
or "WEBAPP_LOGO_FAVICON_URL" in updates
or "WEBAPP_LOGO_FAVICON_URL" in deletes
):
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
@@ -55,9 +56,6 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
+6 -15
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
import asyncio
import hashlib
@@ -65,11 +66,9 @@ def _bump_theme_asset_versions(
previous_theme = previous_by_key.get(key)
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
current_version = int(theme.get("assets_version") or 1)
theme_changed = (
previous_theme is None
or _theme_payload_for_version_compare(theme)
!= _theme_payload_for_version_compare(previous_theme)
)
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
theme
) != _theme_payload_for_version_compare(previous_theme)
if theme_changed or (default_changed and key == config.default_theme):
theme["assets_version"] = max(previous_version + 1, current_version, 1)
elif previous_version > current_version:
@@ -213,12 +212,7 @@ async def _persist_appearance_upload(
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
request.app["webapp_logo_cache"] = None
prune_unused_appearance_assets(settings)
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
return True
@@ -484,10 +478,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok(
{
@@ -0,0 +1,145 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
from bot.services.locale_override_service import (
LOCALE_OVERRIDES_PATH,
audience_for_locale_key,
group_id_for_locale_key,
locale_group_catalog,
load_locale_overrides,
update_locale_overrides,
)
def _locale_languages(
i18n: JsonI18n,
overrides: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
base_languages = set((i18n.base_locales_data or {}).keys())
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
override_languages.update((i18n.locale_overrides or {}).keys())
return locale_language_options(
base_languages | override_languages,
base_languages=base_languages,
)
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
result: Dict[Tuple[str, str], Dict] = {}
for entry in overrides:
lang = str(entry.get("lang") or "")
raw_key = str(entry.get("key") or "")
key = resolve_locale_key(raw_key)
if lang and key:
if raw_key != key and (lang, key) in result:
continue
result[(lang, key)] = entry
return result
def _admin_translations_payload(
i18n: JsonI18n,
overrides: List[Dict[str, Any]],
) -> Dict[str, Any]:
base_data = i18n.base_locales_data or i18n.locales_data or {}
effective_data = i18n.locales_data or {}
override_meta = _locale_override_meta_map(overrides)
language_items = _locale_languages(i18n, overrides)
languages = [item["code"] for item in language_items]
all_keys = sorted(
{key for messages in base_data.values() for key in messages.keys()}
| {key for _, key in override_meta.keys()}
)
groups_by_id = {
group["id"]: {
**group,
"items": [],
}
for group in locale_group_catalog()
}
for key in all_keys:
values: Dict[str, Dict[str, Any]] = {}
for lang in languages:
meta = override_meta.get((lang, key))
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
values[lang] = {
"base": base_data.get(lang, {}).get(key, ""),
"fallback": fallback_base,
"effective": effective_data.get(lang, {}).get(key, ""),
"override": meta.get("value") if meta else "",
"overridden": bool(meta),
"updated_at": meta.get("updated_at") if meta else None,
"updated_by": meta.get("updated_by") if meta else None,
}
group_id = group_id_for_locale_key(key)
groups_by_id.setdefault(
group_id,
{"id": group_id, "title": group_id, "description": "", "items": []},
)
groups_by_id[group_id]["items"].append(
{
"key": key,
"audience": audience_for_locale_key(key),
"values": values,
}
)
groups = [group for group in groups_by_id.values() if group["items"]]
return {
"languages": language_items,
"groups": groups,
"path": str(LOCALE_OVERRIDES_PATH),
"override_count": len(overrides),
}
async def admin_translations_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
await load_locale_overrides(i18n, async_session_factory)
async with async_session_factory() as session:
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
return _ok(_admin_translations_payload(i18n, overrides))
async def admin_translations_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = await _read_json(request)
updates = payload.get("updates") or {}
deletes = payload.get("deletes") or []
if not isinstance(updates, dict):
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
result = await update_locale_overrides(
i18n,
async_session_factory,
updates=updates,
deletes=deletes,
actor_id=actor_id,
)
if not result.get("ok"):
return web.json_response(
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
status=400,
)
return _ok(
{
"applied": result.get("applied", 0),
"reverted": result.get("reverted", 0),
"file_written": result.get("file_written", False),
}
)
+58 -3
View File
@@ -1,5 +1,16 @@
# ruff: noqa: F401,F403,F405,I001
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)
+143 -20
View File
@@ -16,7 +16,7 @@ from typing import Any, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon"
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
section: str
label: str
description: str = ""
@@ -34,6 +34,13 @@ class SettingField:
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
"WEBAPP_TITLE",
"string",
"general",
"Web App title",
placeholder="My subscription",
),
SettingField(
"DEFAULT_LANGUAGE",
"string",
@@ -117,9 +124,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
subsection="Remnawave",
),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
),
SettingField(
"SUBSCRIPTION_MINI_APP_URL",
"url",
@@ -160,6 +164,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
SettingField(
"SUBSCRIPTION_GUIDES_ENABLED",
"bool",
"subscription_guides",
"Embedded install guides",
"Open install instructions inside the Web App instead of an external connect page.",
),
SettingField(
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
"bool",
"subscription_guides",
"Open install guides from bot",
(
"Use the Telegram Mini App install screen for bot connect buttons and show "
"public install guide links."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
"bool",
"subscription_guides",
"Use Remnawave Panel config",
(
"Fetch Subscription Page config from Remnawave Panel by the user's "
"subscription short UUID."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
"bool",
"subscription_guides",
"Enable admin JSON override",
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PATH",
"string",
"subscription_guides",
"Subscription Page config path",
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
placeholder="data/subpage-config/multiapp.json",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON",
"json",
"subscription_guides",
"Subscription Page config JSON",
(
"Optional admin JSON override. It is applied only when the JSON override "
"switch is enabled."
),
placeholder='{\n "version": "1"\n}',
),
# ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
@@ -177,37 +234,46 @@ SETTINGS_MANIFEST: List[SettingField] = [
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"pricing",
"Порядок методов оплаты",
"Через запятую, например: severpay,freekassa,yookassa,heleket",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"bool",
"pricing",
"payments",
"Показывать описание подписки",
"Текст появится перед выбором срока покупки или продления.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"text",
"pricing",
"payments",
"Описание подписки (RU)",
"Русская версия текста на этапе оплаты.",
subsection="checkout",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
"text",
"pricing",
"payments",
"Описание подписки (EN)",
"Английская версия текста на этапе оплаты.",
subsection="checkout",
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
SettingField(
"STARS_ADMIN_ONLY_ENABLED",
"bool",
"payments",
"Telegram Stars admin-only",
(
"Shows Telegram Stars only to users from ADMIN_IDS. "
"Payment callbacks remain active for admin test payments."
),
subsection="common",
i18n_label_key="admin_settings_provider_admin_only_label",
i18n_description_key="admin_settings_provider_admin_only_description",
),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
@@ -217,10 +283,38 @@ 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", "Триал включён", subsection="trial"),
SettingField(
"TRIAL_DURATION_DAYS",
"int",
"pricing",
"Длительность триала (дней)",
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_LIMIT_GB",
"float",
"pricing",
"Лимит трафика триала (ГБ)",
min=0,
subsection="trial",
),
SettingField(
"TRIAL_TRAFFIC_STRATEGY",
"string",
"pricing",
"Стратегия сброса трафика триала",
subsection="trial",
),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
"pricing",
"Internal Squads для триала",
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
subsection="trial",
),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
@@ -454,6 +548,18 @@ 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() == ""):
return None
@@ -507,18 +613,29 @@ def manifest_payload() -> List[dict]:
same value so existing UIs that only read ``placeholder`` also show the
hint inside the empty input.
"""
from bot.payment_providers import find_manifest_owner, manifest_field_default
from bot.payment_providers import (
find_manifest_owner,
manifest_field_default,
provider_admin_only_pairs,
provider_webhook_metadata,
)
sections_order = {
"general": 1,
"appearance": 2,
"pricing": 3,
"pricing": 11,
"payments": 4,
"trial": 5,
"referral": 6,
"notifications": 7,
"support": 8,
"devices": 9,
"subscription_guides": 10,
}
exclusive_map = {
key: opposite
for public_key, admin_key in provider_admin_only_pairs()
for key, opposite in ((public_key, admin_key), (admin_key, public_key))
}
items: List[dict] = []
for field in aggregated_manifest():
@@ -531,10 +648,12 @@ def manifest_payload() -> List[dict]:
)
default_value: Optional[str] = None
webhook_metadata: Optional[dict] = None
owner = find_manifest_owner(field.key)
if owner is not None:
spec, manifest_field = owner
default_value = manifest_field_default(spec, manifest_field)
webhook_metadata = provider_webhook_metadata(spec)
placeholder = field.placeholder
if not placeholder and default_value:
@@ -559,8 +678,12 @@ def manifest_payload() -> List[dict]:
"optional": field.optional,
"secret": field.secret,
}
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:
item.update(webhook_metadata)
if field.choices:
item["choices"] = [
{
@@ -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,
@@ -0,0 +1,219 @@
<!doctype html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>__PAGE_TITLE__</title>
<style nonce="__NONCE__">
:root {
color-scheme: dark light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #0b1017;
color: #f7fafc;
}
body {
min-height: 100dvh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
main {
width: min(100%, 420px);
display: grid;
gap: 14px;
text-align: center;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #aeb8c5;
font-size: 15px;
line-height: 1.55;
}
.actions {
display: grid;
gap: 10px;
margin-top: 4px;
}
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: #14b86f;
color: #03120b;
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.secondary {
border-color: #2d3847;
background: transparent;
color: #f7fafc;
}
.button[aria-disabled="true"] {
pointer-events: none;
background: #344052;
color: #aeb8c5;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<main>
<h1 id="title"></h1>
<p id="status"></p>
<div class="actions">
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
<button id="close-button" class="button secondary" type="button" hidden></button>
</div>
</main>
<script nonce="__NONCE__">
(() => {
const messages = __MESSAGES_JSON__;
const titleEl = document.getElementById("title");
const statusEl = document.getElementById("status");
const openLink = document.getElementById("open-link");
const closeButton = document.getElementById("close-button");
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const target = String(params.get("url") || "").trim();
const isUnsafe =
!target ||
hasControlChars(target) ||
/^(?:javascript|data|vbscript|https?):/i.test(target);
let attempted = false;
let pageLeft = false;
let state = "opening";
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function text(key, fallback) {
const value = messages && messages[key];
return typeof value === "string" && value ? value : fallback;
}
function tryCloseWindow() {
try {
window.close();
} catch (_error) {
void _error;
}
}
function render(nextState) {
state = nextState;
if (nextState === "unavailable") {
titleEl.textContent = text("unavailableTitle", "App link unavailable");
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
openLink.textContent = text("button", "Open app");
openLink.setAttribute("aria-disabled", "true");
openLink.removeAttribute("href");
closeButton.hidden = true;
return;
}
if (nextState === "done") {
titleEl.textContent = text("doneTitle", "Settings added");
statusEl.textContent = text("doneHint", "You can close this window.");
openLink.textContent = text("retryButton", "Open again");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.textContent = text("closeButton", "Close window");
closeButton.hidden = false;
return;
}
titleEl.textContent = text("title", "Opening app");
statusEl.textContent =
nextState === "manual"
? text("manualHint", "If the app did not open automatically, tap the button below.")
: text("hint", "Opening the app on this device...");
openLink.textContent = text("button", "Open app");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.hidden = true;
}
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
window.setTimeout(tryCloseWindow, 120);
}
function notePageLeft() {
if (!attempted) return;
pageLeft = true;
window.setTimeout(markDone, 900);
}
function openTarget() {
if (isUnsafe) return;
attempted = true;
pageLeft = false;
render("opening");
window.location.href = target;
window.setTimeout(() => {
if (state === "opening" && !pageLeft) render("manual");
}, 1600);
}
if (isUnsafe) {
render("unavailable");
return;
}
openLink.addEventListener("click", (event) => {
event.preventDefault();
openTarget();
});
closeButton.addEventListener("click", () => {
tryCloseWindow();
render("done");
});
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
} else if (pageLeft) {
markDone();
}
});
render("opening");
window.setTimeout(openTarget, 80);
})();
</script>
</body>
</html>
@@ -8,7 +8,20 @@
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" />
<link id="app-favicon" rel="icon" href="data:," sizes="any" />
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<link
id="app-apple-touch-icon"
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="180x180"
href="/apple-touch-icon-precomposed.png"
/>
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
+108 -4
View File
@@ -45,6 +45,7 @@
--surface-sheen-soft: transparent;
--surface-hover: rgba(255, 255, 255, 0.08);
--surface-muted: #0a0a0a;
--surface-subtle: #000000;
--surface-subtle-border: #ffffff;
--overlay-scrim: rgba(0, 0, 0, 0.85);
--nav-bg: #000000;
@@ -119,6 +120,8 @@
/* ---------- Panels / cards ---------- */
.theme-key-ascii .card,
.theme-key-ascii .trial-card-facts span,
.theme-key-ascii .trial-activation-facts div,
.theme-key-ascii .period-card,
.theme-key-ascii .method-card,
.theme-key-ascii .settings-row,
@@ -149,6 +152,12 @@
.theme-key-ascii .ticket-message-avatar,
.theme-key-ascii .ticket-message-bubble,
.theme-key-ascii .ticket-composer,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
.theme-key-ascii .admin-sidebar,
.theme-key-ascii .admin-header,
.theme-key-ascii .admin-card,
@@ -183,6 +192,8 @@
.theme-key-ascii .support-new-ticket-button,
.theme-key-ascii .support-select-trigger,
.theme-key-ascii .support-status-tabs-trigger,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .admin-btn,
.theme-key-ascii .admin-chip,
.theme-key-ascii .admin-tabs-trigger,
@@ -230,6 +241,7 @@
.theme-key-ascii .support-status-tabs-trigger[data-state="active"],
.theme-key-ascii .support-select-item[data-highlighted],
.theme-key-ascii .support-select-item[data-selected],
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .admin-nav-item.active,
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
.theme-key-ascii .admin-revenue-period-btn.is-active {
@@ -259,6 +271,9 @@
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
.theme-key-ascii .admin-mobile-toggle:focus-visible,
.theme-key-ascii .language-select-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger[data-state="open"],
.theme-key-ascii .install-app-button:focus-visible,
.theme-key-ascii .bottom-nav button:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 1px;
@@ -305,12 +320,22 @@
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-ascii .trial-offer-card,
.theme-key-ascii .trial-card-facts span,
.theme-key-ascii .trial-activation-card,
.theme-key-ascii .trial-activation-facts div,
.theme-key-ascii .activation-success-dialog,
.theme-key-ascii .subscription-purchase-description,
.theme-key-ascii .support-create-panel,
.theme-key-ascii .ticket-composer {
background: #000000;
}
.theme-key-ascii .trial-card-head > svg,
.theme-key-ascii .dialog-title-icon {
color: #ffffff;
}
.theme-key-ascii .support-heading-icon,
.theme-key-ascii .support-new-ticket-icon,
.theme-key-ascii .support-empty-state svg,
@@ -412,6 +437,7 @@
}
body:has(.theme-key-ascii) .support-select-content,
body:has(.theme-key-ascii) .install-platform-content,
body:has(.theme-key-ascii) .field-error-tooltip {
border: 1px solid #ffffff;
border-radius: 0;
@@ -420,19 +446,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
box-shadow: 0 0 0 1px #ffffff;
}
body:has(.theme-key-ascii) .support-select-item {
body:has(.theme-key-ascii) .support-select-item,
body:has(.theme-key-ascii) .install-platform-item {
border-radius: 0;
color: #ffffff;
}
body:has(.theme-key-ascii) .support-select-item[data-highlighted],
body:has(.theme-key-ascii) .support-select-item[data-selected] {
body:has(.theme-key-ascii) .support-select-item[data-selected],
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff;
color: #000000 !important;
}
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg,
body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
body:has(.theme-key-ascii) .support-select-item[data-selected] svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important;
stroke: #000000 !important;
}
@@ -695,7 +726,8 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.theme-key-ascii .admin-btn-primary svg.lucide,
.theme-key-ascii .admin-nav-item.active svg.lucide,
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-ascii .install-app-button.active svg.lucide {
color: #000000 !important;
stroke: #000000 !important;
}
@@ -1044,12 +1076,16 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.theme-key-ascii :is(
.card, .dialog-card, .toast,
.btn, .input,
.trial-card-facts span, .trial-activation-facts div,
.period-card, .method-card, .settings-row, .option-row,
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
.topup-carryover-note, .subscription-purchase-description,
.language-select-content, .language-select-item,
.language-select-trigger, .bottom-nav, .bottom-nav button,
.link-button,
.install-platform-trigger, .install-platform-content, .install-platform-item,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-subscription-header-icon, .install-loading,
.support-overview-card, .support-list-card, .support-ticket-card,
.support-conversation-card, .support-new-ticket-button,
.support-create-panel, .support-select-trigger, .support-select-content,
@@ -1083,6 +1119,9 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card,
.admin-select-trigger, .admin-select-content,
.install-platform-trigger, .install-platform-content,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-loading,
.admin-cn-card,
.admin-input, .admin-textarea, .admin-btn,
.admin-nav-item, .admin-tabs-trigger
@@ -1096,6 +1135,71 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
border-radius: 0 !important;
}
/* ---------- Install guide theme surfaces ---------- */
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
body:has(.theme-key-ascii) .install-platform-content {
border: 1px solid #ffffff !important;
border-radius: 0 !important;
background: #000000 !important;
box-shadow: none !important;
}
.theme-key-ascii .install-platform-trigger:hover,
.theme-key-ascii .install-app-button:hover:not(:disabled) {
background: #ffffff !important;
color: #000000 !important;
transform: none !important;
}
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .install-app-button.active:hover:not(:disabled),
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff !important;
color: #000000 !important;
border-color: #ffffff !important;
}
.theme-key-ascii .install-app-button.active svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important;
stroke: #000000 !important;
}
.theme-key-ascii .install-step:hover,
.theme-key-ascii .install-subscription-card:hover {
transform: none !important;
box-shadow: none !important;
}
.theme-key-ascii .install-step-icon,
.theme-key-ascii .install-subscription-header-icon {
border: 1px solid currentColor !important;
background: #000000 !important;
color: #ffffff !important;
}
.theme-key-ascii .install-qr-divider {
color: #ffffff !important;
opacity: 0.72;
}
.theme-key-ascii .install-feature-star.attention-dot {
background: #ffffff !important;
animation: ascii-caret 1s steps(1) infinite !important;
}
.theme-key-ascii .install-loading .ui-spinner {
color: #ffffff;
}
/* ============================================================
* Console-style tables: cell borders, header underline,
* row separator using dashed line.
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 3,
"assets_version": 4,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
@@ -33,6 +33,7 @@
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
--surface-hover: rgba(15, 23, 42, 0.045);
--surface-muted: rgba(15, 23, 42, 0.035);
--surface-subtle: rgba(15, 23, 42, 0.025);
--surface-subtle-border: rgba(15, 23, 42, 0.1);
--overlay-scrim: rgba(15, 23, 42, 0.34);
--nav-bg: rgba(255, 255, 255, 0.88);
@@ -109,6 +110,26 @@
z-index: 1;
}
/* New user-facing activation surfaces */
.theme-key-light .trial-offer-card,
.theme-key-light .trial-activation-card,
.theme-key-light .activation-success-dialog {
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.theme-key-light .trial-card-head > svg,
.theme-key-light .dialog-title-icon {
color: color-mix(in srgb, var(--accent) 54%, #000000);
}
.theme-key-light .trial-card-facts span,
.theme-key-light .trial-activation-facts div {
border-color: rgba(15, 23, 42, 0.12);
background: rgba(15, 23, 42, 0.025);
}
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
.theme-key-light .admin-revenue-svg-frame {
background: #ffffff;
@@ -129,3 +150,57 @@
.theme-key-light .bonus-card-head > svg {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
/* Install guide theme surfaces */
.theme-key-light .install-platform-trigger,
.theme-key-light .install-app-button,
.theme-key-light .install-step,
.theme-key-light .install-subscription-card,
.theme-key-light .install-qr-wrap {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
}
.theme-key-light .install-app-button.active {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.theme-key-light .install-platform-trigger:focus-visible,
.theme-key-light .install-platform-trigger[data-state="open"],
.theme-key-light .install-app-button:focus-visible {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
body:has(.theme-key-light) .install-platform-content {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.14);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
}
body:has(.theme-key-light) .install-platform-item[data-highlighted],
body:has(.theme-key-light) .install-platform-item[data-selected] {
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
}
.theme-key-light .install-step-icon,
.theme-key-light .install-subscription-header-icon {
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
.theme-key-light .install-qr-divider {
color: rgba(15, 23, 42, 0.24);
}
.theme-key-light .install-feature-star.attention-dot {
background: #f59e0b;
}
.theme-key-light .install-loading .ui-spinner {
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 2,
"assets_version": 3,
"tokens": {
"color_scheme": "light"
}
+145 -6
View File
@@ -39,6 +39,7 @@
--surface-sheen-soft: transparent;
--surface-hover: rgba(0, 0, 128, 0.14);
--surface-muted: #c0c0c0;
--surface-subtle: #dfdfdf;
--surface-subtle-border: #808080;
--overlay-scrim: rgba(0, 0, 0, 0.35);
--nav-bg: #c0c0c0;
@@ -138,12 +139,15 @@
.theme-key-windows95 svg.lucide-megaphone,
.theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-message-square-plus,
.theme-key-windows95 svg.lucide-monitor,
.theme-key-windows95 svg.lucide-paintbrush,
.theme-key-windows95 svg.lucide-plus,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-refresh-cw,
.theme-key-windows95 svg.lucide-save,
.theme-key-windows95 svg.lucide-search,
.theme-key-windows95 svg.lucide-send,
.theme-key-windows95 svg.lucide-share-2,
.theme-key-windows95 svg.lucide-settings,
.theme-key-windows95 svg.lucide-shield,
.theme-key-windows95 svg.lucide-sliders,
@@ -273,6 +277,10 @@
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-monitor {
--win95-button-icon: var(--win95-icon-dashboard);
}
.theme-key-windows95 svg.lucide-paintbrush {
--win95-button-icon: var(--win95-icon-paintbrush);
}
@@ -281,6 +289,10 @@
--win95-button-icon: var(--win95-icon-folder);
}
.theme-key-windows95 svg.lucide-qr-code {
--win95-button-icon: var(--win95-icon-key);
}
.theme-key-windows95 svg.lucide-refresh-cw {
--win95-button-icon: var(--win95-icon-refresh);
}
@@ -293,6 +305,10 @@
--win95-button-icon: var(--win95-icon-search);
}
.theme-key-windows95 svg.lucide-share-2 {
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-settings {
--win95-button-icon: var(--win95-icon-settings);
}
@@ -371,12 +387,15 @@
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
@@ -425,12 +444,15 @@
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
@@ -488,7 +510,13 @@
.theme-key-windows95 .support-message-scroll,
.theme-key-windows95 .ticket-message-avatar,
.theme-key-windows95 .ticket-message-bubble,
.theme-key-windows95 .ticket-composer {
.theme-key-windows95 .ticket-composer,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button,
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
@@ -510,6 +538,7 @@
body:has(.theme-key-windows95) .language-select-content,
body:has(.theme-key-windows95) .support-select-content,
body:has(.theme-key-windows95) .install-platform-content,
body:has(.theme-key-windows95) .field-error-tooltip,
body:has(.theme-key-windows95) .admin-select-content {
border-width: 2px;
@@ -525,18 +554,23 @@ body:has(.theme-key-windows95) .admin-select-content {
body:has(.theme-key-windows95) .language-select-item,
body:has(.theme-key-windows95) .support-select-item,
body:has(.theme-key-windows95) .install-platform-item,
body:has(.theme-key-windows95) .admin-select-item {
border-radius: 0 !important;
}
body:has(.theme-key-windows95) .support-select-item[data-highlighted],
body:has(.theme-key-windows95) .support-select-item[data-selected] {
body:has(.theme-key-windows95) .support-select-item[data-selected],
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: #000080;
color: #ffffff !important;
}
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg,
body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
body:has(.theme-key-windows95) .support-select-item[data-selected] svg,
body:has(.theme-key-windows95) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-windows95) .install-platform-item[data-selected] svg {
filter: brightness(0) invert(1);
}
@@ -560,7 +594,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .link-button,
.theme-key-windows95 .support-new-ticket-button,
.theme-key-windows95 .support-select-trigger,
.theme-key-windows95 .support-status-tabs-trigger {
.theme-key-windows95 .support-status-tabs-trigger,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
min-height: 34px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
@@ -625,13 +661,17 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .support-new-ticket-button.active,
.theme-key-windows95 .support-status-tabs-trigger[data-state="active"],
.theme-key-windows95 .support-select-item[data-highlighted],
.theme-key-windows95 .support-select-item[data-selected] {
.theme-key-windows95 .support-select-item[data-selected],
.theme-key-windows95 .install-app-button.active {
background: var(--accent);
color: #ffffff;
}
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-windows95 .trial-offer-card,
.theme-key-windows95 .trial-activation-card,
.theme-key-windows95 .activation-success-dialog,
.theme-key-windows95 .subscription-purchase-description,
.theme-key-windows95 .support-create-panel,
.theme-key-windows95 .ticket-composer,
@@ -639,6 +679,20 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
background: #c0c0c0;
}
.theme-key-windows95 .trial-card-facts span,
.theme-key-windows95 .trial-activation-facts div {
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
background: #dfdfdf;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #ffffff;
}
.theme-key-windows95 .dialog-title-icon {
color: var(--accent);
}
.theme-key-windows95 .support-heading-icon,
.theme-key-windows95 .support-new-ticket-icon,
.theme-key-windows95 .support-empty-state svg,
@@ -718,6 +772,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .ticket-composer:focus-within,
.theme-key-windows95 .support-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .ticket-card:focus-visible,
.theme-key-windows95 .support-status-tabs-trigger:focus-visible {
outline: 1px dotted #000000;
@@ -731,6 +788,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
transition: none;
}
/* ---------- Install guide theme surfaces ---------- */
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
background: #c0c0c0;
color: #000000;
transition: none;
transform: none;
}
.theme-key-windows95 .install-platform-trigger:hover,
.theme-key-windows95 .install-app-button:hover:not(:disabled):not(.active) {
background: #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-app-button.active,
.theme-key-windows95 .install-app-button.active:hover:not(:disabled) {
background: var(--accent);
color: #ffffff;
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
background: #c0c0c0;
transition: none;
}
.theme-key-windows95 .install-step:hover,
.theme-key-windows95 .install-subscription-card:hover {
transform: none;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .install-step-icon,
.theme-key-windows95 .install-subscription-header-icon {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #dfdfdf;
color: var(--accent);
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #808080;
}
body:has(.theme-key-windows95) .install-platform-content {
background: #c0c0c0;
}
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: var(--accent);
color: #ffffff !important;
}
.theme-key-windows95 .install-qr-divider {
color: #404040;
opacity: 1;
}
.theme-key-windows95 .install-feature-star.attention-dot {
background: #ffff00 !important;
border: 1px solid #000000;
box-shadow: 1px 1px 0 #000000;
}
.theme-key-windows95 .install-loading .ui-spinner {
color: var(--accent);
}
.theme-key-windows95 .card-heading-accent,
.theme-key-windows95 .brand-row strong,
.theme-key-windows95 .login-brand h1,
@@ -1059,6 +1195,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .admin-revenue-period-btn:focus-visible,
.theme-key-windows95 .admin-mobile-toggle:focus-visible,
.theme-key-windows95 .language-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .bottom-nav button:focus-visible {
outline: 1px dotted #000000;
outline-offset: -4px;
@@ -1116,6 +1255,7 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .admin-nav-item.active svg.lucide,
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-windows95 .install-app-button.active svg.lucide,
.theme-key-windows95 .admin-header svg.lucide {
filter: brightness(0) invert(1);
}
@@ -1133,7 +1273,6 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 svg.lucide-map,
.theme-key-windows95 svg.lucide-menu,
.theme-key-windows95 svg.lucide-mouse-pointer-click,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-radio,
.theme-key-windows95 svg.lucide-repeat-2,
.theme-key-windows95 svg.lucide-server,
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 9,
"assets_version": 11,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
+6 -1
View File
@@ -49,7 +49,11 @@ from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from bot.utils.text_sanitizer import (
panel_description_from_profile,
sanitize_display_name,
sanitize_username,
)
from config.settings import Settings
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
@@ -59,6 +63,7 @@ logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
APP_ROOT = Path(__file__).resolve().parents[5]
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
+49 -38
View File
@@ -2,7 +2,11 @@
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
from .auth import _hash_email_password
from .auth import (
_hash_email_password,
_notify_account_merged,
_sync_merged_panel_identity_for_user,
)
from .common import _invalidate_webapp_user_caches
@@ -109,7 +113,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
if not merge_notice:
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
final_user_id = int(current_user.user_id)
final_telegram_id = _telegram_id_for_user(current_user)
@@ -122,28 +127,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
await _sync_merged_panel_identity_for_user(
request,
current_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
@@ -178,6 +168,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_email_linked:
try:
from bot.services.notification_service import NotificationService
@@ -345,28 +345,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
await _sync_merged_panel_identity_for_user(
request,
db_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
@@ -401,6 +386,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_telegram_linked and final_telegram_id:
try:
from bot.services.notification_service import NotificationService
@@ -435,6 +430,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
async def me_route(request: web.Request) -> web.Response:
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",
@@ -482,6 +488,11 @@ async def account_language_route(request: web.Request) -> web.Response:
return validation_error
language = _normalize_language(str(language_payload.language or ""))
i18n = request.app.get("i18n")
if i18n and hasattr(i18n, "reload_overrides_from_file"):
i18n.reload_overrides_from_file()
if i18n and language not in getattr(i18n, "locales_data", {}):
return _json_error(400, "unsupported_language", "Unsupported language")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
+5 -1
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .guides import warm_subscription_guides_config
def create_subscription_webapp_application(
@@ -20,10 +21,12 @@ def create_subscription_webapp_application(
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
app["webapp_logo_cache"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock()
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
app["subscription_guides_config_lock"] = asyncio.Lock()
app["webapp_rate_limit_buckets"] = {}
app["webapp_rate_limit_lock"] = asyncio.Lock()
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj)
await warm_subscription_guides_config(app_obj)
async def _shutdown(app_obj: web.Application) -> None:
await _close_shared_http_session()
+267 -33
View File
@@ -9,6 +9,7 @@ from config.webapp_themes_config import (
public_theme_payload,
public_themes_catalog_payload,
)
from bot.middlewares.i18n import locale_language_options
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
@@ -95,9 +96,7 @@ async def theme_css_asset_route(request: web.Request) -> web.Response:
raise web.HTTPNotFound(text="theme_css_not_found") from None
query = getattr(request, "query", {}) or {}
cache_control = (
"public, max-age=31536000, immutable" if query.get("v") else "no-cache"
)
cache_control = "public, max-age=31536000, immutable" if query.get("v") else "no-cache"
try:
stat = path.stat()
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
@@ -204,7 +203,7 @@ 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 ""
@@ -372,6 +371,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(
@@ -834,6 +905,45 @@ def _run_git_command(*args: str) -> str:
return result.stdout.strip()
def _normalize_version_branch(raw_branch: str) -> str:
branch = str(raw_branch or "").strip()
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
if branch.startswith(prefix):
branch = branch[len(prefix) :]
break
if branch == "HEAD":
return ""
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
def _resolve_version_branch() -> str:
for env_name in (
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
"GITHUB_REF_NAME",
"CI_COMMIT_REF_NAME",
):
branch = _normalize_version_branch(os.getenv(env_name, ""))
if branch:
return branch
return _normalize_version_branch(
_run_git_command("branch", "--show-current")
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
)
def _format_app_version(tag: str, sha: str, branch: str) -> str:
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
if tag and sha:
return f"{tag}{branch_suffix}+g{sha}"
if sha:
return f"dev{branch_suffix}+g{sha}"
if tag:
return f"{tag}{branch_suffix}"
return f"dev{branch_suffix}+unknown"
def _resolve_app_version() -> str:
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
@@ -855,21 +965,8 @@ def _resolve_app_version() -> str:
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
dirty = bool(_run_git_command("status", "--porcelain"))
if tag and sha:
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
if commits_since_tag and commits_since_tag != "0":
version = f"{tag}+{commits_since_tag}.g{sha}"
else:
version = tag
elif sha:
version = f"dev+g{sha}"
else:
version = "dev+unknown"
if dirty:
version = f"{version}-dirty"
branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
_APP_VERSION_CACHE = version
return version
@@ -974,6 +1071,30 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
WEBAPP_BOOTSTRAP_I18N_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:
@@ -1037,7 +1158,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,
@@ -1068,6 +1192,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,
@@ -1084,6 +1212,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(
@@ -1097,6 +1227,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:
@@ -1118,6 +1314,7 @@ async def index_route(request: web.Request) -> web.Response:
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
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(
@@ -1140,12 +1337,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
@@ -1167,6 +1358,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,
@@ -1372,10 +1607,10 @@ def _resolve_webapp_js_asset_name() -> str:
def _resolve_webapp_admin_js_asset_name() -> str:
return _resolve_hashed_js_asset_name(
kind="admin-js",
base_name="subscription_webapp_admin",
)
# The admin bundle is lazy-loaded from the already running Mini App. In
# deployments where nginx serves static files in front of aiohttp, stale
# hashed admin filenames can 404 even though the runtime build asset exists.
return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
@@ -1405,10 +1640,9 @@ def _resolve_webapp_css_asset_name() -> str:
def _resolve_webapp_admin_css_asset_name() -> str:
return _resolve_hashed_css_asset_name(
kind="admin-css",
base_name="subscription_webapp_admin",
)
# Keep the lazy-loaded admin stylesheet on the stable build filename for
# the same reason as the JS bundle above.
return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
+163 -22
View File
@@ -339,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
redirect_path = "/settings" if purpose == "link" else "/"
async_session_factory: sessionmaker = request.app["async_session_factory"]
final_user_id: Optional[int] = None
source_user_id_for_cache: Optional[int] = None
linked_user_for_panel: Optional[User] = None
link_source_panel_uuid: Optional[str] = None
link_final_panel_uuid: Optional[str] = None
link_merge_notice: Optional[Dict[str, Any]] = None
async with async_session_factory() as session:
try:
if purpose == "link":
current_user_id = int(state.get("user_id") or 0)
source_user_id_for_cache = current_user_id
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
link_source_panel_uuid = (
current_user_before_link.panel_user_uuid if current_user_before_link else None
)
db_user = await _link_telegram_to_user(
request,
session,
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
telegram_user=telegram_user,
settings=settings,
)
if int(db_user.user_id) != current_user_id:
link_final_panel_uuid = db_user.panel_user_uuid
link_merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=current_user_id,
source_panel_uuid=link_source_panel_uuid,
settings=settings,
)
linked_user_for_panel = db_user
else:
db_user = await _ensure_user_from_telegram(
session,
@@ -388,6 +408,34 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
raise redirect(redirect_path, "failed")
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
await _invalidate_webapp_user_caches(
settings,
source_user_id_for_cache,
final_user_id,
include_devices=True,
)
if purpose == "link" and link_merge_notice and linked_user_for_panel:
merge_end_date_raw = link_merge_notice.get("final_end_date")
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
await _sync_merged_panel_identity_for_user(
request,
linked_user_for_panel,
source_panel_uuid=link_source_panel_uuid,
final_panel_uuid=link_final_panel_uuid,
expire_at=merge_end_date,
)
await _notify_account_merged(
request,
settings,
merge_notice=link_merge_notice,
email=linked_user_for_panel.email,
telegram_id=_telegram_id_for_user(linked_user_for_panel),
username=linked_user_for_panel.username,
first_name=linked_user_for_panel.first_name,
)
token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response)
@@ -965,13 +1013,19 @@ def _telegram_id_for_user(user: User) -> Optional[int]:
def _panel_description_for_user(user: User) -> str:
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]:
raw_value = telegram_user.get("photo_url")
if not raw_value:
return None
value = str(raw_value).strip()
return value or None
async def _sync_panel_identity_for_user(
@@ -986,23 +1040,33 @@ 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
if user.email:
payload["email"] = user.email
if expire_at is not None:
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if expire_at > datetime.now(timezone.utc):
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(
@@ -1013,6 +1077,53 @@ async def _sync_panel_identity_for_user(
return False
async def _delete_merged_source_panel_user(
request: web.Request,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
) -> bool:
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
return True
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return False
try:
return bool(
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
return False
async def _sync_merged_panel_identity_for_user(
request: web.Request,
user: User,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
expire_at: Optional[datetime] = None,
) -> bool:
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
# before patching the surviving one so merged accounts can accept both IDs.
await _delete_merged_source_panel_user(
request,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
)
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
async def _build_account_merge_notice(
session: AsyncSession,
*,
@@ -1050,16 +1161,50 @@ async def _build_account_merge_notice(
}
async def _notify_account_merged(
request: web.Request,
settings: Settings,
*,
merge_notice: Optional[Dict[str, Any]],
email: Optional[str],
telegram_id: Optional[int],
username: Optional[str],
first_name: Optional[str],
) -> None:
if not merge_notice:
return
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_merged(
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
email=email,
telegram_id=telegram_id,
username=username,
first_name=first_name,
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
)
except Exception:
logger.exception("Failed to send account merged notification")
def _apply_telegram_profile_to_user(
user: User,
telegram_user: Dict[str, Any],
settings: Settings,
) -> None:
language_code = (
language_code = _normalize_language(
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
)
if language_code not in {"ru", "en"}:
language_code = user.language_code or settings.DEFAULT_LANGUAGE
user.telegram_id = int(telegram_user["id"])
user.username = sanitize_username(telegram_user.get("username"))
@@ -1102,17 +1247,14 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
language_code = (
language_code = _normalize_language(
telegram_user.get("language_code")
or current_user.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,
{
@@ -1134,7 +1276,6 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
@@ -1259,9 +1400,9 @@ async def _ensure_user_from_telegram(
referral_param: Optional[str] = None,
) -> 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
language_code = _normalize_language(
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
)
update_data = {
"telegram_id": user_id,
+312 -30
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
async def apply_promo_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
@@ -67,9 +69,14 @@ async def create_payment_route(request: web.Request) -> web.Response:
traffic_mode = bool(settings.traffic_sale_mode)
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,6 +84,8 @@ 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")
if tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
try:
device_count = int(
float(
@@ -87,23 +96,10 @@ async def create_payment_route(request: web.Request) -> web.Response:
)
except (TypeError, ValueError):
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 +247,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 +294,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 +345,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 +363,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 +489,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 +568,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 +612,243 @@ 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"))
packages = tariff.hwid_device_packages
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(rub_counts | stars_counts):
rub_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="rub",
)
if count in rub_counts
else None
)
stars_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="stars",
)
if count in stars_counts
else None
)
if not rub_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan,
"months": count,
"device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": (
(rub_quote or stars_quote)["valid_from"].isoformat()
if (rub_quote or stars_quote).get("valid_from")
else None
),
"valid_until": (
(rub_quote or stars_quote)["valid_until"].isoformat()
if (rub_quote or stars_quote).get("valid_until")
else None
),
"proration_ratio": float((rub_quote or stars_quote).get("proration_ratio") or 0),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"])
plans.append(plan)
return web.json_response(
{
"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": active.get("extra_hwid_devices_valid_until")
if active
else None,
"extra_hwid_devices_valid_until_text": active.get(
"extra_hwid_devices_valid_until_text"
)
if active
else None,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
else 0,
"plans": plans,
}
)
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 +861,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 +890,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 +909,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 +928,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 +948,15 @@ 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,
)
)
+63 -4
View File
@@ -2,13 +2,31 @@ from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional
from bot.infra.redis import cache_delete, redis_key
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
from bot.utils.ttl_cache import AsyncTTLCache
from 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,13 +66,32 @@ 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)
def invalidate_all_local_webapp_user_payloads(
settings: Settings,
namespace: Optional[str] = None,
*,
include_devices: Optional[bool] = None,
) -> None:
if include_devices is not None:
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
elif namespace is not None:
namespaces = {namespace}
else:
namespaces = None
for (settings_id, cache_namespace, _ttl), cache in tuple(_WEBAPP_USER_PAYLOAD_CACHES.items()):
if settings_id != id(settings):
continue
if namespaces is not None and cache_namespace not in namespaces:
continue
cache.invalidate()
async def invalidate_webapp_user_caches(
settings: Settings,
*user_ids: Optional[int],
@@ -79,3 +116,25 @@ async def invalidate_webapp_user_caches(
invalidate_local_webapp_user_payload(settings, "devices", user_id)
if keys:
await cache_delete(settings, *keys)
async def invalidate_all_webapp_user_payloads(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
for namespace in _payload_namespaces(include_devices):
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern)
except Exception:
continue
async def invalidate_all_webapp_user_caches(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
+8 -22
View File
@@ -2,7 +2,11 @@
from ._runtime import * # noqa: F403,F405
from 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:
+1 -2
View File
@@ -86,8 +86,7 @@ async def _load_devices_payload(
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
"devices": [
_serialize_device(device, index)
for index, device in enumerate(devices, start=1)
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
],
},
}
+287
View File
@@ -0,0 +1,287 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_status,
validate_panel_subscription_guides_config,
)
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS = 30
async def warm_subscription_guides_config(app: web.Application) -> None:
try:
await _subscription_guides_status_shared(app)
except Exception as exc:
logger.warning("Failed to warm subscription guides config: %s", exc)
async def subscription_guides_route(request: web.Request) -> web.Response:
_require_user_id(request)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def public_subscription_guides_route(request: web.Request) -> web.Response:
share_token = subscription_dal.normalize_install_share_token(
request.match_info.get("share_token")
)
if not share_token:
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
subscription = await _public_subscription_payload(request, share_token)
if not subscription.get("active"):
return web.json_response(
{
"ok": False,
"enabled": False,
"config": None,
"source": None,
"subscription": subscription,
"error": "subscription_unavailable",
},
status=404,
)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
"subscription": subscription,
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def _subscription_guides_status_shared(app: web.Application) -> Dict[str, Any]:
settings: Settings = app["settings"]
cache = app.setdefault("subscription_guides_config_cache", {})
lock: asyncio.Lock = app.setdefault("subscription_guides_config_lock", asyncio.Lock())
fingerprint = _subscription_guides_settings_fingerprint(settings)
now = time.monotonic()
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
async with lock:
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
status = await _load_subscription_guides_status(app, settings)
cache["fingerprint"] = fingerprint
cache["status"] = status
cache["ts"] = time.monotonic()
return status
async def _load_subscription_guides_status(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return {"enabled": False, "config": None, "source": None, "error": None}
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
json_override_enabled = bool(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
)
if admin_json and json_override_enabled:
return subscription_guides_status(settings)
if bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)):
panel_status = await _subscription_guides_status_from_panel_config(app, settings)
if panel_status.get("enabled"):
return panel_status
return subscription_guides_status(settings)
async def _subscription_guides_status_from_panel_config(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
panel_service = _panel_service_from_app(app)
if panel_service is None:
return {
"enabled": False,
"config": None,
"source": "panel",
"error": "Panel service is unavailable",
}
try:
config_uuid = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or "").strip()
if not config_uuid:
config_uuid = await _default_panel_subscription_page_config_uuid(panel_service)
config_uuid = config_uuid or PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
detail = await panel_service.get_subscription_page_config_by_uuid(config_uuid)
if detail is None and config_uuid != PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
detail = await panel_service.get_subscription_page_config_by_uuid(
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
)
if detail is None:
raise SubscriptionGuidesConfigError(
f"Panel subscription page config {config_uuid} is unavailable"
)
config = validate_panel_subscription_guides_config(detail)
except (SubscriptionGuidesConfigError, Exception) as exc:
logger.warning("Failed to load subscription guides config from Remnawave Panel: %s", exc)
return {"enabled": False, "config": None, "source": "panel", "error": str(exc)}
return {"enabled": True, "config": config, "source": "panel", "error": None}
async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> str:
get_list = getattr(panel_service, "get_subscription_page_config_list", None)
if not callable(get_list):
return ""
payload = await get_list()
configs = (payload or {}).get("configs")
if not isinstance(configs, list):
return ""
candidates: list[Dict[str, Any]] = [item for item in configs if isinstance(item, dict)]
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid == PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
return uuid
candidates.sort(key=lambda item: int(item.get("viewPosition") or 0))
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid:
return uuid
return ""
async def _public_subscription_payload(
request: web.Request,
share_token: str,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
panel_service = _panel_service_from_app(request.app)
raw_link = ""
username = ""
resolved_short_uuid = ""
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
local_sub = await subscription_dal.get_subscription_by_install_share_token(
session,
share_token,
)
if (
local_sub
and getattr(local_sub, "panel_user_uuid", None)
and _local_subscription_is_publicly_active(local_sub)
and panel_service
):
panel_user = await panel_service.get_user_by_uuid(local_sub.panel_user_uuid)
if panel_user:
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
username = str(panel_user.get("username") or "").strip()
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
display_link, connect_url = await prepare_config_links(settings, raw_link)
return {
"active": bool(display_link),
"config_link": display_link,
"connect_url": connect_url or display_link,
"panel_short_uuid": resolved_short_uuid or None,
"install_share_token": share_token,
"username": username,
"share_url": _public_install_url(request, share_token),
}
def _panel_service_from_app(app: web.Application) -> Any:
subscription_service: Optional[SubscriptionService] = app.get("subscription_service")
panel_service = (
getattr(subscription_service, "panel_service", None) if subscription_service else None
)
return panel_service or app.get("panel_service")
def _subscription_guides_settings_fingerprint(settings: Settings) -> Tuple[Any, ...]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "")
return (
bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", "") or ""),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or ""),
hashlib.sha256(admin_json.encode("utf-8")).hexdigest(),
str(getattr(settings, "PANEL_API_URL", "") or ""),
bool(getattr(settings, "PANEL_API_KEY", "") or ""),
)
def _local_subscription_is_publicly_active(subscription: Any) -> bool:
end_date = getattr(subscription, "end_date", None)
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
return bool(
getattr(subscription, "is_active", False)
and end_date
and end_date > datetime.now(timezone.utc)
)
def _public_install_url(request: web.Request, share_token: str) -> str:
settings: Settings = request.app["settings"]
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
headers = request.headers
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
user_agent = headers.get(
"User-Agent",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
)
return {
"host": host,
"x-forwarded-host": host,
"x-forwarded-proto": proto,
"user-agent": user_agent,
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"upgrade-insecure-requests": "1",
}
+17 -1
View File
@@ -6,6 +6,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/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,22 @@ 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}"
),
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_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
@@ -60,6 +71,11 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/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)
+118 -14
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload
@@ -52,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if db_user.panel_user_uuid
else None
)
install_share_token = (
await subscription_dal.ensure_install_share_token(session, local_sub)
if active and local_sub
else None
)
trial_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
@@ -82,7 +88,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"language_code": lang,
"is_admin": is_admin,
},
"subscription": _serialize_subscription(settings, active, local_sub, lang),
"subscription": _serialize_subscription(
request,
settings,
active,
local_sub,
lang,
install_share_token=install_share_token,
),
"referral": {
"code": referral_code,
"bot_link": referral_link,
@@ -105,7 +118,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app, lang),
"payment_methods": _serialize_payment_methods(
settings,
request.app,
lang,
is_admin=is_admin,
),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
@@ -132,6 +150,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"subscription_purchase_description": settings.subscription_purchase_description(lang),
"subscription_guides_enabled": subscription_guides_available(settings),
"email_auth_enabled": settings.email_auth_configured,
},
}
@@ -179,11 +198,26 @@ def _build_webapp_referral_link(
def _serialize_subscription(
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
request_or_settings: Any,
settings_or_active: Any,
active_or_local_sub: Optional[Any] = None,
local_sub_or_lang: Optional[Any] = None,
lang: Optional[str] = None,
*,
install_share_token: Optional[str] = None,
) -> Dict[str, Any]:
if lang is None:
request = None
settings = request_or_settings
active = settings_or_active
local_sub = active_or_local_sub
lang = str(local_sub_or_lang or "ru")
else:
request = request_or_settings
settings = settings_or_active
active = active_or_local_sub
local_sub = local_sub_or_lang
if not active:
return {
"active": False,
@@ -192,6 +226,9 @@ def _serialize_subscription(
"days_left": 0,
"config_link": None,
"connect_url": None,
"panel_short_uuid": None,
"install_share_token": None,
"install_share_url": None,
}
end_date = active.get("end_date")
@@ -222,7 +259,8 @@ def _serialize_subscription(
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
# max_devices == 0 means unlimited — top-up is pointless in that case.
can_topup_devices = bool(
tariff.has_hwid_device_packages()
tariff.billing_model == "period"
and tariff.has_hwid_device_packages()
and _coerce_int_or_none(active.get("max_devices")) != 0
)
except Exception:
@@ -231,6 +269,23 @@ def _serialize_subscription(
can_topup_traffic = False
can_topup_devices = False
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
share_token = str(
install_share_token or getattr(local_sub, "install_share_token", "") or ""
).strip()
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until")
if extra_hwid_valid_until and extra_hwid_valid_until.tzinfo is None:
extra_hwid_valid_until = extra_hwid_valid_until.replace(tzinfo=timezone.utc)
extra_hwid_next_valid_from = active.get("extra_hwid_devices_next_valid_from")
if extra_hwid_next_valid_from and extra_hwid_next_valid_from.tzinfo is None:
extra_hwid_next_valid_from = extra_hwid_next_valid_from.replace(tzinfo=timezone.utc)
extra_hwid_count = _coerce_int_or_none(active.get("extra_hwid_devices")) or 0
device_topup_renewal_available = bool(
extra_hwid_count > 0
and extra_hwid_valid_until
and end_date
and extra_hwid_valid_until < end_date
)
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
@@ -240,6 +295,9 @@ def _serialize_subscription(
"remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"panel_short_uuid": panel_short_uuid or None,
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
"install_share_url": _build_install_share_link(request, settings, share_token),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
@@ -278,12 +336,46 @@ def _serialize_subscription(
"is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")),
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
"extra_hwid_devices": extra_hwid_count,
"extra_hwid_devices_valid_until": extra_hwid_valid_until.isoformat()
if extra_hwid_valid_until
else None,
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until.strftime("%d.%m.%Y %H:%M")
if extra_hwid_valid_until
else None,
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from.isoformat()
if extra_hwid_next_valid_from
else None,
"device_topup_renewal_available": device_topup_renewal_available,
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
def _build_install_share_link(
request: Optional[web.Request],
settings: Settings,
share_token: str,
) -> Optional[str]:
share_token = subscription_dal.normalize_install_share_token(share_token)
if not share_token or request is None:
return None
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _serialize_plans(
settings: Settings,
lang: str,
@@ -310,7 +402,9 @@ def _serialize_plans(
tariff,
tariff.hwid_device_packages,
lang,
),
)
if tariff.billing_model == "period"
else [],
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
@@ -517,10 +611,14 @@ def _serialize_tariff_change_target(
"mode": "recalc_days",
"kind": "free",
"title": "recalc_days",
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
}
)
"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)
if paid_diff > 0:
actions.append(
@@ -540,6 +638,10 @@ 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 +684,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 +693,7 @@ def _serialize_payment_methods(
for method in settings.payment_methods_order:
method = method.lower()
spec = get_provider_spec(method)
if spec and spec.is_visible(settings, app):
if spec and spec.is_visible_for_user(settings, app, is_admin=is_admin):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
-1
View File
@@ -156,7 +156,6 @@ async def admin_panel_actions_callback_handler(
panel_service=panel_service,
session=session,
)
await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data)
elif action == "view_payments":
+52 -21
View File
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
@@ -25,6 +26,44 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _user_email(user: Optional[User]) -> str:
return str(getattr(user, "email", None) or "").strip()
def _format_user_with_email(
*,
first_name: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
fallback: str = "",
) -> str:
parts = []
if first_name:
parts.append(first_name)
if username:
parts.append(f"(@{username})")
display = " ".join(parts).strip() or str(fallback or "").strip()
clean_email = str(email or "").strip()
if clean_email:
display = (
f"{display} · {clean_email}" if display and display != clean_email else clean_email
)
return hd.quote(display)
def _format_log_entry_user(log_entry: MessageLog, translate) -> str:
fallback = (
translate("system_or_unknown_user") if not log_entry.user_id else f"ID: {log_entry.user_id}"
)
return _format_user_with_email(
first_name=log_entry.telegram_first_name,
username=log_entry.telegram_username,
email=_user_email(getattr(log_entry, "author_user", None)),
fallback=fallback,
)
async def display_logs_menu(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
@@ -94,19 +133,7 @@ async def _display_formatted_logs(
log_entries_text = []
for log_entry_model in logs:
user_display_parts = []
if log_entry_model.telegram_first_name:
user_display_parts.append(log_entry_model.telegram_first_name)
if log_entry_model.telegram_username:
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
user_display = " ".join(user_display_parts).strip()
if not user_display:
user_display = (
_("system_or_unknown_user")
if not log_entry_model.user_id
else f"ID: {log_entry_model.user_id}"
)
user_display = _format_log_entry_user(log_entry_model, _)
user_id_display = (
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
@@ -270,10 +297,11 @@ async def process_user_id_for_logs_handler(
return
target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
user_display_name = _format_user_with_email(
first_name=user_model_for_logs.first_name,
username=user_model_for_logs.username,
email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
)
logs_models = await message_log_dal.get_user_message_logs(
@@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler(
await callback.answer()
return
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
user_display_name = _format_user_with_email(
first_name=user_model_for_logs.first_name,
username=user_model_for_logs.username,
email=user_model_for_logs.email,
fallback=f"ID {target_user_id}",
)
logs_models = await message_log_dal.get_user_message_logs(
@@ -392,6 +421,7 @@ async def export_logs_csv_handler(
_("admin_csv_header_user_id"),
_("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"),
_("admin_csv_header_email"),
_("admin_csv_header_event_type"),
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"),
@@ -417,6 +447,7 @@ async def export_logs_csv_handler(
log.user_id or "",
log.telegram_username or "",
log.telegram_first_name or "",
_user_email(getattr(log, "author_user", None)),
log.event_type or "",
content_clean,
"Yes" if log.is_admin_event else "No",
File diff suppressed because it is too large Load Diff
@@ -1264,8 +1264,13 @@ async def process_delete_user_confirmation_handler(
return
try:
if user_model.panel_user_uuid:
panel_deleted = await panel_service.delete_user_from_panel(user_model.panel_user_uuid)
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
session,
target_user_id,
user=user_model,
)
for panel_uuid in panel_user_uuids:
panel_deleted = await panel_service.delete_user_from_panel(panel_uuid)
if not panel_deleted:
await message.answer(
_(
+25
View File
@@ -16,7 +16,12 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu
@@ -133,10 +138,12 @@ async def process_promo_code_input(
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
db_user = await user_dal.get_user_by_id(session, user.id)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
email=getattr(db_user, "email", None) if db_user else None,
suspicious_input=code_input,
)
except Exception as e:
@@ -160,12 +167,30 @@ async def process_promo_code_input(
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
install_links = await ensure_user_install_guide_links(session, settings, user.id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
response_to_user_text = append_install_share_link_text(
response_to_user_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user.id,
)
install_share_url = None
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
)
else:
await session.commit()
+36 -2
View File
@@ -17,12 +17,16 @@ from bot.keyboards.inline.user_keyboards import (
get_language_selection_keyboard,
get_main_menu_inline_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
from bot.services.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
@@ -715,6 +719,23 @@ async def start_command_handler(
),
config_link=config_link_text,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
promo_success_text = append_install_share_link_text(
promo_success_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user_id,
)
install_share_url = None
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
@@ -726,6 +747,7 @@ async def start_command_handler(
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
),
parse_mode="HTML",
)
@@ -891,7 +913,12 @@ async def select_language_callback_handler(
try:
lang_payload = callback.data.split("_", 2)[2]
lang_code, _, return_target = lang_payload.partition(":")
raw_lang_code, _, return_target = lang_payload.partition(":")
lang_code = normalize_locale_language_code(
raw_lang_code,
set(i18n.locales_data.keys()),
prefer_known_base=True,
)
except IndexError:
await safe_answer_callback(
callback,
@@ -899,6 +926,13 @@ async def select_language_callback_handler(
show_alert=True,
)
return
if lang_code not in i18n.locales_data:
await safe_answer_callback(
callback,
"Unsupported language.",
show_alert=True,
)
return
user_id = callback.from_user.id
try:
+110 -11
View File
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -326,6 +330,7 @@ async def select_tariff_period_callback(
settings,
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer()
@@ -378,6 +383,7 @@ async def select_tariff_package_callback(
settings,
sale_mode=sale_mode,
back_callback=back_callback,
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer()
@@ -490,6 +496,7 @@ async def select_tariff_premium_package_callback(
settings,
sale_mode=f"premium_topup@{tariff.key}",
back_callback="tariff_topup:list",
user_id=callback.from_user.id,
)
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
await callback.answer()
@@ -518,10 +525,14 @@ async def hwid_devices_list_callback(
await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
return
tariff = config.require(active["tariff_key"])
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard(
tariff,
packages,
@@ -529,14 +540,31 @@ 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 +573,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 +590,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 +700,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 +789,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 +897,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 +1085,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 +1212,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 +1429,8 @@ async def my_devices_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if (
tariff_for_devices.hwid_device_packages
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
devices_kb.append(
@@ -62,7 +62,12 @@ async def select_subscription_period_callback_handler(
from bot.payment_providers import iter_provider_specs
currency_methods_enabled = any(
spec.price_source != "stars" and spec.is_enabled(settings)
spec.price_source != "stars"
and spec.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_configured=False,
)
for spec in iter_provider_specs()
)
if currency_methods_enabled:
@@ -104,6 +109,7 @@ async def select_subscription_period_callback_handler(
"traffic" if traffic_mode else "subscription", callback_context
),
back_callback=subscription_options_callback(callback_context),
user_id=callback.from_user.id,
)
try:
+38 -2
View File
@@ -14,7 +14,12 @@ from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings
from db.dal import user_dal
from .start import send_main_menu
@@ -74,6 +79,7 @@ async def request_trial_confirmation_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -104,9 +110,23 @@ async def request_trial_confirmation_handler(
traffic_gb=traffic_display,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
db_user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_trial_activation(
user_id,
end_date_obj,
username=db_user.username if db_user else callback.from_user.username,
email=getattr(db_user, "email", None) if db_user else None,
)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
@@ -139,6 +159,7 @@ async def request_trial_confirmation_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -214,6 +235,7 @@ async def confirm_activate_trial_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -243,6 +265,13 @@ async def confirm_activate_trial_handler(
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
@@ -266,6 +295,7 @@ async def confirm_activate_trial_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -293,7 +323,13 @@ async def confirm_activate_trial_handler(
if activation_result and activation_result.get("activated") and end_date_obj:
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
db_user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_trial_activation(
user_id,
end_date_obj,
username=db_user.username if db_user else callback.from_user.username,
email=getattr(db_user, "email", None) if db_user else None,
)
try:
from db.dal import ad_dal as _ad_dal
+71 -19
View File
@@ -3,6 +3,9 @@ from typing import Any, Dict, List, Optional, Tuple
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import locale_language_options
from bot.utils.install_links import bot_install_guide_url
from bot.utils.mini_app_url import subscription_mini_app_trial_url
from config.settings import Settings
BOT_MENU_CONTEXT = "bot"
@@ -54,8 +57,9 @@ def payment_methods_back_callback(
return f"tariff:package:{tariff_key}:{value}"
if sale_base == "premium_topup" and tariff_key:
return f"tariff:premium_package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices"} and tariff_key:
return f"hwid_devices:package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package"
return f"hwid_devices:{action}:{tariff_key}:{value}"
if sale_base == "tariff_upgrade" and tariff_key:
amount = str(price) if price is not None else value
return f"tariff_change:pay:{tariff_key}:{amount}"
@@ -76,17 +80,34 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str:
return f"tariff:select:{tariff_key}{context_suffix}"
if sale_base in {"topup", "premium_topup"}:
return "tariff_topup:list"
if sale_base in {"hwid_device", "hwid_devices"}:
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return "hwid_devices:list"
return subscription_options_callback(context)
def _trial_activation_button(lang: str, i18n_instance, settings: Settings) -> InlineKeyboardButton:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
if settings.SUBSCRIPTION_MINI_APP_URL:
trial_url = subscription_mini_app_trial_url(settings) or settings.SUBSCRIPTION_MINI_APP_URL
return InlineKeyboardButton(
text=_(key="menu_activate_trial_button"),
web_app=WebAppInfo(url=trial_url),
)
return InlineKeyboardButton(
text=_(key="menu_activate_trial_button"),
callback_data="main_action:request_trial",
)
def get_main_menu_inline_keyboard(
lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED:
builder.row(_trial_activation_button(lang, i18n_instance, settings))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
@@ -129,11 +150,7 @@ def get_bot_interface_inline_keyboard(
builder = InlineKeyboardBuilder()
if show_trial_button and settings.TRIAL_ENABLED:
builder.row(
InlineKeyboardButton(
text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial"
)
)
builder.row(_trial_activation_button(lang, i18n_instance, settings))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
@@ -231,14 +248,18 @@ def get_language_selection_keyboard(
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
builder = InlineKeyboardBuilder()
builder.button(
text=f"🇬🇧 English {'' if current_lang == 'en' else ''}",
callback_data=f"set_lang_en{callback_suffix}",
)
builder.button(
text=f"🇷🇺 Русский {'' if current_lang == 'ru' else ''}",
callback_data=f"set_lang_ru{callback_suffix}",
)
if hasattr(i18n_instance, "language_options"):
languages = i18n_instance.language_options()
else:
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
for language in languages:
lang_code = language["code"]
checked = "" if current_lang == lang_code else ""
builder.button(
text=f"{language['flag']} {language['label']}{checked}",
callback_data=f"set_lang_{lang_code}{callback_suffix}",
)
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
builder.adjust(1)
return builder.as_markup()
@@ -405,6 +426,7 @@ def get_hwid_device_packages_keyboard(
i18n_instance,
settings: Settings,
back_callback: str = "main_action:my_subscription",
renewal: bool = False,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -417,7 +439,10 @@ def get_hwid_device_packages_keyboard(
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
),
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
f"{tariff.key}:{package.count}"
),
)
)
builder.row(
@@ -436,6 +461,8 @@ def get_payment_method_keyboard(
settings: Settings,
sale_mode: str = "subscription",
back_callback: Optional[str] = None,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
@@ -454,7 +481,16 @@ def get_payment_method_keyboard(
for method in settings.payment_methods_order:
spec = get_provider_spec(method)
if not spec or not spec.callback_prefix or not spec.is_enabled(settings):
if (
not spec
or not spec.callback_prefix
or not spec.is_available_to_user(
settings,
user_id=user_id,
is_admin=is_admin,
require_configured=False,
)
):
continue
callback_data = spec.callback_data(
value=value_str,
@@ -689,13 +725,29 @@ def get_connect_and_main_keyboard(
config_link: Optional[str],
connect_button_url: Optional[str] = None,
preserve_message: bool = False,
install_share_url: Optional[str] = None,
) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
install_url = bot_install_guide_url(settings)
button_target = connect_button_url or config_link
if button_target:
if install_url:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
web_app=WebAppInfo(url=install_url),
)
)
if install_share_url:
builder.row(
InlineKeyboardButton(
text=_("install_guide_share_button"),
url=install_share_url,
)
)
elif button_target:
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
+7 -39
View File
@@ -5,20 +5,17 @@ from typing import Awaitable, Callable, Optional
from aiogram import Bot, Dispatcher
from aiogram.exceptions import TelegramNetworkError
from aiogram.types import BotCommand, MenuButtonDefault, MenuButtonWebApp, WebAppInfo
from sqlalchemy.orm import sessionmaker
from bot.app.controllers.dispatcher_controller import build_dispatcher
from bot.app.factories.build_services import build_core_services
from bot.app.web.web_server import build_and_start_web_app
from bot.handlers.admin.sync_admin import perform_sync
from bot.infra.redis import close_redis
from bot.middlewares.i18n import JsonI18n
from bot.routers import build_root_router
from bot.services.panel_api_service import PanelApiService
from bot.services.settings_override_service import load_overrides_from_db
from bot.services.locale_override_service import load_locale_overrides
from bot.utils.message_queue import init_queue_manager
from config.settings import Settings
from db.database_setup import init_db_connection
from db.database_setup import init_db, init_db_connection
TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0
@@ -156,6 +153,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
if settings.SUBSCRIPTION_MINI_APP_URL:
async def _configure_mini_app_menu() -> None:
menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE,
@@ -169,6 +167,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
logging.info("STARTUP: Mini app domain registered and default menu button restored.")
await _run_telegram_startup_step(
"registering mini app menu button",
_configure_mini_app_menu,
@@ -186,6 +185,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
await bot.set_my_commands(bot_commands)
logging.info("STARTUP: bot command descriptions set.")
await _run_telegram_startup_step(
"setting bot commands",
_configure_bot_commands,
@@ -200,42 +200,9 @@ async def on_startup_configured(dispatcher: Dispatcher):
except Exception:
logging.exception("STARTUP: Failed to initialize message queue manager.")
# Automatic sync on startup — runs in background so the dispatcher can
# start serving Telegram webhooks immediately even if the panel is slow.
# perform_sync is single-flight, so concurrent admin-triggered runs will
# be skipped while this one is in progress.
logging.info("STARTUP: Bot on_startup_configured completed.")
async def _background_startup_sync(
*,
panel_service: PanelApiService,
session_factory: sessionmaker,
settings: Settings,
i18n_instance: JsonI18n,
) -> None:
try:
async with session_factory() as session:
sync_result = await perform_sync(
panel_service=panel_service,
session=session,
settings=settings,
i18n_instance=i18n_instance,
)
status = sync_result.get("status")
details = sync_result.get("details", "N/A")
if status == "completed":
logging.info(f"STARTUP: Background sync completed successfully. Details: {details}")
elif status == "skipped":
logging.info(f"STARTUP: Background sync skipped: {details}")
else:
logging.warning(
f"STARTUP: Background sync finished with status '{status}'. Details: {details}"
)
except Exception:
logging.exception("STARTUP: Background sync failed.")
async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
@@ -299,9 +266,10 @@ async def run_bot(settings_param: Settings):
if local_async_session_factory is None:
logging.critical("Failed to initialize database connection and session factory. Exiting.")
return
await load_overrides_from_db(settings_param, local_async_session_factory)
await init_db(settings_param, local_async_session_factory)
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
i18n_instance = extra["i18n_instance"]
await load_locale_overrides(i18n_instance, local_async_session_factory)
# Get bot username for YooKassa default return URL if needed
actual_bot_username = "your_bot_username"
+439 -9
View File
@@ -1,7 +1,10 @@
import json
import logging
import os
from typing import Any, Awaitable, Callable, Dict, Optional
import re
import time
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
from aiogram import BaseMiddleware
from aiogram.types import Update, User
@@ -10,14 +13,309 @@ 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 +324,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 +353,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 +500,11 @@ class JsonI18n:
)
return key.format(**kwargs) if kwargs else key
text = lang_data.get(key)
text = lang_data.get(lookup_key)
if text is None:
if effective_lang_code != self.default_lang:
default_lang_data = self.locales_data.get(self.default_lang, {})
text = default_lang_data.get(key)
text = default_lang_data.get(lookup_key)
if text is None:
logging.warning(
+5 -14
View File
@@ -8,7 +8,7 @@ from aiogram.types import User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
@@ -55,22 +55,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
)
# Also update description on panel if linked
# Keep panel identity fields fresh, but do not rewrite
# description from profile changes. Remnawave may return
# description with lossy encoding in list views.
try:
panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid:
description_text = "\n".join(
[
db_user.email or "",
username_for_display(tg_user.username, with_at=False)
if sanitized_username is not None
else "",
sanitized_first_name or "",
sanitized_last_name or "",
]
).strip()
panel_payload = {
"description": description_text,
"telegramId": tg_user.id,
}
if db_user.email:
@@ -81,7 +72,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
)
except Exception as e_upd_desc:
logging.warning(
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
f"ProfileSyncMiddleware: Failed to update panel identity for user {tg_user.id}: {e_upd_desc}" # noqa: E501
)
except Exception as e:
logging.error(
@@ -22,9 +22,11 @@ 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,
provider_webhook_metadata,
resolve_provider_presentation,
)
@@ -52,6 +54,8 @@ __all__ = [
"pending_statuses",
"provider_telegram_button_text",
"provider_emoji_map",
"provider_admin_only_pairs",
"provider_label_map",
"provider_webhook_metadata",
"resolve_provider_presentation",
]
+109 -3
View File
@@ -28,6 +28,8 @@ class ProviderEnvConfig(BaseSettings):
env vars it consumes no edits in the global ``Settings`` required.
"""
ADMIN_ONLY_ENABLED: bool = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
@@ -36,6 +38,15 @@ class ProviderEnvConfig(BaseSettings):
)
def provider_runtime_enabled(config: Any, *admin_only_attrs: str) -> bool:
"""Return True when a provider should run for public or admin-only payments."""
if bool(getattr(config, "ENABLED", False)):
return True
attrs = admin_only_attrs or ("ADMIN_ONLY_ENABLED",)
return any(bool(getattr(config, attr, False)) for attr in attrs)
@dataclass(frozen=True)
class ProviderConfigBundle:
"""Functional config + presentation overrides for a single provider."""
@@ -68,6 +79,9 @@ class ProviderManifestField:
attr: Optional[str] = (
None # attribute name on the target model; defaults to key without env_prefix
)
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
i18n_subsection_key: Optional[str] = None
@dataclass(frozen=True)
@@ -99,6 +113,11 @@ class WebAppPaymentContext:
description: str
sale_mode: str
traffic_gb: Optional[float] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None
hwid_proration_ratio: Optional[float] = None
hwid_full_price: Optional[float] = None
EnabledPredicate = Callable[[Any], bool]
@@ -135,11 +154,23 @@ class PaymentProviderSpec:
config_class: Optional[Type[ProviderEnvConfig]] = None
presentation_class: Optional[Type[ProviderEnvConfig]] = None
manifest_fields: Sequence[ProviderManifestField] = ()
enabled_manifest_key: Optional[str] = None
admin_only_manifest_key: Optional[str] = None
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
admin_only_enabled: Optional[EnabledPredicate] = None
@property
def settings_key(self) -> str:
return self.id.upper()
@property
def enabled_field_key(self) -> str:
return self.enabled_manifest_key or f"{self.settings_key}_ENABLED"
@property
def admin_only_field_key(self) -> str:
return self.admin_only_manifest_key or f"{self.settings_key}_ADMIN_ONLY_ENABLED"
@property
def default_telegram_emoji(self) -> str:
return self.telegram_emoji or self.emoji
@@ -148,7 +179,7 @@ class PaymentProviderSpec:
def method_ids(self) -> tuple[str, ...]:
return (self.id, *tuple(self.aliases))
def is_enabled(self, source: Any) -> bool:
def _predicate_value(self, predicate: EnabledPredicate, source: Any) -> bool:
# If this spec carries a provider-local config_class, prefer the live
# config bundle so callers can pass plain Settings without having to
# know about provider-local env layouts.
@@ -157,8 +188,46 @@ class PaymentProviderSpec:
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bool(self.enabled(bundle.config))
return bool(self.enabled(source))
return bool(predicate(bundle.config))
return bool(predicate(source))
def is_enabled(self, source: Any) -> bool:
return self._predicate_value(self.enabled, source)
def is_admin_only_enabled(self, source: Any) -> bool:
if self.admin_only_enabled is not None:
return self._predicate_value(self.admin_only_enabled, source)
if self.config_class is not None and self.service_key:
from .registry import get_provider_bundle
bundle = get_provider_bundle(self.service_key)
if bundle and bundle.config is not None:
return bool(getattr(bundle.config, self.admin_only_config_attr, False))
return bool(getattr(source, self.admin_only_field_key, False))
def is_effectively_enabled(self, source: Any) -> bool:
return self.is_enabled(source) or self.is_admin_only_enabled(source)
def _is_admin_user(
self,
source: Any,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> bool:
if is_admin is not None:
return bool(is_admin)
if user_id is None:
return False
try:
normalized_user_id = int(user_id)
except (TypeError, ValueError):
return False
try:
admin_ids = {int(item) for item in (getattr(source, "ADMIN_IDS", None) or [])}
except (TypeError, ValueError):
return False
return normalized_user_id in admin_ids
def is_service_configured(self, app: Any) -> bool:
if not self.requires_configured_service:
@@ -171,6 +240,43 @@ class PaymentProviderSpec:
def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app)
def is_available_to_user(
self,
source: Any,
app: Any = None,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
require_configured: bool = True,
) -> bool:
public_enabled = self.is_enabled(source)
admin_only_visible = self.is_admin_only_enabled(source) and self._is_admin_user(
source,
user_id=user_id,
is_admin=is_admin,
)
if not (public_enabled or admin_only_visible):
return False
if require_configured and app is not None and not self.is_service_configured(app):
return False
return True
def is_visible_for_user(
self,
source: Any,
app: Any,
*,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
) -> bool:
return self.is_available_to_user(
source,
app,
user_id=user_id,
is_admin=is_admin,
require_configured=True,
)
def load_router(self) -> Any:
return self.router
+47 -4
View File
@@ -26,6 +26,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
PaymentSuccessRequest,
@@ -37,9 +38,12 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
render_payment_link,
sale_mode_base,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
@@ -119,7 +123,7 @@ class CryptoPayService:
@property
def configured(self) -> bool:
return bool(self.config.ENABLED and self.config.TOKEN)
return bool(provider_runtime_enabled(self.config) and self.config.TOKEN)
@property
def client(self):
@@ -154,13 +158,14 @@ class CryptoPayService:
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
payment_record = await payment_dal.create_payment_record(
session,
@@ -176,7 +181,17 @@ class CryptoPayService:
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio")
if hwid_quote
else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
},
)
await session.commit()
@@ -191,7 +206,7 @@ class CryptoPayService:
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if is_traffic else None,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
}
)
try:
@@ -349,11 +364,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 +399,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 +449,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()
+23 -2
View File
@@ -29,6 +29,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -49,6 +50,7 @@ from .shared import (
payment_failed,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -152,14 +154,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):
@@ -451,6 +453,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 +471,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 +497,7 @@ async def pay_fk_callback_handler(
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+22 -1
View File
@@ -27,6 +27,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -48,6 +49,7 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -241,7 +243,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:
@@ -546,6 +548,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 +566,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 +588,7 @@ async def pay_heleket_callback_handler(
months=parts.months,
provider="heleket",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+51 -7
View File
@@ -23,6 +23,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -45,6 +46,7 @@ from .shared import (
payment_record_amounts,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
safe_callback_answer,
)
@@ -66,7 +68,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 +165,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:
@@ -395,17 +407,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 +447,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 +476,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 +498,7 @@ async def pay_platega_callback_handler(
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
@@ -538,11 +576,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 +778,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 +791,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 +809,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)
+84
View File
@@ -321,8 +321,13 @@ def pending_statuses() -> List[str]:
def iter_provider_manifest_fields() -> Iterable[tuple[PaymentProviderSpec, ProviderManifestField]]:
"""Yield (spec, manifest_field) for every fragment declared on a provider SPEC."""
for spec in PAYMENT_PROVIDER_SPECS:
emitted_keys: set[str] = set()
for field in spec.manifest_fields:
emitted_keys.add(field.key)
yield spec, field
admin_only_field = provider_admin_only_manifest_field(spec)
if admin_only_field is not None and admin_only_field.key not in emitted_keys:
yield spec, admin_only_field
def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, ProviderManifestField]]:
@@ -333,6 +338,85 @@ 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
if not spec.service_key:
return None
for candidate in PAYMENT_PROVIDER_SPECS:
if (
candidate.service_key == spec.service_key
and candidate.webhook_path
and candidate.webhook_route
):
return candidate
return None
def provider_webhook_metadata(spec: PaymentProviderSpec) -> Optional[Dict[str, Any]]:
"""Return admin-manifest webhook metadata for a provider SPEC.
Some visible payment buttons share one backing service and webhook route
(for example Platega SBP and Platega Crypto), so presentation-only specs
inherit the route from their service sibling.
"""
webhook_spec = _webhook_spec_for(spec)
if webhook_spec is None or not webhook_spec.webhook_path:
return None
try:
path = str(webhook_spec.webhook_path(None) or "").strip()
except Exception:
return None
if not path:
return None
return {
"provider_id": spec.id,
"provider_label": spec.label,
"webhook_provider_id": webhook_spec.id,
"webhook_path": path,
"webhook_requires_base_url": bool(webhook_spec.webhook_requires_base_url),
}
def manifest_field_default(
spec: PaymentProviderSpec,
manifest_field: ProviderManifestField,
+22 -1
View File
@@ -25,6 +25,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -46,6 +47,7 @@ from .shared import (
payment_failed,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -135,7 +137,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:
@@ -395,6 +397,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 +415,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 +437,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,
@@ -55,6 +56,7 @@ from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
@@ -82,6 +84,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",
@@ -113,6 +116,7 @@ __all__ = [
"payment_record_amounts",
"payment_unavailable",
"post_json_request",
"quote_hwid_callback_parts",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
@@ -21,6 +21,8 @@ from .common import (
format_human_units,
mark_payment_failed_creation,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_tariff_key,
)
@@ -112,6 +114,34 @@ def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str
)
async def quote_hwid_callback_parts(
*,
session: AsyncSession,
user_id: int,
parts: PaymentCallbackParts,
subscription_service,
currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None
quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(parts.months),
tariff_key=sale_mode_tariff_key(parts.sale_mode),
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
currency=currency,
)
if not quote:
return None, None
quoted_parts = PaymentCallbackParts(
months=parts.months,
price=float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
def payment_link_message_text(
translator: Translator,
parts: PaymentCallbackParts,
+30 -3
View File
@@ -60,7 +60,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 +75,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 +86,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 +99,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 +131,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]:
@@ -194,6 +206,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 +226,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 +263,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,
+30 -6
View File
@@ -35,6 +35,7 @@ from .shared import (
payment_failed,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
safe_callback_answer,
sale_mode_base,
sale_mode_tariff_key,
@@ -80,9 +81,10 @@ class StarsService:
stars_price: int,
description: str,
sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> Optional[int]:
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
@@ -93,7 +95,15 @@ class StarsService:
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -197,11 +207,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 +241,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 +465,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",
+624 -115
View File
@@ -2,7 +2,7 @@ import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Mapping, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
@@ -28,6 +28,7 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
HttpClientMixin,
@@ -48,13 +49,76 @@ from .shared import (
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
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 +134,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 +199,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 +224,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 +258,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 +268,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 +276,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 +430,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
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
provider_subscription="wata",
provider_notification="wata",
db_user=payment.user,
log_prefix=log_prefix,
)
)
if outcome is None:
return None
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
async def _mark_declined_from_payload(
self,
session: AsyncSession,
payment: Any,
payload: Mapping[str, Any],
*,
log_prefix: str,
notify_user: bool,
) -> Optional[Any]:
transaction_id = _wata_transaction_id(payload) or str(payment.payment_id)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark payment %s as failed.",
log_prefix,
transaction_id,
)
return None
if notify_user:
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
def _local_payment_link_ttl_expired(self, payment: Any) -> bool:
created_at = getattr(payment, "created_at", None)
if isinstance(created_at, datetime):
created_dt = (
created_at.replace(tzinfo=timezone.utc)
if created_at.tzinfo is None
else created_at.astimezone(timezone.utc)
)
else:
created_dt = _parse_wata_datetime(created_at)
if created_dt is None:
return False
expires_at = created_dt + timedelta(minutes=self.payment_link_ttl_minutes)
return expires_at <= datetime.now(timezone.utc)
async def _expired_link_payload_for_payment(self, payment: Any) -> Optional[Mapping[str, Any]]:
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
if not provider_payment_id:
return None
success, data = await self.get_payment_link(provider_payment_id)
if not success or not isinstance(data, dict):
status_code = data.get("status") if isinstance(data, dict) else None
if status_code == 404 and self._local_payment_link_ttl_expired(payment):
return {"id": provider_payment_id}
return None
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
expiration_dt = _parse_wata_datetime(expiration_raw)
if expiration_dt is None:
return None
if expiration_dt > datetime.now(timezone.utc):
return None
return data
async def _mark_expired_link(
self,
session: AsyncSession,
payment: Any,
payload: Mapping[str, Any],
*,
log_prefix: str,
) -> Optional[Any]:
provider_payment_id = (
first_value(payload, "id", "paymentLinkId", "payment_link_id")
or getattr(payment, "provider_payment_id", None)
or str(payment.payment_id)
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
"canceled",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark expired payment link %s as canceled.",
log_prefix,
provider_payment_id,
)
return None
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
async def refresh_payment_status(self, session: AsyncSession, payment: Any) -> Any:
if str(getattr(payment, "provider", "") or "").lower() != "wata":
return payment
if not self.configured:
return payment
current_status = str(getattr(payment, "status", "") or "").lower()
if current_status == "succeeded" or current_status in {
"failed",
"canceled",
"cancelled",
"failed_creation",
}:
return payment
paid_payload = await self._find_transaction_for_payment(payment, status="Paid")
if paid_payload:
refreshed = await self._mark_paid_from_payload(
session,
payment,
paid_payload,
log_prefix="Wata status refresh",
)
return refreshed or payment
declined_payload = await self._find_transaction_for_payment(payment, status="Declined")
if declined_payload:
refreshed = await self._mark_declined_from_payload(
session,
payment,
declined_payload,
log_prefix="Wata status refresh",
notify_user=False,
)
return refreshed or payment
expired_link_payload = await self._expired_link_payload_for_payment(payment)
if expired_link_payload:
refreshed = await self._mark_expired_link(
session,
payment,
expired_link_payload,
log_prefix="Wata status refresh",
)
return refreshed or payment
return payment
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="wata_disabled")
@@ -287,12 +742,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 +756,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 +820,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 +839,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 +857,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 +912,7 @@ async def pay_wata_callback_handler(
months=parts.months,
provider="wata",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
@@ -463,6 +926,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 +943,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 +956,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 +1018,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 +1129,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",
+193 -25
View File
@@ -36,6 +36,7 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import ensure_user_install_guide_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
@@ -48,9 +49,12 @@ from .base import (
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
provider_runtime_enabled,
)
from .shared import (
PaymentCallbackParts,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
create_webapp_payment_record,
format_human_units,
@@ -63,6 +67,7 @@ from .shared import (
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
resolve_inviter_name,
send_success_message_to_user,
)
@@ -150,7 +155,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 +168,11 @@ 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 +263,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 +422,49 @@ 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 = (
int(float(hwid_devices_raw))
if _metadata_value_present(hwid_devices_raw)
else (int(subscription_months) if _is_hwid_device_sale_base(sale_mode_base) else 0)
)
if sale_mode_base == "subscription":
months_for_activation = int(subscription_months)
elif _is_hwid_device_sale_base(sale_mode_base):
months_for_activation = hwid_devices_count
else:
months_for_activation = int(traffic_amount_gb)
traffic_gb_for_activation = traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
return (
subscription_months,
traffic_amount_gb,
hwid_devices_count,
months_for_activation,
traffic_gb_for_activation,
)
async def process_successful_payment(
@@ -427,6 +482,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 +495,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 +510,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 +539,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 +698,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 +707,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 +730,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 +765,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 +789,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 +798,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 +819,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 +834,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 +856,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 +880,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 +1299,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 +1311,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))
)
)
@@ -1219,8 +1328,15 @@ async def _initiate_yk_payment(
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"}
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 +1373,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 +1578,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 +1620,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 +1653,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 +1746,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 +1773,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 +1860,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 +2023,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:
@@ -2557,10 +2728,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 +2868,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="💳",
+4 -1
View File
@@ -15,6 +15,7 @@ from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.email_templates import EmailContent, render_login_code
from config.settings import Settings
from db.dal import security_dal
@@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona
class EmailAuthService:
def __init__(self, settings: Settings):
def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None):
self.settings = settings
self.i18n = i18n
def _smtp_attempts(self) -> list[SmtpAttempt]:
attempts: list[SmtpAttempt] = []
@@ -465,6 +467,7 @@ class EmailAuthService:
language_code=language_code,
magic_link=magic_link,
purpose=purpose,
i18n=self.i18n,
)
message = EmailMessage()
+5 -2
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
from urllib.parse import urlsplit
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
from config.settings import Settings
_BG = "#05070a"
@@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str:
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
return normalize_locale_language_code(
language_code or settings.DEFAULT_LANGUAGE or "ru",
prefer_known_base=False,
)
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
@@ -0,0 +1,721 @@
"""Load, persist and apply runtime overrides for localization strings."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import (
JsonI18n,
LocaleOverrides,
is_valid_locale_language_code,
normalize_locale_language_code,
normalize_locale_overrides_payload,
resolve_locale_key,
)
from db.dal import locale_overrides_dal
logger = logging.getLogger(__name__)
APP_ROOT = Path(__file__).resolve().parents[3]
LOCALE_OVERRIDES_PATH = APP_ROOT / "data" / "locales-overrides.json"
LOCALE_GROUPS = [
{
"id": "admin_navigation",
"title": "Admin navigation and shared UI",
"description": "Sidebar, section headers, toolbar actions, filters, and shared controls.",
"audience": "internal",
"prefixes": (
"admin_nav_",
"admin_section_",
"admin_panel_title",
"admin_back_to_panel",
"admin_sidebar_",
"admin_exit",
"admin_menu",
"admin_language",
"admin_page_",
"admin_close",
"admin_collapse",
"admin_expand",
"admin_show",
"admin_hide",
"admin_loading",
"admin_btn_",
"admin_filter_",
"admin_sort_",
"admin_status_",
"admin_badge_",
"admin_aria_",
"admin_search",
"admin_clear",
"admin_save",
"admin_saving",
"admin_add",
"admin_apply",
"admin_reset",
"admin_copy",
"admin_copied",
"admin_error",
"admin_unknown_action",
"back_to_admin_panel_button",
"back_to_ads_list_button",
"back_to_stats_monitoring_button",
"back_to_user_management_button",
"prev_page_button",
"next_page_button",
),
},
{
"id": "admin_dashboard",
"title": "Admin dashboard and stats",
"description": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.",
"audience": "internal",
"prefixes": (
"admin_stats_",
"admin_financial_",
"admin_enhanced_",
"admin_panel_stats_",
"admin_panel_traffic_",
"admin_queue_",
"admin_sync_status_",
"admin_stats_button",
"admin_sync_panel_button",
"admin_sync_initiated_from_panel",
"admin_total",
"error_displaying_statistics",
"inline_admin_",
"inline_user_stats_",
"inline_financial_",
"inline_system_",
),
},
{
"id": "admin_users",
"title": "Admin users",
"description": (
"User lists, user cards, bans, grants, premium overrides, and direct messages."
),
"audience": "internal",
"prefixes": (
"admin_user_",
"admin_users_",
"admin_ban_",
"admin_unban_",
"admin_banned_",
"admin_premium_override_",
"admin_traffic_grant_",
"admin_view_banned_",
"user_card_",
"user_premium_",
"user_regular_",
"user_traffic_",
"user_override_",
"premium_override_",
"regular_override_",
"traffic_grant_",
),
},
{
"id": "admin_payments",
"title": "Admin payments",
"description": (
"Payment tables, payment details, exports, provider labels, and payment stats."
),
"audience": "internal",
"prefixes": (
"admin_payment_",
"admin_payments_",
"admin_no_payments",
"admin_view_payments",
"admin_refresh_payments",
"admin_export_payments",
"admin_export_sent",
"admin_amount",
"admin_provider",
"admin_description",
"admin_date",
"admin_csv_payment_",
"admin_csv_amount",
"admin_csv_currency",
"admin_csv_provider",
"admin_csv_status",
"admin_csv_description",
"admin_csv_units",
"admin_csv_months",
"admin_csv_created_at",
),
},
{
"id": "admin_promos_marketing",
"title": "Admin promos, ads, and broadcasts",
"description": "Promo management, ad campaigns, marketing tools, and broadcast workflows.",
"audience": "internal",
"prefixes": (
"admin_promo_",
"admin_promos_",
"admin_bulk_promo_",
"admin_ads_",
"admin_ad_",
"admin_broadcast_",
"admin_create_promo_",
"admin_create_bulk_promo_",
"admin_active_promos_",
"broadcast_",
"confirm_broadcast_",
"cancel_broadcast_",
),
},
{
"id": "admin_tariffs",
"title": "Admin tariffs",
"description": (
"Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets."
),
"audience": "internal",
"prefixes": ("admin_tariff_", "admin_tariffs_", "admin_trial"),
},
{
"id": "admin_support",
"title": "Admin support inbox",
"description": "Support ticket inbox, ticket filters, admin replies, and support statuses.",
"audience": "internal",
"prefixes": ("admin_support_",),
},
{
"id": "admin_appearance",
"title": "Admin appearance",
"description": "Theme catalog, branding, logo, favicon, and public page links.",
"audience": "internal",
"prefixes": (
"admin_themes_",
"admin_appearance",
"admin_settings_icon_",
"admin_settings_field_webapp_",
"admin_settings_field_subscription_mini_app_url",
"admin_settings_field_support_link",
"admin_settings_field_server_status_url",
"admin_settings_field_terms_",
"admin_settings_field_privacy_",
"admin_settings_field_user_agreement_",
"appearance_",
),
},
{
"id": "admin_settings_payments",
"title": "Admin payment settings",
"description": (
"Payment method toggles, prices, provider credentials, and webhook settings."
),
"audience": "internal",
"prefixes": (
"admin_settings_field_default_currency_",
"admin_settings_field_month_",
"admin_settings_field_rub_",
"admin_settings_field_stars_",
"admin_settings_field_traffic_packages_",
"admin_settings_field_payment_methods_",
"admin_settings_field_subscription_purchase_",
"admin_settings_field_yookassa_",
"admin_settings_field_freekassa_",
"admin_settings_field_platega_",
"admin_settings_field_severpay_",
"admin_settings_field_cryptopay_",
"admin_settings_field_wata_",
"admin_settings_field_heleket_",
),
},
{
"id": "admin_settings_subscriptions",
"title": "Admin subscription settings",
"description": (
"Panel connection, default squads, trials, referrals, device limits, and guides."
),
"audience": "internal",
"prefixes": (
"admin_settings_field_panel_",
"admin_settings_field_user_",
"admin_settings_field_trial_",
"admin_settings_field_referral_",
"admin_settings_field_legacy_refs",
"admin_settings_field_my_devices_",
"admin_settings_field_subscription_guides_",
"admin_settings_field_subscription_page_",
),
},
{
"id": "admin_settings_notifications",
"title": "Admin notifications and logs",
"description": "Logging, required channel, subscription notifications, and support limits.",
"audience": "internal",
"prefixes": (
"admin_settings_field_log_",
"admin_settings_field_support_",
"admin_settings_field_subscription_notifications_",
"admin_settings_field_subscription_notify_",
"admin_settings_field_required_",
"admin_settings_field_disable_welcome_",
"admin_settings_field_start_command_",
"admin_settings_field_default_language_",
),
},
{
"id": "admin_settings",
"title": "Admin settings",
"description": (
"Settings screen groups, subsections, helper text, and uncategorized settings."
),
"audience": "internal",
"prefixes": ("admin_settings_",),
},
{
"id": "admin_translations",
"title": "Admin translations",
"description": "Translation override screen, language controls, and locale group labels.",
"audience": "internal",
"prefixes": ("admin_translations_",),
},
{
"id": "admin_logs",
"title": "Admin logs and exports",
"description": "Activity logs, log exports, CSV headers, and event detail labels.",
"audience": "internal",
"prefixes": (
"admin_logs_",
"admin_log_",
"admin_all_logs_",
"admin_view_logs_",
"admin_export_logs_",
"admin_no_logs",
"admin_csv_header_",
"admin_event",
"admin_content",
"csv_yes",
"csv_no",
"error_displaying_logs_",
),
},
{
"id": "admin_misc",
"title": "Admin miscellaneous",
"description": (
"Older bot-admin labels and admin-only strings that do not fit another section."
),
"audience": "internal",
"prefixes": ("admin_",),
},
{
"id": "webapp",
"title": "Mini App",
"description": "User-facing Mini App screens, navigation, settings, and toasts.",
"audience": "user",
"prefixes": ("wa_",),
},
{
"id": "bot_menu",
"title": "Telegram bot menu",
"description": "Start menu, inline buttons, language selector, and bot-only flows.",
"audience": "user",
"prefixes": (
"main_menu_",
"menu_",
"bot_interface_",
"choose_language",
"language_",
"back_",
"cancel_",
"connect_",
),
},
{
"id": "subscriptions",
"title": "Subscriptions and devices",
"description": (
"Subscription status, install guides, traffic packages, trials, and devices."
),
"audience": "user",
"prefixes": (
"subscription_",
"trial_",
"tariff_",
"traffic_",
"device_",
"devices_",
"my_devices_",
"install_",
"config_",
),
},
{
"id": "payments",
"title": "Payments",
"description": "Payment provider flows, invoices, payment methods, and checkout messages.",
"audience": "user",
"prefixes": (
"payment_",
"pay_",
"yookassa_",
"free_kassa_",
"freekassa_",
"wata_",
"heleket_",
"cryptopay_",
"platega_",
"stars_",
"autorenew_",
),
},
{
"id": "support",
"title": "Support",
"description": "Support links, ticket inbox copy, ticket statuses, and notifications.",
"audience": "user",
"prefixes": ("support_", "ticket_"),
},
{
"id": "referrals_promos",
"title": "Referrals and promos",
"description": "Referral program, invite copy, promo codes, and bonuses.",
"audience": "user",
"prefixes": ("referral_", "promo_", "invite_", "inline_referral_"),
},
{
"id": "auth_security",
"title": "Auth and security",
"description": "Login, email verification, account linking, and security messages.",
"audience": "user",
"prefixes": (
"auth_",
"login_",
"password_",
"security_",
"webapp_auth_",
"channel_subscription_",
),
},
{
"id": "emails",
"title": "Emails",
"description": "Transactional emails sent to users: login codes, payments, and reminders.",
"audience": "user",
"prefixes": ("email_",),
},
{
"id": "notifications_sync",
"title": "Notifications and sync",
"description": "Admin notifications, panel sync, logs, and background status messages.",
"audience": "internal",
"prefixes": ("notification_", "notifications_", "sync_", "log_", "panel_"),
},
]
DEFAULT_LOCALE_GROUP = {
"id": "common",
"title": "Common",
"description": "Shared buttons, statuses, validation errors, and uncategorized strings.",
"audience": "user",
"prefixes": (),
}
INTERNAL_LOCALE_KEY_PREFIXES = (
"admin_",
"log_",
"notification_",
"notifications_",
"panel_",
"sync_",
)
@dataclass(frozen=True)
class LocaleOverridesFileState:
exists: bool
readable: bool
overrides: LocaleOverrides
def _valid_languages(i18n: JsonI18n) -> set[str]:
return set((i18n.base_locales_data or i18n.locales_data or {}).keys())
def _valid_keys_by_language(i18n: JsonI18n) -> Dict[str, set[str]]:
source = i18n.base_locales_data or i18n.locales_data or {}
return {
lang: {str(key) for key in messages}
for lang, messages in source.items()
if isinstance(messages, dict)
}
def _normalize_for_i18n(i18n: JsonI18n, payload: object) -> tuple[LocaleOverrides, Dict[str, str]]:
return normalize_locale_overrides_payload(
payload,
valid_languages=_valid_languages(i18n),
valid_keys_by_language=_valid_keys_by_language(i18n),
allow_extra_languages=True,
)
def _flatten(overrides: LocaleOverrides) -> Iterable[Tuple[str, str, str]]:
for lang, messages in overrides.items():
for key, value in messages.items():
yield lang, key, value
def _flat_map(overrides: LocaleOverrides) -> Dict[Tuple[str, str], str]:
return {(lang, key): value for lang, key, value in _flatten(overrides)}
def _count_overrides(overrides: LocaleOverrides) -> int:
return sum(len(messages) for messages in overrides.values())
def _read_locale_overrides_file_state(
i18n: JsonI18n,
*,
path: Path = LOCALE_OVERRIDES_PATH,
) -> LocaleOverridesFileState:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return LocaleOverridesFileState(exists=False, readable=False, overrides={})
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to read locale overrides from %s: %s", path, exc)
return LocaleOverridesFileState(exists=True, readable=False, overrides={})
overrides, errors = _normalize_for_i18n(i18n, payload)
if errors:
logger.warning("Skipping invalid locale override entries from %s: %s", path, errors)
return LocaleOverridesFileState(exists=True, readable=True, overrides=overrides)
def read_locale_overrides_file(
i18n: JsonI18n,
*,
path: Path = LOCALE_OVERRIDES_PATH,
) -> LocaleOverrides:
return _read_locale_overrides_file_state(i18n, path=path).overrides
def write_locale_overrides_file(
overrides: LocaleOverrides,
*,
path: Path = LOCALE_OVERRIDES_PATH,
) -> bool:
payload = {
lang: dict(sorted(messages.items()))
for lang, messages in sorted(overrides.items())
if messages
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return True
except OSError as exc:
logger.warning("Failed to write locale overrides to %s: %s", path, exc)
return False
async def _replace_db_overrides(
session: AsyncSession,
desired_overrides: LocaleOverrides,
*,
updated_by: Optional[int] = None,
) -> int:
current = _flat_map(await locale_overrides_dal.get_all_overrides(session))
desired = _flat_map(desired_overrides)
changes: Dict[Tuple[str, str], Tuple[bool, str]] = {}
for identity, value in desired.items():
if current.get(identity) != value:
changes[identity] = (True, value)
for identity in current:
if identity not in desired:
changes[identity] = (False, "")
if changes:
await locale_overrides_dal.bulk_apply(session, updates=changes, updated_by=updated_by)
return len(changes)
async def load_locale_overrides(
i18n: JsonI18n,
async_session_factory: sessionmaker,
*,
overrides_path: Path = LOCALE_OVERRIDES_PATH,
) -> int:
"""Load locale overrides and keep the DB mirror in sync.
A valid JSON file is the source of truth. The DB is used as a fallback only
when the file is missing or cannot be read/parsed.
"""
i18n.configure_overrides_file(overrides_path)
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
try:
async with async_session_factory() as session:
if file_state.readable:
async with session.begin():
changed = await _replace_db_overrides(
session,
file_state.overrides,
updated_by=None,
)
i18n.set_locale_overrides(file_state.overrides)
i18n.configure_overrides_file(overrides_path)
logger.info(
"Applied %s locale overrides from %s and synced %s DB rows",
_count_overrides(file_state.overrides),
overrides_path,
changed,
)
return _count_overrides(file_state.overrides)
db_overrides = await locale_overrides_dal.get_all_overrides(session)
except Exception as exc:
logger.warning("Could not load locale overrides from DB: %s", exc)
if file_state.readable:
i18n.set_locale_overrides(file_state.overrides)
return _count_overrides(file_state.overrides)
i18n.set_locale_overrides({})
return 0
normalized, errors = _normalize_for_i18n(i18n, db_overrides)
if errors:
logger.warning("Skipping invalid DB locale override entries: %s", errors)
try:
async with async_session_factory() as session:
async with session.begin():
changed = await _replace_db_overrides(session, normalized, updated_by=None)
if changed:
logger.info("Canonicalized %s DB locale override rows", changed)
except Exception as exc:
logger.warning("Could not canonicalize DB locale overrides: %s", exc)
i18n.set_locale_overrides(normalized)
if not file_state.exists:
logger.info(
"Locale overrides file %s is missing; trying to bootstrap it from DB state",
overrides_path,
)
file_written = write_locale_overrides_file(normalized, path=overrides_path)
if file_written:
i18n.configure_overrides_file(overrides_path)
logger.info("Created locale overrides file %s from DB state", overrides_path)
logger.info("Applied %s locale overrides from DB fallback", _count_overrides(normalized))
return _count_overrides(normalized)
async def update_locale_overrides(
i18n: JsonI18n,
async_session_factory: sessionmaker,
*,
updates: Dict[str, Dict[str, Any]],
deletes: Optional[List[Dict[str, str]]] = None,
actor_id: Optional[int] = None,
overrides_path: Path = LOCALE_OVERRIDES_PATH,
) -> Dict[str, Any]:
deletes = list(deletes or [])
normalized_updates, errors = _normalize_for_i18n(i18n, updates)
normalized_deletes: List[Tuple[str, str]] = []
valid_languages = _valid_languages(i18n)
valid_keys = {key for keys in _valid_keys_by_language(i18n).values() for key in keys}
for item in deletes:
if not isinstance(item, dict):
errors.setdefault("_deletes", "invalid_delete")
continue
lang = normalize_locale_language_code(
item.get("lang"),
valid_languages=None,
prefer_known_base=False,
)
key = resolve_locale_key(item.get("key"))
error_key = f"{lang or '_language'}.{key or '_key'}"
if lang not in valid_languages and not is_valid_locale_language_code(lang):
errors.setdefault(error_key, "invalid_language")
continue
if key not in valid_keys:
errors.setdefault(error_key, "unknown_key")
continue
normalized_deletes.append((lang, key))
if errors:
return {"ok": False, "errors": errors}
async with async_session_factory() as session:
db_overrides = await locale_overrides_dal.get_all_overrides(session)
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
source_overrides = file_state.overrides if file_state.readable else db_overrides
desired, source_errors = _normalize_for_i18n(i18n, source_overrides)
if source_errors:
logger.warning("Skipping invalid locale override entries before update: %s", source_errors)
for lang, messages in normalized_updates.items():
desired.setdefault(lang, {}).update(messages)
for lang, key in normalized_deletes:
if lang in desired:
desired[lang].pop(key, None)
if not desired[lang]:
desired.pop(lang, None)
desired = {
lang: dict(sorted(messages.items()))
for lang, messages in sorted(desired.items())
if messages
}
file_written = write_locale_overrides_file(desired, path=overrides_path)
if not file_written and file_state.exists and file_state.readable:
return {"ok": False, "errors": {"_file": "write_failed"}}
async with async_session_factory() as session:
async with session.begin():
await _replace_db_overrides(session, desired, updated_by=actor_id)
i18n.set_locale_overrides(desired)
if file_written:
i18n.configure_overrides_file(overrides_path)
return {
"ok": True,
"applied": sum(len(messages) for messages in normalized_updates.values()),
"reverted": len(normalized_deletes),
"file_written": file_written,
}
def group_id_for_locale_key(key: str) -> str:
for group in LOCALE_GROUPS:
if any(key.startswith(prefix) or key == prefix for prefix in group["prefixes"]):
return str(group["id"])
return str(DEFAULT_LOCALE_GROUP["id"])
def audience_for_locale_key(key: str) -> str:
if key.startswith(INTERNAL_LOCALE_KEY_PREFIXES):
return "internal"
group_id = group_id_for_locale_key(key)
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
if group["id"] == group_id:
return str(group.get("audience") or "user")
return "user"
def locale_group_catalog() -> List[Dict[str, Any]]:
catalog: List[Dict[str, Any]] = []
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
item = {key: value for key, value in group.items() if key != "prefixes"}
item["title_key"] = f"translations_group_{item['id']}"
item["description_key"] = f"translations_group_{item['id']}_hint"
catalog.append(item)
return catalog
+81 -6
View File
@@ -56,11 +56,16 @@ class NotificationService:
user_id: int,
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 = _(
@@ -690,6 +704,52 @@ class NotificationService:
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_account_merged(
self,
*,
primary_user_id: int,
removed_user_id: int,
email: Optional[str],
telegram_id: Optional[int],
username: Optional[str] = None,
first_name: Optional[str] = None,
final_end_date_text: Optional[str] = None,
primary_panel_user_uuid: Optional[str] = None,
removed_panel_user_uuid: Optional[str] = None,
):
"""Send notification when duplicate email/Telegram accounts are merged."""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
display_user_id = int(telegram_id or primary_user_id)
user_display = self._format_user_display(
user_id=display_user_id,
username=username,
first_name=first_name,
email=email,
)
message = _(
"log_account_merged",
primary_user_id=primary_user_id,
removed_user_id=removed_user_id,
telegram_id=telegram_id or "",
user_display=user_display,
email=hd.quote(email or ""),
final_end_date=hd.quote(final_end_date_text or ""),
primary_panel_user_uuid=hd.quote(primary_panel_user_uuid or ""),
removed_panel_user_uuid=hd.quote(removed_panel_user_uuid or ""),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
profile_keyboard = (
self._build_profile_keyboard(_, int(telegram_id)) if telegram_id else None
)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
def _format_traffic_gb_admin(self, traffic_gb: float) -> str:
value = float(traffic_gb)
if value.is_integer():
@@ -716,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,
@@ -731,6 +792,7 @@ class NotificationService:
user_display = self._format_user_display(
user_id=user_id,
username=username,
email=email,
)
try:
@@ -782,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:
@@ -794,6 +861,7 @@ class NotificationService:
user_display = self._format_user_display(
user_id=user_id,
username=username,
email=email,
)
message = _(
@@ -809,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:
@@ -821,6 +893,7 @@ class NotificationService:
user_display = self._format_user_display(
user_id=user_id,
username=username,
email=email,
)
message = _(
@@ -873,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:
@@ -885,11 +959,12 @@ class NotificationService:
user_id=user_id,
username=username,
first_name=first_name,
email=email,
)
message = _(
"log_suspicious_promo",
user_display=hd.quote(user_display),
user_display=user_display,
user_id=user_id,
suspicious_input=hd.quote(suspicious_input),
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
+48
View File
@@ -597,6 +597,54 @@ class PanelApiService:
return f"{base_sub_url}/{client_type.lower()}"
return base_sub_url
async def get_subscription_page_config_by_short_uuid(
self,
short_uuid: str,
request_headers: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, Any]]:
if not short_uuid:
return None
endpoint = f"/subscriptions/subpage-config/{short_uuid}"
payload = {"requestHeaders": request_headers or {}}
response_data = await self._request(
"GET",
endpoint,
json=payload,
log_full_response=False,
)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config for short UUID {short_uuid}. Response: {response_data}" # noqa: E501
)
return None
async def get_subscription_page_config_list(self) -> Optional[Dict[str, Any]]:
endpoint = "/subscription-page-configs"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config list from panel. Response: {response_data}"
)
return None
async def get_subscription_page_config_by_uuid(
self,
config_uuid: str,
) -> Optional[Dict[str, Any]]:
config_uuid = str(config_uuid or "").strip()
if not config_uuid:
return None
endpoint = f"/subscription-page-configs/{config_uuid}"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config {config_uuid} from panel. Response: {response_data}" # noqa: E501
)
return None
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
if self._devices_cache.ttl_seconds <= 0:
return await self._get_user_devices_uncached(user_uuid)
+40 -3
View File
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
)
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import user_dal
from db.dal import tariff_dal, user_dal
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
@@ -63,13 +63,46 @@ class PanelWebhookService:
**kwargs,
):
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
extra_text = str(kwargs.pop("extra_text", "") or "").strip()
try:
text = _(message_key, **kwargs)
if extra_text:
text = f"{text}\n\n{extra_text}"
await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup
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 +130,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 +182,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 +192,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 +242,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)
@@ -270,6 +297,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():
@@ -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
if not period_start or period_start >= period_end:
period_start = valid_from
period_end = valid_until
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
billable_start = max(now, valid_from)
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
ratio = billable_seconds / basis_seconds
raw_price = full_price * ratio
price = self._round_hwid_price(raw_price, currency=currency)
min_price = getattr(package, "min_price", None)
if raw_price > 0 and min_price is not None:
price = max(price, self._round_hwid_price(float(min_price), currency=currency))
if currency == "stars":
price = float(int(math.ceil(price)))
return {
"price": price,
"full_price": full_price,
"pricing_period_months": period_months,
"proration_ratio": ratio,
"valid_from": valid_from,
"valid_until": valid_until,
"billable_seconds": billable_seconds,
"period_seconds": basis_seconds,
"currency": currency,
}
async def quote_hwid_device_topup(
self,
session: AsyncSession,
*,
user_id: int,
device_count: int,
tariff_key: Optional[str] = None,
renewal: bool = False,
currency: str = "rub",
now: Optional[datetime] = None,
) -> Optional[Dict[str, Any]]:
try:
purchased_devices = int(device_count)
except (TypeError, ValueError):
return None
if purchased_devices <= 0:
return None
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub:
return None
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
if not tariff or tariff.billing_model != "period":
return None
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
if base_hwid_limit == 0:
return None
package = self._find_hwid_package(tariff, purchased_devices, currency)
if not package:
return None
now = now or datetime.now(timezone.utc)
window = await self._hwid_topup_validity_window(
session,
sub,
renewal=renewal,
now=now,
)
if not window:
return None
valid_from, valid_until, entitlement_summary = window
quote = self._quote_hwid_package_price(
sub=sub,
package=package,
valid_from=valid_from,
valid_until=valid_until,
now=now,
currency="stars" if currency == "stars" else "rub",
)
quote.update(
{
"subscription_id": sub.subscription_id,
"tariff_key": tariff.key,
"device_count": purchased_devices,
"renewal": renewal,
"active_extra_devices": int(entitlement_summary.get("active_devices") or 0),
"active_until": entitlement_summary.get("active_until"),
}
)
return quote
async def activate_hwid_device_topup(
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,25 @@ 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 +537,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 +802,50 @@ 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 +899,11 @@ 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,91 @@ 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,11 @@ 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 +226,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 +237,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 +352,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 +434,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 +502,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 +512,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 +582,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 +685,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 +721,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
@@ -83,7 +83,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 -1
View File
@@ -125,7 +125,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,
+390 -16
View File
@@ -2,7 +2,7 @@ import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import Optional
from typing import Any, Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
@@ -30,6 +30,7 @@ PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100
TARIFF_WORKER_BATCH_SIZE = 50
TARIFF_WORKER_PANEL_CONCURRENCY = 10
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS = 900
class TariffTrafficWorker:
@@ -51,6 +52,7 @@ class TariffTrafficWorker:
self._stopped = asyncio.Event()
self._premium_nodes_cache = {}
self._premium_node_usage_tick_cache = {}
self._premium_squad_match_cache = {}
async def _user_lang(self, session: AsyncSession, user_id: int) -> str:
try:
@@ -148,6 +150,7 @@ class TariffTrafficWorker:
return
panel_users_by_uuid = await self._prefetch_panel_users_by_uuid(subs)
panel_view = "list" if panel_users_by_uuid is not None else "full_fetch"
semaphore = asyncio.Semaphore(TARIFF_WORKER_PANEL_CONCURRENCY)
async def _fetch_panel(sub: Subscription) -> dict:
@@ -155,6 +158,13 @@ class TariffTrafficWorker:
cached_panel_user = panel_users_by_uuid.get(str(sub.panel_user_uuid))
if cached_panel_user is not None:
return cached_panel_user
return await self._repair_missing_panel_user_for_subscription(
session,
sub,
panel_users_by_uuid=panel_users_by_uuid,
semaphore=semaphore,
confirmed_missing=True,
)
async with semaphore:
try:
@@ -167,12 +177,22 @@ class TariffTrafficWorker:
sub.panel_user_uuid,
)
return {}
return data or {}
if data:
return data
return await self._repair_missing_panel_user_for_subscription(
session,
sub,
panel_users_by_uuid=None,
semaphore=semaphore,
confirmed_missing=False,
)
for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE):
chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE]
panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk))
for sub, panel_data in zip(chunk, panel_payloads):
if not panel_data:
continue
try:
tariff = self.settings.tariffs_config.require(sub.tariff_key)
except Exception:
@@ -195,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,
@@ -213,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(
@@ -255,6 +277,68 @@ class TariffTrafficWorker:
)
return by_uuid
async def _repair_missing_panel_user_for_subscription(
self,
session: AsyncSession,
sub: Subscription,
*,
panel_users_by_uuid: Optional[dict[str, dict]],
semaphore: asyncio.Semaphore,
confirmed_missing: bool,
) -> dict:
current_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
try:
user_id = int(sub.user_id)
except (TypeError, ValueError):
user_id = 0
db_user = await user_dal.get_user_by_id(session, user_id) if user_id else None
canonical_uuid = str(getattr(db_user, "panel_user_uuid", "") or "").strip()
if canonical_uuid and canonical_uuid != current_uuid:
panel_user = None
if panel_users_by_uuid is not None:
panel_user = panel_users_by_uuid.get(canonical_uuid)
else:
async with semaphore:
try:
panel_user = await self.panel_service.get_user_by_uuid(
canonical_uuid,
log_response=False,
)
except Exception:
logging.exception(
"TariffTrafficWorker: failed to fetch canonical panel user %s",
canonical_uuid,
)
panel_user = None
if panel_user:
logging.warning(
"TariffTrafficWorker: repaired subscription %s panel UUID %s -> %s",
sub.subscription_id,
current_uuid,
canonical_uuid,
)
sub.panel_user_uuid = canonical_uuid
return panel_user
if confirmed_missing:
sub.is_active = False
sub.skip_notifications = True
sub.status_from_panel = "PANEL_USER_NOT_FOUND"
logging.warning(
"TariffTrafficWorker: deactivated subscription %s because panel user %s is missing",
sub.subscription_id,
current_uuid,
)
else:
logging.warning(
"TariffTrafficWorker: skipping subscription %s because panel user %s "
"could not be fetched",
sub.subscription_id,
current_uuid,
)
return {}
async def _ensure_period_reset_strategy(
self,
sub: Subscription,
@@ -294,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,
@@ -382,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 (
@@ -402,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))
@@ -452,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
@@ -487,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",
@@ -501,20 +701,194 @@ class TariffTrafficWorker:
premium_limit,
)
async def _get_full_panel_user_for_squad_confirmation(
self,
panel_user_uuid: str,
) -> Optional[dict]:
try:
return await self.panel_service.get_user_by_uuid(
panel_user_uuid,
log_response=False,
)
except Exception:
logging.exception(
"TariffTrafficWorker: failed to confirm panel squads for user %s",
panel_user_uuid,
)
return None
@staticmethod
def _same_premium_period(value: Optional[datetime], premium_period_start: datetime) -> bool:
if value is None:
return False
try:
return month_start(value) == premium_period_start
except Exception:
return False
async def _repair_premium_topup_balance_from_ledger(
self,
session: AsyncSession,
sub: Subscription,
premium_period_start: datetime,
premium_topup_balance: int,
premium_topup_used: int,
) -> int:
ledger_total = await self._premium_topup_ledger_total(
session,
int(getattr(sub, "subscription_id", 0) or 0),
premium_period_start,
)
if ledger_total is None:
return premium_topup_balance
tracked_total = max(0, int(premium_topup_balance or 0)) + max(
0,
int(premium_topup_used or 0),
)
if ledger_total <= tracked_total:
return premium_topup_balance
repaired_bytes = ledger_total - tracked_total
logging.warning(
"Premium top-up balance repaired from ledger for user %s subscription %s: "
"tracked=%s ledger=%s repaired=%s",
getattr(sub, "user_id", None),
getattr(sub, "subscription_id", None),
tracked_total,
ledger_total,
repaired_bytes,
)
return premium_topup_balance + repaired_bytes
async def _premium_topup_ledger_total(
self,
session: AsyncSession,
subscription_id: int,
premium_period_start: datetime,
) -> Optional[int]:
if not subscription_id or not isinstance(session, AsyncSession):
return None
try:
return await tariff_dal.sum_traffic_topups(
session,
subscription_id=subscription_id,
kinds=["premium_topup", "admin_premium_topup"],
created_at_gte=premium_period_start,
)
except Exception:
logging.exception(
"TariffTrafficWorker: failed to read premium top-up ledger for subscription %s",
subscription_id,
)
return None
@staticmethod
def _premium_squad_match_cache_key(
panel_user_uuid: str,
desired_set: set[str],
) -> tuple[str, tuple[str, ...]]:
return str(panel_user_uuid), tuple(sorted(desired_set))
def _premium_squad_match_cache_is_fresh(self, cache_key: tuple[str, tuple[str, ...]]) -> bool:
cached_at = self._premium_squad_match_cache.get(cache_key)
if not cached_at:
return False
return (
time.monotonic() - float(cached_at)
< TARIFF_WORKER_SQUAD_CONFIRMATION_CACHE_TTL_SECONDS
)
def _remember_premium_squad_match(self, cache_key: tuple[str, tuple[str, ...]]) -> None:
self._premium_squad_match_cache[cache_key] = time.monotonic()
@classmethod
def _panel_active_squad_uuid_set(
cls,
panel_user_dict: Optional[dict],
) -> tuple[bool, set[str]]:
current_known, current_raw = cls._panel_active_squads_raw(panel_user_dict)
return current_known, cls._internal_squad_uuid_set(current_raw)
@staticmethod
def _panel_active_squads_raw(panel_user_dict: Optional[dict]) -> tuple[bool, Any]:
if not isinstance(panel_user_dict, dict):
return False, None
for key in (
"activeInternalSquads",
"active_internal_squads",
"activeInternalSquadUuids",
"active_internal_squad_uuids",
):
if key in panel_user_dict:
return True, panel_user_dict.get(key)
return False, None
def _log_premium_squad_panel_patch(
self,
*,
sub: Subscription,
panel_uuid: str,
update_payload: dict[str, Any],
current_panel_user: Optional[dict],
reasons: list[str],
panel_view: str,
) -> None:
current_known, current_set = self._panel_active_squad_uuid_set(current_panel_user)
desired_set = self._internal_squad_uuid_set(update_payload.get("activeInternalSquads"))
fields = "none" if current_known and current_set == desired_set else "activeInternalSquads"
logging.info(
"Sync panel PATCH: source=%s user_id=%s telegram_id=%s panel_uuid=%s "
"panel_view=%s reasons=%s fields=%s payload_fields=%s changes=%s",
"premium_squad_limit",
getattr(sub, "user_id", None),
getattr(sub, "user_id", None),
panel_uuid,
panel_view,
",".join(reasons),
fields,
"activeInternalSquads",
"activeInternalSquads:%s->%s"
% (
self._format_squad_uuid_set(current_set if current_known else None),
self._format_squad_uuid_set(desired_set),
),
)
@staticmethod
def _internal_squad_uuid_set(raw) -> set[str]:
if not isinstance(raw, list):
if not isinstance(raw, (list, tuple, set)):
return set()
out: set[str] = set()
for item in raw:
if isinstance(item, dict):
u = item.get("uuid") or item.get("internalSquadUuid") or item.get("squadUuid")
nested_squad = item.get("internalSquad") or item.get("squad")
if not isinstance(nested_squad, dict):
nested_squad = {}
u = (
item.get("uuid")
or item.get("internalSquadUuid")
or item.get("squadUuid")
or nested_squad.get("uuid")
)
if u:
out.add(str(u))
elif item:
out.add(str(item))
return out
@staticmethod
def _format_squad_uuid_set(value: Optional[set[str]]) -> str:
if value is None:
return "missing"
values = sorted(str(item) for item in value)
preview = ",".join(values[:4])
suffix = ",..." if len(values) > 4 else ""
text = f"[{len(values)}:{preview}{suffix}]"
if len(text) > 96:
return f"{text[:93]}..."
return text
@staticmethod
def _fmt_bytes(value: int) -> str:
size = float(max(0, int(value or 0)))
+84
View File
@@ -0,0 +1,84 @@
"""Helpers for Telegram bot install-guide links."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from bot.utils.mini_app_url import (
subscription_mini_app_install_url,
subscription_public_install_url,
)
from config.subscription_guides_config import subscription_guides_available
from db.dal import subscription_dal
@dataclass(frozen=True)
class InstallGuideLinks:
personal_url: Optional[str] = None
public_share_url: Optional[str] = None
def bot_install_guides_enabled(settings: Any) -> bool:
return bool(
getattr(settings, "SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED", False)
and subscription_guides_available(settings)
and subscription_mini_app_install_url(settings)
)
def bot_install_guide_url(settings: Any) -> Optional[str]:
if not bot_install_guides_enabled(settings):
return None
return subscription_mini_app_install_url(settings)
async def ensure_user_install_guide_links(
session: AsyncSession,
settings: Any,
user_id: int,
panel_user_uuid: Optional[str] = None,
local_subscription: Optional[Any] = None,
) -> InstallGuideLinks:
personal_url = bot_install_guide_url(settings)
if not personal_url:
return InstallGuideLinks()
public_share_url = None
try:
local_sub = (
local_subscription
if local_subscription is not None
else await subscription_dal.get_active_subscription_by_user_id(
session,
user_id,
panel_user_uuid,
)
)
if local_sub is not None:
share_token = await subscription_dal.ensure_install_share_token(session, local_sub)
public_share_url = subscription_public_install_url(settings, share_token)
except Exception:
logging.exception("Failed to resolve install guide share link for user %s.", user_id)
return InstallGuideLinks(personal_url=personal_url, public_share_url=public_share_url)
def append_install_share_link_text(
text: str,
translator: Any,
public_share_url: Optional[str],
) -> str:
if not public_share_url:
return text
try:
share_line = translator(
"install_guide_share_link_line",
install_share_link=public_share_url,
)
except Exception:
share_line = f"\n\nInstall guide:\n<code>{public_share_url}</code>"
return f"{text}{share_line}"
+35 -1
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from config.settings import Settings
from db.dal.subscription_dal import normalize_install_share_token
def append_query_params(base_url: str, params: dict[str, str]) -> str:
@@ -31,3 +32,36 @@ def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[s
return None
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
return append_query_params(base, {"topup": normalized})
def subscription_mini_app_path_url(settings: Settings, path: str) -> Optional[str]:
"""Return a Mini App URL with ``path`` appended to the configured app base."""
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
if not base:
return None
normalized_path = f"/{str(path or '').lstrip('/')}"
return f"{base.rstrip('/')}{normalized_path}"
def subscription_mini_app_install_url(settings: Settings) -> Optional[str]:
"""Return the personal embedded install guide URL."""
return subscription_mini_app_path_url(settings, "/install")
def subscription_mini_app_trial_url(settings: Settings) -> Optional[str]:
"""Return the trial activation URL inside the Mini App."""
return subscription_mini_app_path_url(settings, "/trial")
def subscription_public_install_url(settings: Settings, share_token: str) -> Optional[str]:
"""Return the public install guide URL for a normalized share token."""
token = normalize_install_share_token(share_token)
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
if not token or not base:
return None
parts = urlsplit(base)
if parts.scheme and parts.netloc:
public_base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
public_base = base.rstrip("/")
return f"{public_base.rstrip('/')}/s/{quote(token)}"
+37
View File
@@ -146,6 +146,43 @@ _NORMALIZED_BANNED_TOKENS = {
_USERNAME_PLACEHOLDER = "клиент"
def looks_like_broken_panel_text(value: Optional[str]) -> bool:
if value is None:
return False
text = unicodedata.normalize("NFKC", str(value)).strip()
if not text:
return False
if "\ufffd" in text:
return True
meaningful = [ch for ch in text if not ch.isspace()]
if len(meaningful) < 2:
return False
question_count = sum(1 for ch in meaningful if ch == "?")
if question_count < 2:
return False
has_content = any(
ch != "?" and not unicodedata.category(ch).startswith("P") for ch in meaningful
)
return not has_content and question_count / len(meaningful) >= 0.5
def panel_description_from_profile(
username: Optional[str],
first_name: Optional[str],
last_name: Optional[str],
) -> str:
lines = []
for value in (username, first_name, last_name):
line = (value or "").strip()
if line and not looks_like_broken_panel_text(line):
lines.append(line)
return "\n".join(lines).strip()
def _normalize_for_detection(value: str) -> str:
if not value:
return ""
File diff suppressed because one or more lines are too long
+53 -4
View File
@@ -164,6 +164,7 @@ class Settings(BaseSettings):
)
STARS_ENABLED: bool = Field(default=True)
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
PAYMENT_METHODS_ORDER: Optional[str] = Field(
default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
@@ -273,6 +274,13 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = Field(default=3)
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
default=None,
description=(
"Comma-separated UUIDs of internal squads to assign during trial activation. "
"Falls back to USER_SQUAD_UUIDS when empty."
),
)
CRYPT4_ENABLED: bool = Field(
default=False, description="Enable happ crypt4 encryption for subscription URLs"
@@ -323,6 +331,35 @@ class Settings(BaseSettings):
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
SUBSCRIPTION_GUIDES_ENABLED: bool = Field(
default=True,
description="Show embedded install instructions inside the subscription Mini App.",
)
SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED: bool = Field(
default=True,
description=(
"Open Mini App install guides from Telegram bot connect buttons and show public "
"install guide share links."
),
)
SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED: bool = Field(
default=True,
description=(
"Use Remnawave Panel Subscription Page config for embedded guides when available."
),
)
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED: bool = Field(
default=False,
description="Enable admin JSON override for embedded guides config.",
)
SUBSCRIPTION_PAGE_CONFIG_PATH: str = Field(
default="data/subpage-config/multiapp.json",
description="Path to Remnawave Subscription Page v1 JSON config for embedded guides.",
)
SUBSCRIPTION_PAGE_CONFIG_JSON: str = Field(
default="",
description="Admin-provided Remnawave Subscription Page v1 JSON config override.",
)
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
@@ -514,6 +551,17 @@ class Settings(BaseSettings):
return [uuid.strip() for uuid in self.USER_SQUAD_UUIDS.split(",") if uuid.strip()]
return None
@computed_field
@property
def parsed_trial_squad_uuids(self) -> Optional[List[str]]:
if self.TRIAL_SQUAD_UUIDS:
trial_squads = [
uuid.strip() for uuid in self.TRIAL_SQUAD_UUIDS.split(",") if uuid.strip()
]
if trial_squads:
return trial_squads
return self.parsed_user_squad_uuids
@computed_field
@property
def parsed_user_external_squad_uuid(self) -> Optional[str]:
@@ -565,13 +613,14 @@ class Settings(BaseSettings):
@property
def stars_subscription_options(self) -> Dict[int, int]:
options: Dict[int, int] = {}
if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
stars_enabled = self.STARS_ENABLED or self.STARS_ADMIN_ONLY_ENABLED
if stars_enabled and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
options[1] = self.STARS_PRICE_1_MONTH
if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
if stars_enabled and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
options[3] = self.STARS_PRICE_3_MONTHS
if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
if stars_enabled and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
options[6] = self.STARS_PRICE_6_MONTHS
if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
if stars_enabled and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
options[12] = self.STARS_PRICE_12_MONTHS
return options
@@ -0,0 +1,669 @@
"""Loader and validator for Remnawave Subscription Page v1 configs."""
from __future__ import annotations
import copy
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
from urllib.parse import urlsplit
class SubscriptionGuidesConfigError(ValueError):
"""Raised when the embedded subscription guides config is invalid."""
APP_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG_PATH = "data/subpage-config/multiapp.json"
DEFAULT_CONFIG_BUNDLED_PATH = (
Path(__file__).resolve().parent / "defaults" / "subscription_page_multiapp.json"
)
ALLOWED_LOCALES = {
"az",
"be",
"de",
"en",
"es",
"fa",
"fr",
"hi",
"id",
"ja",
"pl",
"pt",
"ru",
"th",
"tk",
"tr",
"uk",
"uz",
"vi",
"zh",
}
ALLOWED_PLATFORMS = {
"android",
"androidTV",
"appleTV",
"ios",
"linux",
"macos",
"windows",
}
ALLOWED_BUTTON_TYPES = {"copyButton", "external", "subscriptionLink"}
BASE_TRANSLATION_KEYS = (
"active",
"bandwidth",
"connectionKeysHeader",
"copyLink",
"expired",
"expires",
"expiresIn",
"getLink",
"inactive",
"indefinitely",
"installationGuideHeader",
"linkCopied",
"linkCopiedToClipboard",
"name",
"scanQrCode",
"scanQrCodeDescription",
"scanToImport",
"status",
"unknown",
)
ALLOWED_SVG_COLORS = {
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"slate",
"gray",
"zinc",
"neutral",
"stone",
}
UI_SUBSCRIPTION_INFO_TYPES = {"cards", "collapsed", "expanded", "hidden"}
UI_INSTALLATION_GUIDE_TYPES = {"accordion", "cards", "minimal", "timeline"}
SVG_KEY_RE = re.compile(r"^[A-Za-z]+$")
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
UNSAFE_SVG_RE = re.compile(
r"(<\s*/?\s*(?:script|foreignObject|iframe|object|embed|image|use|style|a)\b)"
r"|(\son[a-z]+\s*=)"
r"|(javascript\s*:)"
r"|(data\s*:)",
re.IGNORECASE,
)
_CONFIG_CACHE: Dict[Tuple[str, str], Dict[str, Any]] = {}
PANEL_CONFIG_KEYS = (
"config",
"subscriptionPageConfig",
"subpageConfig",
"subPageConfig",
"pageConfig",
)
PANEL_WRAPPER_KEYS = ("response", "data", "result", *PANEL_CONFIG_KEYS)
def validate_subscription_guides_config_text(raw: str) -> Dict[str, Any]:
"""Parse and validate a v1 subscription guides config JSON string."""
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise SubscriptionGuidesConfigError(f"Invalid JSON: {exc.msg}") from exc
return validate_subscription_guides_config(payload)
def default_subscription_guides_config_text() -> str:
try:
return DEFAULT_CONFIG_BUNDLED_PATH.read_text(encoding="utf-8")
except OSError as exc:
raise SubscriptionGuidesConfigError(
f"Bundled default config is unavailable: {exc}"
) from exc
def resolve_subscription_guides_config_path(settings: Any) -> Path:
configured_path = str(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", DEFAULT_CONFIG_PATH)
or DEFAULT_CONFIG_PATH
).strip()
if not configured_path:
raise SubscriptionGuidesConfigError("SUBSCRIPTION_PAGE_CONFIG_PATH is empty")
path = Path(configured_path)
if not path.is_absolute():
path = APP_ROOT / path
return path
def ensure_subscription_guides_config_file(settings: Any) -> Path:
path = resolve_subscription_guides_config_path(settings)
if not path.exists():
raise SubscriptionGuidesConfigError(f"Config file does not exist: {path}")
return path
def subscription_guides_admin_config_json(settings: Any) -> Tuple[str, str]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
if admin_json:
return admin_json, "admin_json"
return "", "empty"
def load_subscription_guides_config(settings: Any) -> Tuple[Dict[str, Any], str]:
"""Load the enabled guides config from admin JSON or a configured file path."""
source, raw = _read_config_source(settings)
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
cache_key = (source, digest)
cached = _CONFIG_CACHE.get(cache_key)
if cached is not None:
return copy.deepcopy(cached), source
config = validate_subscription_guides_config_text(raw)
_CONFIG_CACHE[cache_key] = copy.deepcopy(config)
return config, source
def subscription_guides_status(settings: Any) -> Dict[str, Any]:
"""Return a safe status payload for user-facing guide availability checks."""
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return {"enabled": False, "config": None, "source": None, "error": None}
try:
config, source = load_subscription_guides_config(settings)
except SubscriptionGuidesConfigError as exc:
return {"enabled": False, "config": None, "source": None, "error": str(exc)}
return {"enabled": True, "config": config, "source": source, "error": None}
def subscription_guides_available(settings: Any) -> bool:
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return False
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
if (
not (
admin_json
and bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False))
)
and bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True))
and getattr(settings, "PANEL_API_URL", None)
and getattr(settings, "PANEL_API_KEY", None)
):
return True
status = subscription_guides_status(settings)
return bool(status.get("enabled") and status.get("config"))
def extract_subscription_guides_config_from_panel(payload: Any) -> Any:
"""Extract Subscription Page v1 config from flexible Panel API response shapes."""
return _extract_config_candidate(payload, set())
def validate_panel_subscription_guides_config(
payload: Any,
*,
allow_default_when_missing: bool = False,
) -> Dict[str, Any]:
config = extract_subscription_guides_config_from_panel(payload)
if config is None:
if allow_default_when_missing and panel_subscription_page_allowed(payload):
default_text = default_subscription_guides_config_text()
return validate_subscription_guides_config_text(default_text)
raise SubscriptionGuidesConfigError("Panel response does not contain a v1 config")
return validate_subscription_guides_config(config)
def panel_subscription_page_allowed(payload: Any) -> bool:
candidate = _find_panel_response_object(payload, set())
return bool(candidate and candidate.get("webpageAllowed") is True)
def validate_subscription_guides_config(payload: Any) -> Dict[str, Any]:
if not isinstance(payload, Mapping):
raise SubscriptionGuidesConfigError("Config root must be an object")
if payload.get("version") != "1":
raise SubscriptionGuidesConfigError(
"Only Subscription Page config version '1' is supported"
)
locales = _validate_locales(payload.get("locales"))
svg_library = _validate_svg_library(payload.get("svgLibrary"))
branding = _validate_branding(payload.get("brandingSettings"))
ui_config = _validate_ui_config(payload.get("uiConfig"))
base_settings = _validate_base_settings(payload.get("baseSettings"))
base_translations = _validate_base_translations(payload.get("baseTranslations"), locales)
platforms = _validate_platforms(payload.get("platforms"), locales, svg_library)
return {
"version": "1",
"locales": locales,
"brandingSettings": branding,
"uiConfig": ui_config,
"baseSettings": base_settings,
"baseTranslations": base_translations,
"svgLibrary": svg_library,
"platforms": platforms,
}
def _read_config_source(settings: Any) -> Tuple[str, str]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
json_override_enabled = bool(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
)
if admin_json and json_override_enabled:
return "admin_json", admin_json
path = ensure_subscription_guides_config_file(settings)
try:
return "file", path.read_text(encoding="utf-8")
except OSError as exc:
raise SubscriptionGuidesConfigError(f"Failed to read config file: {exc}") from exc
def _extract_config_candidate(value: Any, seen: set[int]) -> Any:
if isinstance(value, str):
text = value.strip()
if not text.startswith(("{", "[")):
return None
try:
return _extract_config_candidate(json.loads(text), seen)
except json.JSONDecodeError as exc:
raise SubscriptionGuidesConfigError(f"Invalid JSON in Panel config: {exc.msg}") from exc
if not isinstance(value, Mapping):
return None
value_id = id(value)
if value_id in seen:
return None
seen.add(value_id)
if _looks_like_v1_config(value):
return value
for key in PANEL_WRAPPER_KEYS:
if key not in value:
continue
candidate = _extract_config_candidate(value.get(key), seen)
if candidate is not None:
return candidate
return None
def _looks_like_v1_config(value: Mapping[str, Any]) -> bool:
return (
value.get("version") == "1"
and "locales" in value
and "svgLibrary" in value
and "platforms" in value
)
def _find_panel_response_object(value: Any, seen: set[int]) -> Optional[Mapping[str, Any]]:
if not isinstance(value, Mapping):
return None
value_id = id(value)
if value_id in seen:
return None
seen.add(value_id)
if "webpageAllowed" in value:
return value
for key in ("response", "data", "result"):
candidate = _find_panel_response_object(value.get(key), seen)
if candidate is not None:
return candidate
return None
def _validate_locales(value: Any) -> list[str]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError("locales must be a non-empty array")
locales: list[str] = []
for index, item in enumerate(value):
locale = str(item or "").strip()
if locale not in ALLOWED_LOCALES:
raise SubscriptionGuidesConfigError(f"Unsupported locale at locales[{index}]: {locale}")
if locale not in locales:
locales.append(locale)
return locales
def _validate_branding(value: Any) -> Dict[str, str]:
data = _require_object(value, "brandingSettings")
result = {
"title": _require_text(data, "title", "brandingSettings.title"),
"logoUrl": _require_text(data, "logoUrl", "brandingSettings.logoUrl"),
"supportUrl": _require_text(data, "supportUrl", "brandingSettings.supportUrl"),
}
_assert_http_url(result["logoUrl"], "brandingSettings.logoUrl")
_assert_http_url(result["supportUrl"], "brandingSettings.supportUrl")
return result
def _validate_ui_config(value: Any) -> Dict[str, str]:
data = _require_object(value, "uiConfig")
subscription_info = _require_text(
data,
"subscriptionInfoBlockType",
"uiConfig.subscriptionInfoBlockType",
)
installation_guides = _require_text(
data,
"installationGuidesBlockType",
"uiConfig.installationGuidesBlockType",
)
if subscription_info not in UI_SUBSCRIPTION_INFO_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported uiConfig.subscriptionInfoBlockType: {subscription_info}"
)
if installation_guides not in UI_INSTALLATION_GUIDE_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported uiConfig.installationGuidesBlockType: {installation_guides}"
)
return {
"subscriptionInfoBlockType": subscription_info,
"installationGuidesBlockType": installation_guides,
}
def _validate_base_settings(value: Any) -> Dict[str, Any]:
data = value if isinstance(value, Mapping) else {}
return {
"metaTitle": _optional_text(data, "metaTitle") or "Subscription",
"metaDescription": _optional_text(data, "metaDescription") or "Subscription",
"showConnectionKeys": bool(data.get("showConnectionKeys", False)),
"hideGetLinkButton": bool(data.get("hideGetLinkButton", False)),
}
def _validate_base_translations(value: Any, locales: Iterable[str]) -> Dict[str, Dict[str, str]]:
data = _require_object(value, "baseTranslations")
result: Dict[str, Dict[str, str]] = {}
for key in BASE_TRANSLATION_KEYS:
result[key] = _validate_locale_strings(
data.get(key),
locales,
f"baseTranslations.{key}",
)
return result
def _validate_svg_library(value: Any) -> Dict[str, str]:
data = _require_object(value, "svgLibrary")
if not data:
raise SubscriptionGuidesConfigError("svgLibrary must not be empty")
result: Dict[str, str] = {}
for key, raw_svg in data.items():
svg_key = str(key or "").strip()
if not SVG_KEY_RE.fullmatch(svg_key):
raise SubscriptionGuidesConfigError(f"Invalid svgLibrary key: {svg_key}")
result[svg_key] = _sanitize_svg(raw_svg, f"svgLibrary.{svg_key}")
return result
def _validate_platforms(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
) -> Dict[str, Dict[str, Any]]:
data = _require_object(value, "platforms")
if not data:
raise SubscriptionGuidesConfigError("platforms must not be empty")
result: Dict[str, Dict[str, Any]] = {}
for platform_key, raw_platform in data.items():
key = str(platform_key or "").strip()
if key not in ALLOWED_PLATFORMS:
raise SubscriptionGuidesConfigError(f"Unsupported platform: {key}")
platform = _require_object(raw_platform, f"platforms.{key}")
icon_key = _validate_svg_icon_key(
platform.get("svgIconKey"),
svg_library,
f"platforms.{key}.svgIconKey",
)
apps = _validate_apps(platform.get("apps"), locales, svg_library, f"platforms.{key}.apps")
result[key] = {
"displayName": _validate_localized_or_text(
platform.get("displayName"),
locales,
f"platforms.{key}.displayName",
),
"svgIconKey": icon_key,
"apps": apps,
}
return result
def _validate_apps(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError(f"{path} must be a non-empty array")
apps: list[Dict[str, Any]] = []
for index, raw_app in enumerate(value):
app_path = f"{path}[{index}]"
app = _require_object(raw_app, app_path)
name = _require_text(app, "name", f"{app_path}.name")
if len(name) < 2:
raise SubscriptionGuidesConfigError(f"{app_path}.name must contain at least 2 chars")
icon_key = _optional_svg_icon_key(
app.get("svgIconKey"),
svg_library,
f"{app_path}.svgIconKey",
)
apps.append(
{
"name": name,
"svgIconKey": icon_key,
"featured": bool(app.get("featured", False)),
"blocks": _validate_blocks(
app.get("blocks"),
locales,
svg_library,
f"{app_path}.blocks",
),
}
)
return apps
def _validate_blocks(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError(f"{path} must be a non-empty array")
blocks: list[Dict[str, Any]] = []
for index, raw_block in enumerate(value):
block_path = f"{path}[{index}]"
block = _require_object(raw_block, block_path)
color = _optional_text(block, "svgIconColor")
if color and color not in ALLOWED_SVG_COLORS and not HEX_COLOR_RE.fullmatch(color):
raise SubscriptionGuidesConfigError(f"{block_path}.svgIconColor is invalid")
blocks.append(
{
"svgIconKey": _validate_svg_icon_key(
block.get("svgIconKey"),
svg_library,
f"{block_path}.svgIconKey",
),
"svgIconColor": color or "",
"title": _validate_locale_strings(
block.get("title"),
locales,
f"{block_path}.title",
),
"description": _validate_locale_strings(
block.get("description"),
locales,
f"{block_path}.description",
),
"buttons": _validate_buttons(
block.get("buttons"),
locales,
svg_library,
f"{block_path}.buttons",
),
}
)
return blocks
def _validate_buttons(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise SubscriptionGuidesConfigError(f"{path} must be an array")
buttons: list[Dict[str, Any]] = []
for index, raw_button in enumerate(value):
button_path = f"{path}[{index}]"
button = _require_object(raw_button, button_path)
button_type = _require_text(button, "type", f"{button_path}.type")
if button_type not in ALLOWED_BUTTON_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported button type at {button_path}: {button_type}"
)
link = _require_text(button, "link", f"{button_path}.link")
_validate_button_link(link, button_type, f"{button_path}.link")
buttons.append(
{
"type": button_type,
"link": link,
"text": _validate_locale_strings(
button.get("text"),
locales,
f"{button_path}.text",
),
"svgIconKey": _validate_svg_icon_key(
button.get("svgIconKey"),
svg_library,
f"{button_path}.svgIconKey",
),
}
)
return buttons
def _validate_locale_strings(value: Any, locales: Iterable[str], path: str) -> Dict[str, str]:
data = _require_object(value, path)
result: Dict[str, str] = {}
for locale in locales:
text = data.get(locale)
if not isinstance(text, str) or not text.strip():
raise SubscriptionGuidesConfigError(f"{path}.{locale} is required")
result[locale] = text.strip()
return result
def _validate_localized_or_text(
value: Any,
locales: Iterable[str],
path: str,
) -> str | Dict[str, str]:
if isinstance(value, str):
text = value.strip()
if text:
return text
return _validate_locale_strings(value, locales, path)
def _validate_svg_icon_key(value: Any, svg_library: Mapping[str, str], path: str) -> str:
key = _string_value(value)
if not key:
raise SubscriptionGuidesConfigError(f"{path} is required")
if key not in svg_library:
raise SubscriptionGuidesConfigError(f"{path} references missing svgLibrary key: {key}")
return key
def _optional_svg_icon_key(value: Any, svg_library: Mapping[str, str], path: str) -> Optional[str]:
key = _string_value(value)
if not key:
return None
if key not in svg_library:
raise SubscriptionGuidesConfigError(f"{path} references missing svgLibrary key: {key}")
return key
def _validate_button_link(link: str, _button_type: str, path: str) -> None:
_assert_safe_link(link, path)
def _assert_safe_link(value: str, path: str) -> None:
if CONTROL_CHARS_RE.search(value):
raise SubscriptionGuidesConfigError(f"{path} contains control characters")
lower = value.strip().lower()
if lower.startswith(("javascript:", "data:", "vbscript:")):
raise SubscriptionGuidesConfigError(f"{path} uses an unsafe URL scheme")
def _assert_http_url(value: str, path: str) -> None:
_assert_safe_link(value, path)
parts = urlsplit(value)
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise SubscriptionGuidesConfigError(f"{path} must be an http(s) URL")
def _sanitize_svg(value: Any, path: str) -> str:
svg = _string_value(value)
if not svg:
raise SubscriptionGuidesConfigError(f"{path} is required")
trimmed = svg.strip()
if not trimmed.lower().startswith("<svg"):
raise SubscriptionGuidesConfigError(f"{path} must be an SVG document")
if UNSAFE_SVG_RE.search(trimmed):
raise SubscriptionGuidesConfigError(f"{path} contains unsafe SVG markup")
return trimmed
def _require_object(value: Any, path: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise SubscriptionGuidesConfigError(f"{path} must be an object")
return value
def _require_text(data: Mapping[str, Any], key: str, path: str) -> str:
value = _string_value(data.get(key))
if not value:
raise SubscriptionGuidesConfigError(f"{path} is required")
return value
def _optional_text(data: Mapping[str, Any], key: str) -> str:
return _string_value(data.get(key))
def _string_value(value: Any) -> str:
if not isinstance(value, str):
return ""
return value.strip()
+26
View File
@@ -25,6 +25,8 @@ class TrafficPackage(BaseModel):
class HwidDevicePackage(BaseModel):
count: int
price: float
prices: Dict[str, float] = Field(default_factory=dict)
min_price: Optional[float] = None
@model_validator(mode="after")
def validate_values(self) -> "HwidDevicePackage":
@@ -32,8 +34,32 @@ class HwidDevicePackage(BaseModel):
raise ValueError("device package count must be greater than zero")
if self.price < 0:
raise ValueError("device package price must be non-negative")
normalized_prices: Dict[str, float] = {}
for period, value in (self.prices or {}).items():
period_key = str(period).strip()
if not period_key:
raise ValueError("device package price period must not be empty")
try:
period_months = int(period_key)
except (TypeError, ValueError) as exc:
raise ValueError("device package price period must be an integer") from exc
if period_months <= 0:
raise ValueError("device package price period must be positive")
if float(value) < 0:
raise ValueError("device package period price must be non-negative")
normalized_prices[str(period_months)] = float(value)
self.prices = normalized_prices
if self.min_price is not None and self.min_price < 0:
raise ValueError("device package min_price must be non-negative")
return self
def price_for_period(self, months: int) -> float:
months_int = max(1, int(months or 1))
value = self.prices.get(str(months_int))
if value is not None:
return float(value)
return float(self.price) * months_int
class PackageSet(BaseModel):
rub: List[TrafficPackage] = Field(default_factory=list)
+8 -1
View File
@@ -365,7 +365,11 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
except OSError:
return True
if key == "light":
return "--success-text" not in style or ".theme-key-light.app-shell" not in style
return (
"--success-text" not in style
or ".theme-key-light.app-shell" not in style
or "Install guide theme surfaces" not in style
)
if key == "ascii":
return (
".theme-key-ascii" not in style
@@ -374,6 +378,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "ascii-boot-type" not in style
or "Console-style tables" not in style
or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
)
if key != "windows95":
return False
@@ -394,7 +399,9 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "::-webkit-slider-thumb" not in style
or "?v=9" not in style
or "lucide-life-buoy" not in style
or "lucide-qr-code" not in style
or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
)
+2
View File
@@ -1,6 +1,7 @@
from . import (
ad_dal,
app_settings_dal,
locale_overrides_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
@@ -23,5 +24,6 @@ __all__ = (
"ad_dal",
"security_dal",
"app_settings_dal",
"locale_overrides_dal",
"support_dal",
)
+86
View File
@@ -0,0 +1,86 @@
"""Persistent overrides for localization strings."""
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import LocaleOverride
async def get_all_overrides(session: AsyncSession) -> Dict[str, Dict[str, str]]:
rows = (await session.execute(select(LocaleOverride))).scalars().all()
result: Dict[str, Dict[str, str]] = {}
for row in rows:
result.setdefault(row.lang, {})[row.key] = row.value
return result
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, object]]:
rows = (await session.execute(select(LocaleOverride))).scalars().all()
items: List[Dict[str, object]] = []
for row in rows:
items.append(
{
"lang": row.lang,
"key": row.key,
"value": row.value,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": row.updated_by,
}
)
return items
async def upsert_override(
session: AsyncSession,
*,
lang: str,
key: str,
value: str,
updated_by: Optional[int],
) -> None:
now = datetime.now(timezone.utc)
stmt = (
pg_insert(LocaleOverride)
.values(lang=lang, key=key, value=value, updated_at=now, updated_by=updated_by)
.on_conflict_do_update(
index_elements=[LocaleOverride.lang, LocaleOverride.key],
set_={
"value": value,
"updated_at": now,
"updated_by": updated_by,
},
)
)
await session.execute(stmt)
async def delete_override(session: AsyncSession, *, lang: str, key: str) -> bool:
stmt = delete(LocaleOverride).where(
LocaleOverride.lang == lang,
LocaleOverride.key == key,
)
result = await session.execute(stmt)
return bool(result.rowcount or 0)
async def bulk_apply(
session: AsyncSession,
*,
updates: Dict[Tuple[str, str], Tuple[bool, str]],
updated_by: Optional[int],
) -> None:
for (lang, key), (set_flag, value) in updates.items():
if set_flag:
await upsert_override(
session,
lang=lang,
key=key,
value=value,
updated_by=updated_by,
)
else:
await delete_override(session, lang=lang, key=key)
+9 -1
View File
@@ -4,6 +4,7 @@ from typing import List, Optional
from sqlalchemy import func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from ..models import MessageLog
@@ -22,7 +23,13 @@ async def create_message_log(session: AsyncSession, log_data: dict) -> Optional[
async def get_all_message_logs(session: AsyncSession, limit: int, offset: int) -> List[MessageLog]:
stmt = select(MessageLog).order_by(MessageLog.timestamp.desc()).limit(limit).offset(offset)
stmt = (
select(MessageLog)
.options(selectinload(MessageLog.author_user), selectinload(MessageLog.target_user))
.order_by(MessageLog.timestamp.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return result.scalars().all()
@@ -38,6 +45,7 @@ async def get_user_message_logs(
) -> List[MessageLog]:
stmt = (
select(MessageLog)
.options(selectinload(MessageLog.author_user), selectinload(MessageLog.target_user))
.where(
or_(
MessageLog.user_id == user_id_to_search,
+60
View File
@@ -86,6 +86,66 @@ async def get_payment_by_db_id(session: AsyncSession, payment_db_id: int) -> Opt
return result.scalar_one_or_none()
async def find_recent_pending_provider_payment(
session: AsyncSession,
*,
user_id: int,
provider: str,
pending_status: str,
amount: float,
sale_mode: Optional[str],
months: Optional[int],
purchased_gb: Optional[float],
purchased_hwid_devices: Optional[int],
tariff_key: Optional[str] = None,
since_minutes: int = 60,
) -> Optional[Payment]:
"""Return the most recent pending payment matching the given tariff parameters.
Used to reuse an existing provider payment link instead of creating a new one
on repeated user clicks. Only payments with a populated ``provider_payment_id``
are returned without it, there's no link to reuse.
"""
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
conditions = [
Payment.user_id == user_id,
Payment.provider == provider,
Payment.status == pending_status,
Payment.provider_payment_id.isnot(None),
Payment.created_at >= cutoff,
func.abs(Payment.amount - float(amount)) < 0.01,
]
if sale_mode is not None:
conditions.append(Payment.sale_mode == sale_mode)
if tariff_key is not None:
conditions.append(Payment.tariff_key == tariff_key)
if months is not None:
conditions.append(Payment.subscription_duration_months == months)
else:
conditions.append(Payment.subscription_duration_months.is_(None))
if purchased_gb is not None:
conditions.append(func.abs(Payment.purchased_gb - float(purchased_gb)) < 0.0001)
else:
conditions.append(Payment.purchased_gb.is_(None))
if purchased_hwid_devices is not None:
conditions.append(Payment.purchased_hwid_devices == purchased_hwid_devices)
else:
conditions.append(Payment.purchased_hwid_devices.is_(None))
stmt = (
select(Payment)
.where(and_(*conditions))
.options(joinedload(Payment.user), joinedload(Payment.promo_code_used))
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_payment_status_by_db_id(
session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None
) -> Optional[Payment]:
+78
View File
@@ -1,4 +1,6 @@
import logging
import re
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
@@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload
from db.models import Subscription
INSTALL_SHARE_TOKEN_BYTES = 16
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
model_columns = Subscription.__mapper__.columns.keys()
@@ -42,6 +46,80 @@ async def get_subscription_by_panel_subscription_uuid(
return result.scalar_one_or_none()
def normalize_install_share_token(value: Any) -> str:
token = str(value or "").strip().lower()
if not re.fullmatch(r"[a-f0-9]{32}", token):
return ""
return token
async def get_subscription_by_install_share_token(
session: AsyncSession,
token: str,
) -> Optional[Subscription]:
normalized = normalize_install_share_token(token)
if not normalized:
return None
stmt = select(Subscription).where(Subscription.install_share_token == normalized)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def ensure_install_share_token(
session: AsyncSession,
subscription: Subscription,
) -> str:
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
existing = normalize_install_share_token(raw_existing)
if existing:
if existing != getattr(subscription, "install_share_token", None):
subscription.install_share_token = existing
await session.flush()
return existing
subscription_id = getattr(subscription, "subscription_id", None)
for _attempt in range(10):
token = secrets.token_hex(INSTALL_SHARE_TOKEN_BYTES)
if await get_subscription_by_install_share_token(session, token):
continue
if subscription_id:
result = await session.execute(
update(Subscription)
.where(
Subscription.subscription_id == subscription_id,
or_(
Subscription.install_share_token.is_(None),
Subscription.install_share_token == "",
Subscription.install_share_token == raw_existing,
),
)
.values(install_share_token=token)
)
await session.flush()
if result.rowcount:
await session.refresh(subscription)
return (
normalize_install_share_token(
getattr(subscription, "install_share_token", None)
)
or token
)
await session.refresh(subscription)
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
existing = normalize_install_share_token(raw_existing)
if existing:
return existing
continue
subscription.install_share_token = token
await session.flush()
await session.refresh(subscription)
return token
raise RuntimeError("Failed to generate a unique install share token")
async def get_active_subscriptions_for_user(
session: AsyncSession, user_id: int
) -> List[Subscription]:
+149 -2
View File
@@ -1,9 +1,11 @@
import inspect
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, select
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import HwidDevicePurchase, TariffChange, TrafficTopup, TrafficWarning
from db.models import HwidDevicePurchase, Payment, TariffChange, TrafficTopup, TrafficWarning
async def create_traffic_topup(
@@ -26,17 +28,41 @@ async def create_traffic_topup(
return record
async def sum_traffic_topups(
session: AsyncSession,
*,
subscription_id: int,
kinds: Optional[List[str]] = None,
created_at_gte=None,
) -> int:
conditions = [TrafficTopup.subscription_id == subscription_id]
if kinds:
conditions.append(TrafficTopup.kind.in_(list(kinds)))
if created_at_gte is not None:
conditions.append(TrafficTopup.created_at >= created_at_gte)
result = await session.execute(
select(func.coalesce(func.sum(TrafficTopup.purchased_bytes), 0)).where(
and_(*conditions)
)
)
return int(result.scalar() or 0)
async def create_hwid_device_purchase(
session: AsyncSession,
*,
subscription_id: int,
payment_id: Optional[int],
purchased_devices: int,
valid_from: Optional[datetime] = None,
valid_until: Optional[datetime] = None,
) -> HwidDevicePurchase:
record = HwidDevicePurchase(
subscription_id=subscription_id,
payment_id=payment_id,
purchased_devices=purchased_devices,
valid_from=valid_from or datetime.now(timezone.utc),
valid_until=valid_until,
)
session.add(record)
await session.flush()
@@ -44,6 +70,127 @@ async def create_hwid_device_purchase(
return record
def _hwid_active_conditions(subscription_id: int, at: datetime) -> List[Any]:
return [
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_from.is_(None), HwidDevicePurchase.valid_from <= at),
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
]
async def _resolve_result_value(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
return value
async def sum_active_hwid_devices(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> int:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0)).where(
and_(*_hwid_active_conditions(subscription_id, at))
)
)
return int(await _resolve_result_value(result.scalar()) or 0)
async def get_hwid_device_entitlement_summary(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> Dict[str, Any]:
at = at or datetime.now(timezone.utc)
active_result = await session.execute(
select(
func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0),
func.max(HwidDevicePurchase.valid_until),
).where(and_(*_hwid_active_conditions(subscription_id, at)))
)
active_devices, active_until = await _resolve_result_value(active_result.one())
future_result = await session.execute(
select(func.min(HwidDevicePurchase.valid_from)).where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
HwidDevicePurchase.valid_from > at,
)
)
)
return {
"active_devices": int(active_devices or 0),
"active_until": active_until,
"next_valid_from": await _resolve_result_value(future_result.scalar_one_or_none()),
}
async def get_hwid_device_value_entries(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(
HwidDevicePurchase.purchase_id,
HwidDevicePurchase.purchased_devices,
HwidDevicePurchase.valid_from,
HwidDevicePurchase.valid_until,
HwidDevicePurchase.created_at,
Payment.amount,
Payment.currency,
)
.outerjoin(Payment, Payment.payment_id == HwidDevicePurchase.payment_id)
.where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
)
)
)
entries = []
rows = await _resolve_result_value(result.all())
for row in rows:
entries.append(
{
"purchase_id": row[0],
"purchased_devices": row[1],
"valid_from": row[2],
"valid_until": row[3],
"created_at": row[4],
"amount": row[5],
"currency": row[6],
}
)
return entries
async def expire_hwid_device_purchases(
session: AsyncSession,
*,
purchase_ids: List[int],
at: Optional[datetime] = None,
) -> int:
ids = [int(item) for item in purchase_ids if item is not None]
if not ids:
return 0
at = at or datetime.now(timezone.utc)
result = await session.execute(
update(HwidDevicePurchase)
.where(HwidDevicePurchase.purchase_id.in_(ids))
.values(valid_until=at)
)
return result.rowcount or 0
async def create_tariff_change(
session: AsyncSession,
change_data: Dict[str, Any],
+95 -4
View File
@@ -12,10 +12,17 @@ from sqlalchemy.orm import aliased
from ..models import (
AdAttribution,
EmailVerificationCode,
HwidDevicePurchase,
MessageLog,
Payment,
PromoCodeActivation,
Subscription,
SupportTicket,
SupportTicketMessage,
TariffChange,
TrafficTopup,
TrafficWarning,
User,
UserBilling,
UserPaymentMethod,
@@ -583,6 +590,40 @@ async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
return result.scalars().all()
async def get_panel_user_uuids_for_user(
session: AsyncSession,
user_id: int,
*,
user: Optional[User] = None,
) -> List[str]:
"""Return every Remnawave user UUID linked to a bot user.
The canonical UUID normally lives on ``users.panel_user_uuid``, but older
or partially-synced records can still have UUIDs only on subscription rows.
"""
if user is None:
user = await get_user_by_id(session, user_id)
panel_uuids: List[str] = []
seen: set[str] = set()
def add_uuid(value: Any) -> None:
panel_uuid = str(value or "").strip()
if panel_uuid and panel_uuid not in seen:
seen.add(panel_uuid)
panel_uuids.append(panel_uuid)
add_uuid(getattr(user, "panel_user_uuid", None))
stmt = select(Subscription.panel_user_uuid).where(Subscription.user_id == user_id)
result = await session.execute(stmt)
for panel_uuid in result.scalars().all():
add_uuid(panel_uuid)
return panel_uuids
async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive user statistics including active users, trial users, etc."""
from datetime import datetime, timezone
@@ -706,19 +747,69 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
)
# Clean up dependent tables that do not cascade automatically
subscription_ids = select(Subscription.subscription_id).where(Subscription.user_id == user_id)
payment_ids = select(Payment.payment_id).where(Payment.user_id == user_id)
support_ticket_ids = select(SupportTicket.ticket_id).where(SupportTicket.user_id == user_id)
# Clean up dependent tables that do not cascade automatically.
await session.execute(
delete(TrafficTopup).where(
or_(
TrafficTopup.subscription_id.in_(subscription_ids),
TrafficTopup.payment_id.in_(payment_ids),
)
)
)
await session.execute(
delete(HwidDevicePurchase).where(
or_(
HwidDevicePurchase.subscription_id.in_(subscription_ids),
HwidDevicePurchase.payment_id.in_(payment_ids),
)
)
)
await session.execute(
delete(TariffChange).where(
or_(
TariffChange.subscription_id.in_(subscription_ids),
TariffChange.payment_id.in_(payment_ids),
)
)
)
await session.execute(
delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids))
)
await session.execute(
delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids))
)
await session.execute(
update(SupportTicketMessage)
.where(SupportTicketMessage.author_user_id == user_id)
.values(author_user_id=None)
)
await session.execute(delete(SupportTicket).where(SupportTicket.user_id == user_id))
await session.execute(
delete(EmailVerificationCode).where(EmailVerificationCode.target_user_id == user_id)
)
await session.execute(
delete(MessageLog).where(
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
)
)
await session.execute(delete(Payment).where(Payment.user_id == user_id))
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
await session.execute(delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id))
await session.execute(
delete(PromoCodeActivation).where(
or_(
PromoCodeActivation.user_id == user_id,
PromoCodeActivation.payment_id.in_(payment_ids),
)
)
)
await session.execute(delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id))
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
await session.execute(delete(Payment).where(Payment.user_id == user_id))
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
await session.delete(user)
await session.flush()
+3 -2
View File
@@ -1,5 +1,6 @@
import logging
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
@@ -10,6 +11,7 @@ from db.models import Base
from .migrator import run_database_migrations
async_engine = None
DB_INIT_ADVISORY_LOCK_ID = 817512404897421337
def redacted_database_url(database_url: str) -> str:
@@ -71,6 +73,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
)
async with async_engine.begin() as conn:
await conn.execute(text(f"SELECT pg_advisory_xact_lock({DB_INIT_ADVISORY_LOCK_ID})"))
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_database_migrations)
logging.info("PostgreSQL database initialized/checked successfully using SQLAlchemy.")
@@ -83,8 +86,6 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
logging.warning(f"Failed to load setting overrides on startup: {e_overrides}")
async with session_factory() as session:
from sqlalchemy import text
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
try:

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