Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30e729f1f | ||
|
|
1e63431ae3 | ||
|
|
072e7273c4 | ||
|
|
df079138ee | ||
|
|
87f32114d7 | ||
|
|
b330e604f6 | ||
|
|
827d69231c | ||
|
|
0623850c3f | ||
|
|
4c77d129e7 | ||
|
|
c68cb97964 | ||
|
|
59fa301344 | ||
|
|
23784d00fe | ||
|
|
17cff74b7c | ||
|
|
2a3a3c21ba | ||
|
|
ba7811621e | ||
|
|
340afe1b80 | ||
|
|
11b7823188 | ||
|
|
de1763cfe1 | ||
|
|
d30b069876 | ||
|
|
0d68da9624 | ||
|
|
4d857b386b | ||
|
|
be7a3bc153 | ||
|
|
5c71fc0de2 | ||
|
|
82cc33587c | ||
|
|
121f3c6ddf | ||
|
|
3410eeaec1 | ||
|
|
85ecd644b8 | ||
|
|
7cffb4667f | ||
|
|
0449ded505 | ||
|
|
e15cbffdb1 | ||
|
|
9674c674a2 | ||
|
|
bafc5f4709 | ||
|
|
a60de46173 | ||
|
|
1165dc3fa6 | ||
|
|
f5ab18a67b | ||
|
|
65db4aaff6 | ||
|
|
d9a23235e5 | ||
|
|
00b54e1f15 | ||
|
|
3c4ff66150 | ||
|
|
2b3078fcce | ||
|
|
6f4074e8d6 | ||
|
|
235ee25d9f | ||
|
|
d1b9990bac | ||
|
|
60d1ba4efc | ||
|
|
29b6e6eb24 | ||
|
|
aa65972483 | ||
|
|
ad275a7c83 | ||
|
|
02b57ead00 | ||
|
|
7f484fa7b3 | ||
|
|
3ea906a74d | ||
|
|
939c40ccbf | ||
|
|
f0eb291f56 | ||
|
|
46391b10e2 | ||
|
|
5918a6cc71 | ||
|
|
0eeabc7b3a | ||
|
|
31eb5c06ad | ||
|
|
60d8c297f9 | ||
|
|
dab70d5a97 | ||
|
|
520a6289a8 | ||
|
|
7adca53116 | ||
|
|
fbc3e6c084 | ||
|
|
19ba5c8f11 | ||
|
|
ce0d4dccf7 | ||
|
|
7c874cd4aa | ||
|
|
17224b4f74 | ||
|
|
40414264be | ||
|
|
d9a4a007e2 | ||
|
|
d3925cad22 | ||
|
|
443e2e62db | ||
|
|
4b7c58a6c9 | ||
|
|
19aa2ec9c9 | ||
|
|
c4c5b8e3a0 | ||
|
|
33a7dbc0e6 | ||
|
|
72921b9a8f | ||
|
|
835436fa1a | ||
|
|
648f4ba4bc | ||
|
|
f5023dc46b | ||
|
|
4c0a798050 | ||
|
|
cfe7c4ec5f | ||
|
|
c82779e15b | ||
|
|
9e56715e77 | ||
|
|
2254b9ad19 | ||
|
|
81707d9c7c | ||
|
|
9e61a3d8a8 | ||
|
|
8a38524774 | ||
|
|
87d3f1b410 |
@@ -16,6 +16,7 @@ frontend/node_modules/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
|
||||
# CI
|
||||
|
||||
@@ -63,3 +63,4 @@ locales/en_backup.json
|
||||
db/models_old.py
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
!data/locales-overrides.example.json
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
Copyright 2025 machka-pasla
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,16 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import (
|
||||
_build_admin_webapp_referral_link,
|
||||
_error,
|
||||
_ok,
|
||||
_premium_traffic_list_payload,
|
||||
_read_json,
|
||||
_serialize_payment,
|
||||
_serialize_subscription,
|
||||
_serialize_user,
|
||||
)
|
||||
|
||||
import hashlib
|
||||
from html import escape as html_escape
|
||||
@@ -861,18 +872,62 @@ async def admin_user_delete_route(request: web.Request) -> web.Response:
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is None:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found")
|
||||
|
||||
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
|
||||
session,
|
||||
target_id,
|
||||
user=user,
|
||||
)
|
||||
if panel_user_uuids and panel_service is None:
|
||||
await session.rollback()
|
||||
return _error(503, "panel_service_unavailable")
|
||||
|
||||
for panel_uuid in panel_user_uuids:
|
||||
try:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(
|
||||
panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Admin webapp failed to delete panel user %s for user %s: %s",
|
||||
panel_uuid,
|
||||
target_id,
|
||||
exc,
|
||||
)
|
||||
await session.rollback()
|
||||
return _error(502, "panel_delete_failed", str(exc))
|
||||
|
||||
if not panel_deleted:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
502,
|
||||
"panel_delete_failed",
|
||||
f"Failed to delete panel user {panel_uuid}",
|
||||
)
|
||||
|
||||
ok = await user_dal.delete_user_and_relations(session, target_id)
|
||||
if not ok:
|
||||
await session.rollback()
|
||||
return _error(404, "not_found")
|
||||
await message_log_dal.create_message_log(
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"user_id": actor_id if actor_id != target_id else None,
|
||||
"event_type": "admin_delete_user_webapp",
|
||||
"content": f"Deleted user_id={target_id}",
|
||||
"content": (
|
||||
f"Deleted user_id={target_id}; "
|
||||
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_all_webapp_user_payloads,
|
||||
reset_subscription_guides_cache,
|
||||
reset_webapp_settings_cache,
|
||||
)
|
||||
|
||||
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"WEBAPP_TITLE",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
}
|
||||
)
|
||||
|
||||
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"MY_DEVICES_SECTION_ENABLED",
|
||||
"USER_HWID_DEVICE_LIMIT",
|
||||
"USER_TRAFFIC_LIMIT_GB",
|
||||
"USER_TRAFFIC_STRATEGY",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def changed_setting_keys(
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
) -> set[str]:
|
||||
keys = {str(key) for key in (updates or {}).keys()}
|
||||
keys.update(str(key) for key in (deletes or []) if key is not None)
|
||||
return keys
|
||||
|
||||
|
||||
async def refresh_webapp_runtime_after_settings_change(
|
||||
request: Any,
|
||||
*,
|
||||
updates: Mapping[str, Any] | None = None,
|
||||
deletes: Sequence[Any] | None = None,
|
||||
include_user_payloads: bool = True,
|
||||
) -> None:
|
||||
settings = request.app["settings"]
|
||||
keys = changed_setting_keys(updates, deletes)
|
||||
|
||||
reset_webapp_settings_cache(request.app)
|
||||
reset_subscription_guides_cache(request.app)
|
||||
|
||||
if include_user_payloads:
|
||||
await invalidate_all_webapp_user_payloads(
|
||||
settings,
|
||||
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
|
||||
)
|
||||
|
||||
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
|
||||
request.app["webapp_logo_cache"] = None
|
||||
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
|
||||
|
||||
prune_unused_appearance_assets(settings)
|
||||
@@ -16,7 +16,7 @@ from typing import Any, List, Optional, Tuple
|
||||
@dataclass(frozen=True)
|
||||
class SettingField:
|
||||
key: str
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon"
|
||||
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
|
||||
section: str
|
||||
label: str
|
||||
description: str = ""
|
||||
@@ -34,6 +34,13 @@ class SettingField:
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
# ─── General ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE",
|
||||
"string",
|
||||
"general",
|
||||
"Web App title",
|
||||
placeholder="My subscription",
|
||||
),
|
||||
SettingField(
|
||||
"DEFAULT_LANGUAGE",
|
||||
"string",
|
||||
@@ -117,9 +124,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
subsection="Remnawave",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"url",
|
||||
@@ -160,6 +164,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
|
||||
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
|
||||
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Embedded install guides",
|
||||
"Open install instructions inside the Web App instead of an external connect page.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Open install guides from bot",
|
||||
(
|
||||
"Use the Telegram Mini App install screen for bot connect buttons and show "
|
||||
"public install guide links."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Use Remnawave Panel config",
|
||||
(
|
||||
"Fetch Subscription Page config from Remnawave Panel by the user's "
|
||||
"subscription short UUID."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
|
||||
"bool",
|
||||
"subscription_guides",
|
||||
"Enable admin JSON override",
|
||||
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_PATH",
|
||||
"string",
|
||||
"subscription_guides",
|
||||
"Subscription Page config path",
|
||||
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
|
||||
placeholder="data/subpage-config/multiapp.json",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PAGE_CONFIG_JSON",
|
||||
"json",
|
||||
"subscription_guides",
|
||||
"Subscription Page config JSON",
|
||||
(
|
||||
"Optional admin JSON override. It is applied only when the JSON override "
|
||||
"switch is enabled."
|
||||
),
|
||||
placeholder='{\n "version": "1"\n}',
|
||||
),
|
||||
# ─── Subscription periods & pricing ────────────────────────────
|
||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
|
||||
@@ -177,37 +234,46 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||
),
|
||||
SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
"pricing",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую, например: severpay,freekassa,yookassa,heleket",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"payments",
|
||||
"Показывать описание подписки",
|
||||
"Текст появится перед выбором срока покупки или продления.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
|
||||
"text",
|
||||
"pricing",
|
||||
"payments",
|
||||
"Описание подписки (RU)",
|
||||
"Русская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
|
||||
"text",
|
||||
"pricing",
|
||||
"payments",
|
||||
"Описание подписки (EN)",
|
||||
"Английская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
||||
SettingField(
|
||||
"STARS_ADMIN_ONLY_ENABLED",
|
||||
"bool",
|
||||
"payments",
|
||||
"Telegram Stars admin-only",
|
||||
(
|
||||
"Shows Telegram Stars only to users from ADMIN_IDS. "
|
||||
"Payment callbacks remain active for admin test payments."
|
||||
),
|
||||
subsection="common",
|
||||
i18n_label_key="admin_settings_provider_admin_only_label",
|
||||
i18n_description_key="admin_settings_provider_admin_only_description",
|
||||
),
|
||||
SettingField(
|
||||
"PAYMENT_METHODS_ORDER",
|
||||
"string",
|
||||
@@ -217,10 +283,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>
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(255, 255, 255, 0.08);
|
||||
--surface-muted: #0a0a0a;
|
||||
--surface-subtle: #000000;
|
||||
--surface-subtle-border: #ffffff;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.85);
|
||||
--nav-bg: #000000;
|
||||
@@ -119,6 +120,8 @@
|
||||
/* ---------- Panels / cards ---------- */
|
||||
|
||||
.theme-key-ascii .card,
|
||||
.theme-key-ascii .trial-card-facts span,
|
||||
.theme-key-ascii .trial-activation-facts div,
|
||||
.theme-key-ascii .period-card,
|
||||
.theme-key-ascii .method-card,
|
||||
.theme-key-ascii .settings-row,
|
||||
@@ -149,6 +152,12 @@
|
||||
.theme-key-ascii .ticket-message-avatar,
|
||||
.theme-key-ascii .ticket-message-bubble,
|
||||
.theme-key-ascii .ticket-composer,
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .install-step,
|
||||
.theme-key-ascii .install-subscription-card,
|
||||
.theme-key-ascii .install-qr-wrap,
|
||||
.theme-key-ascii .install-loading,
|
||||
.theme-key-ascii .admin-sidebar,
|
||||
.theme-key-ascii .admin-header,
|
||||
.theme-key-ascii .admin-card,
|
||||
@@ -183,6 +192,8 @@
|
||||
.theme-key-ascii .support-new-ticket-button,
|
||||
.theme-key-ascii .support-select-trigger,
|
||||
.theme-key-ascii .support-status-tabs-trigger,
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .admin-btn,
|
||||
.theme-key-ascii .admin-chip,
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
@@ -230,6 +241,7 @@
|
||||
.theme-key-ascii .support-status-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .support-select-item[data-highlighted],
|
||||
.theme-key-ascii .support-select-item[data-selected],
|
||||
.theme-key-ascii .install-app-button.active,
|
||||
.theme-key-ascii .admin-nav-item.active,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active {
|
||||
@@ -259,6 +271,9 @@
|
||||
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-ascii .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-ascii .language-select-trigger:focus-visible,
|
||||
.theme-key-ascii .install-platform-trigger:focus-visible,
|
||||
.theme-key-ascii .install-platform-trigger[data-state="open"],
|
||||
.theme-key-ascii .install-app-button:focus-visible,
|
||||
.theme-key-ascii .bottom-nav button:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 1px;
|
||||
@@ -305,12 +320,22 @@
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-ascii .trial-offer-card,
|
||||
.theme-key-ascii .trial-card-facts span,
|
||||
.theme-key-ascii .trial-activation-card,
|
||||
.theme-key-ascii .trial-activation-facts div,
|
||||
.theme-key-ascii .activation-success-dialog,
|
||||
.theme-key-ascii .subscription-purchase-description,
|
||||
.theme-key-ascii .support-create-panel,
|
||||
.theme-key-ascii .ticket-composer {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .trial-card-head > svg,
|
||||
.theme-key-ascii .dialog-title-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .support-heading-icon,
|
||||
.theme-key-ascii .support-new-ticket-icon,
|
||||
.theme-key-ascii .support-empty-state svg,
|
||||
@@ -412,6 +437,7 @@
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-content,
|
||||
body:has(.theme-key-ascii) .install-platform-content,
|
||||
body:has(.theme-key-ascii) .field-error-tooltip {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
@@ -420,19 +446,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
|
||||
box-shadow: 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item {
|
||||
body:has(.theme-key-ascii) .support-select-item,
|
||||
body:has(.theme-key-ascii) .install-platform-item {
|
||||
border-radius: 0;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected] {
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
|
||||
background: #ffffff;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
body:has(.theme-key-ascii) .support-select-item[data-selected] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
@@ -695,7 +726,8 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
.theme-key-ascii .admin-btn-primary svg.lucide,
|
||||
.theme-key-ascii .admin-nav-item.active svg.lucide,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
|
||||
.theme-key-ascii .install-app-button.active svg.lucide {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
@@ -1044,12 +1076,16 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
.theme-key-ascii :is(
|
||||
.card, .dialog-card, .toast,
|
||||
.btn, .input,
|
||||
.trial-card-facts span, .trial-activation-facts div,
|
||||
.period-card, .method-card, .settings-row, .option-row,
|
||||
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
|
||||
.topup-carryover-note, .subscription-purchase-description,
|
||||
.language-select-content, .language-select-item,
|
||||
.language-select-trigger, .bottom-nav, .bottom-nav button,
|
||||
.link-button,
|
||||
.install-platform-trigger, .install-platform-content, .install-platform-item,
|
||||
.install-app-button, .install-step, .install-subscription-card,
|
||||
.install-qr-wrap, .install-subscription-header-icon, .install-loading,
|
||||
.support-overview-card, .support-list-card, .support-ticket-card,
|
||||
.support-conversation-card, .support-new-ticket-button,
|
||||
.support-create-panel, .support-select-trigger, .support-select-content,
|
||||
@@ -1083,6 +1119,9 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
|
||||
.admin-panel-dash-card,
|
||||
.admin-select-trigger, .admin-select-content,
|
||||
.install-platform-trigger, .install-platform-content,
|
||||
.install-app-button, .install-step, .install-subscription-card,
|
||||
.install-qr-wrap, .install-loading,
|
||||
.admin-cn-card,
|
||||
.admin-input, .admin-textarea, .admin-btn,
|
||||
.admin-nav-item, .admin-tabs-trigger
|
||||
@@ -1096,6 +1135,71 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ---------- Install guide theme surfaces ---------- */
|
||||
|
||||
.theme-key-ascii .install-platform-trigger,
|
||||
.theme-key-ascii .install-app-button,
|
||||
.theme-key-ascii .install-step,
|
||||
.theme-key-ascii .install-subscription-card,
|
||||
.theme-key-ascii .install-qr-wrap,
|
||||
.theme-key-ascii .install-loading,
|
||||
body:has(.theme-key-ascii) .install-platform-content {
|
||||
border: 1px solid #ffffff !important;
|
||||
border-radius: 0 !important;
|
||||
background: #000000 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-platform-trigger:hover,
|
||||
.theme-key-ascii .install-app-button:hover:not(:disabled) {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-app-button.active,
|
||||
.theme-key-ascii .install-app-button.active:hover:not(:disabled),
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
border-color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-app-button.active svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-step:hover,
|
||||
.theme-key-ascii .install-subscription-card:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-step-icon,
|
||||
.theme-key-ascii .install-subscription-header-icon {
|
||||
border: 1px solid currentColor !important;
|
||||
background: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-qr-divider {
|
||||
color: #ffffff !important;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-feature-star.attention-dot {
|
||||
background: #ffffff !important;
|
||||
animation: ascii-caret 1s steps(1) infinite !important;
|
||||
}
|
||||
|
||||
.theme-key-ascii .install-loading .ui-spinner {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Console-style tables: cell borders, header underline,
|
||||
* row separator using dashed line.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"assets_version": 4,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
|
||||
--surface-hover: rgba(15, 23, 42, 0.045);
|
||||
--surface-muted: rgba(15, 23, 42, 0.035);
|
||||
--surface-subtle: rgba(15, 23, 42, 0.025);
|
||||
--surface-subtle-border: rgba(15, 23, 42, 0.1);
|
||||
--overlay-scrim: rgba(15, 23, 42, 0.34);
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
@@ -109,6 +110,26 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* New user-facing activation surfaces */
|
||||
.theme-key-light .trial-offer-card,
|
||||
.theme-key-light .trial-activation-card,
|
||||
.theme-key-light .activation-success-dialog {
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-head > svg,
|
||||
.theme-key-light .dialog-title-icon {
|
||||
color: color-mix(in srgb, var(--accent) 54%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .trial-card-facts span,
|
||||
.theme-key-light .trial-activation-facts div {
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
background: rgba(15, 23, 42, 0.025);
|
||||
}
|
||||
|
||||
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
|
||||
.theme-key-light .admin-revenue-svg-frame {
|
||||
background: #ffffff;
|
||||
@@ -129,3 +150,57 @@
|
||||
.theme-key-light .bonus-card-head > svg {
|
||||
color: color-mix(in srgb, var(--accent) 50%, #000000);
|
||||
}
|
||||
|
||||
/* Install guide theme surfaces */
|
||||
|
||||
.theme-key-light .install-platform-trigger,
|
||||
.theme-key-light .install-app-button,
|
||||
.theme-key-light .install-step,
|
||||
.theme-key-light .install-subscription-card,
|
||||
.theme-key-light .install-qr-wrap {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
|
||||
}
|
||||
|
||||
.theme-key-light .install-app-button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-key-light .install-platform-trigger:focus-visible,
|
||||
.theme-key-light .install-platform-trigger[data-state="open"],
|
||||
.theme-key-light .install-app-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-content {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.14);
|
||||
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
body:has(.theme-key-light) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .install-step-icon,
|
||||
.theme-key-light .install-subscription-header-icon {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
.theme-key-light .install-qr-divider {
|
||||
color: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.theme-key-light .install-feature-star.attention-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.theme-key-light .install-loading .ui-spinner {
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 2,
|
||||
"assets_version": 3,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
--surface-sheen-soft: transparent;
|
||||
--surface-hover: rgba(0, 0, 128, 0.14);
|
||||
--surface-muted: #c0c0c0;
|
||||
--surface-subtle: #dfdfdf;
|
||||
--surface-subtle-border: #808080;
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.35);
|
||||
--nav-bg: #c0c0c0;
|
||||
@@ -138,12 +139,15 @@
|
||||
.theme-key-windows95 svg.lucide-megaphone,
|
||||
.theme-key-windows95 svg.lucide-message-square,
|
||||
.theme-key-windows95 svg.lucide-message-square-plus,
|
||||
.theme-key-windows95 svg.lucide-monitor,
|
||||
.theme-key-windows95 svg.lucide-paintbrush,
|
||||
.theme-key-windows95 svg.lucide-plus,
|
||||
.theme-key-windows95 svg.lucide-qr-code,
|
||||
.theme-key-windows95 svg.lucide-refresh-cw,
|
||||
.theme-key-windows95 svg.lucide-save,
|
||||
.theme-key-windows95 svg.lucide-search,
|
||||
.theme-key-windows95 svg.lucide-send,
|
||||
.theme-key-windows95 svg.lucide-share-2,
|
||||
.theme-key-windows95 svg.lucide-settings,
|
||||
.theme-key-windows95 svg.lucide-shield,
|
||||
.theme-key-windows95 svg.lucide-sliders,
|
||||
@@ -273,6 +277,10 @@
|
||||
--win95-button-icon: var(--win95-icon-send);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-monitor {
|
||||
--win95-button-icon: var(--win95-icon-dashboard);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-paintbrush {
|
||||
--win95-button-icon: var(--win95-icon-paintbrush);
|
||||
}
|
||||
@@ -281,6 +289,10 @@
|
||||
--win95-button-icon: var(--win95-icon-folder);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-qr-code {
|
||||
--win95-button-icon: var(--win95-icon-key);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-refresh-cw {
|
||||
--win95-button-icon: var(--win95-icon-refresh);
|
||||
}
|
||||
@@ -293,6 +305,10 @@
|
||||
--win95-button-icon: var(--win95-icon-search);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-share-2 {
|
||||
--win95-button-icon: var(--win95-icon-send);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-settings {
|
||||
--win95-button-icon: var(--win95-icon-settings);
|
||||
}
|
||||
@@ -371,12 +387,15 @@
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -425,12 +444,15 @@
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -488,7 +510,13 @@
|
||||
.theme-key-windows95 .support-message-scroll,
|
||||
.theme-key-windows95 .ticket-message-avatar,
|
||||
.theme-key-windows95 .ticket-message-bubble,
|
||||
.theme-key-windows95 .ticket-composer {
|
||||
.theme-key-windows95 .ticket-composer,
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button,
|
||||
.theme-key-windows95 .install-step,
|
||||
.theme-key-windows95 .install-subscription-card,
|
||||
.theme-key-windows95 .install-qr-wrap,
|
||||
.theme-key-windows95 .install-loading {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
@@ -510,6 +538,7 @@
|
||||
|
||||
body:has(.theme-key-windows95) .language-select-content,
|
||||
body:has(.theme-key-windows95) .support-select-content,
|
||||
body:has(.theme-key-windows95) .install-platform-content,
|
||||
body:has(.theme-key-windows95) .field-error-tooltip,
|
||||
body:has(.theme-key-windows95) .admin-select-content {
|
||||
border-width: 2px;
|
||||
@@ -525,18 +554,23 @@ body:has(.theme-key-windows95) .admin-select-content {
|
||||
|
||||
body:has(.theme-key-windows95) .language-select-item,
|
||||
body:has(.theme-key-windows95) .support-select-item,
|
||||
body:has(.theme-key-windows95) .install-platform-item,
|
||||
body:has(.theme-key-windows95) .admin-select-item {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .support-select-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected] {
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
background: #000080;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg,
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
body:has(.theme-key-windows95) .support-select-item[data-selected] svg,
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted] svg,
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] svg {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
@@ -560,7 +594,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .link-button,
|
||||
.theme-key-windows95 .support-new-ticket-button,
|
||||
.theme-key-windows95 .support-select-trigger,
|
||||
.theme-key-windows95 .support-status-tabs-trigger {
|
||||
.theme-key-windows95 .support-status-tabs-trigger,
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button {
|
||||
min-height: 34px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
@@ -625,13 +661,17 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .support-new-ticket-button.active,
|
||||
.theme-key-windows95 .support-status-tabs-trigger[data-state="active"],
|
||||
.theme-key-windows95 .support-select-item[data-highlighted],
|
||||
.theme-key-windows95 .support-select-item[data-selected] {
|
||||
.theme-key-windows95 .support-select-item[data-selected],
|
||||
.theme-key-windows95 .install-app-button.active {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-windows95 .trial-offer-card,
|
||||
.theme-key-windows95 .trial-activation-card,
|
||||
.theme-key-windows95 .activation-success-dialog,
|
||||
.theme-key-windows95 .subscription-purchase-description,
|
||||
.theme-key-windows95 .support-create-panel,
|
||||
.theme-key-windows95 .ticket-composer,
|
||||
@@ -639,6 +679,20 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .trial-card-facts span,
|
||||
.theme-key-windows95 .trial-activation-facts div {
|
||||
border: 2px solid;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
background: #dfdfdf;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .dialog-title-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .support-heading-icon,
|
||||
.theme-key-windows95 .support-new-ticket-icon,
|
||||
.theme-key-windows95 .support-empty-state svg,
|
||||
@@ -718,6 +772,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
|
||||
.theme-key-windows95 .ticket-composer:focus-within,
|
||||
.theme-key-windows95 .support-select-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger[data-state="open"],
|
||||
.theme-key-windows95 .install-app-button:focus-visible,
|
||||
.theme-key-windows95 .ticket-card:focus-visible,
|
||||
.theme-key-windows95 .support-status-tabs-trigger:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
@@ -731,6 +788,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* ---------- Install guide theme surfaces ---------- */
|
||||
|
||||
.theme-key-windows95 .install-platform-trigger,
|
||||
.theme-key-windows95 .install-app-button {
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-platform-trigger:hover,
|
||||
.theme-key-windows95 .install-app-button:hover:not(:disabled):not(.active) {
|
||||
background: #dfdfdf;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-app-button.active,
|
||||
.theme-key-windows95 .install-app-button.active:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #000000,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step,
|
||||
.theme-key-windows95 .install-subscription-card,
|
||||
.theme-key-windows95 .install-qr-wrap,
|
||||
.theme-key-windows95 .install-loading {
|
||||
background: #c0c0c0;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step:hover,
|
||||
.theme-key-windows95 .install-subscription-card:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-step-icon,
|
||||
.theme-key-windows95 .install-subscription-header-icon {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #dfdfdf;
|
||||
color: var(--accent);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #ffffff,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .install-platform-content {
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
|
||||
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
background: var(--accent);
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-qr-divider {
|
||||
color: #404040;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-feature-star.attention-dot {
|
||||
background: #ffff00 !important;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: 1px 1px 0 #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .install-loading .ui-spinner {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .card-heading-accent,
|
||||
.theme-key-windows95 .brand-row strong,
|
||||
.theme-key-windows95 .login-brand h1,
|
||||
@@ -1059,6 +1195,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-windows95 .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-windows95 .language-select-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger:focus-visible,
|
||||
.theme-key-windows95 .install-platform-trigger[data-state="open"],
|
||||
.theme-key-windows95 .install-app-button:focus-visible,
|
||||
.theme-key-windows95 .bottom-nav button:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: -4px;
|
||||
@@ -1116,6 +1255,7 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .admin-nav-item.active svg.lucide,
|
||||
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
|
||||
.theme-key-windows95 .install-app-button.active svg.lucide,
|
||||
.theme-key-windows95 .admin-header svg.lucide {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
@@ -1133,7 +1273,6 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 svg.lucide-map,
|
||||
.theme-key-windows95 svg.lucide-menu,
|
||||
.theme-key-windows95 svg.lucide-mouse-pointer-click,
|
||||
.theme-key-windows95 svg.lucide-qr-code,
|
||||
.theme-key-windows95 svg.lucide-radio,
|
||||
.theme-key-windows95 svg.lucide-repeat-2,
|
||||
.theme-key-windows95 svg.lucide-server,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 9,
|
||||
"assets_version": 11,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
|
||||
@@ -49,7 +49,11 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from bot.utils.text_sanitizer import (
|
||||
panel_description_from_profile,
|
||||
sanitize_display_name,
|
||||
sanitize_username,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
@@ -59,6 +63,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
|
||||
APP_ROOT = Path(__file__).resolve().parents[5]
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .guides import warm_subscription_guides_config
|
||||
|
||||
|
||||
def create_subscription_webapp_application(
|
||||
@@ -20,10 +21,12 @@ def create_subscription_webapp_application(
|
||||
app["settings"] = settings
|
||||
app["async_session_factory"] = async_session_factory
|
||||
app["i18n"] = dp.get("i18n_instance")
|
||||
app["email_auth_service"] = EmailAuthService(settings)
|
||||
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
|
||||
app["subscription_guides_config_lock"] = asyncio.Lock()
|
||||
app["webapp_rate_limit_buckets"] = {}
|
||||
app["webapp_rate_limit_lock"] = asyncio.Lock()
|
||||
|
||||
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
await warm_subscription_guides_config(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
|
||||
@@ -9,6 +9,7 @@ from config.webapp_themes_config import (
|
||||
public_theme_payload,
|
||||
public_themes_catalog_payload,
|
||||
)
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
|
||||
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
|
||||
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
|
||||
@@ -95,9 +96,7 @@ async def theme_css_asset_route(request: web.Request) -> web.Response:
|
||||
raise web.HTTPNotFound(text="theme_css_not_found") from None
|
||||
|
||||
query = getattr(request, "query", {}) or {}
|
||||
cache_control = (
|
||||
"public, max-age=31536000, immutable" if query.get("v") else "no-cache"
|
||||
)
|
||||
cache_control = "public, max-age=31536000, immutable" if query.get("v") else "no-cache"
|
||||
try:
|
||||
stat = path.stat()
|
||||
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
|
||||
@@ -204,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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import (
|
||||
invalidate_local_webapp_user_payload,
|
||||
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
|
||||
)
|
||||
from bot.middlewares.i18n import (
|
||||
is_valid_locale_language_code,
|
||||
normalize_locale_language_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,25 +30,7 @@ async def _invalidate_webapp_user_caches(
|
||||
*user_ids: Optional[int],
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
keys: List[str] = []
|
||||
seen: set[int] = set()
|
||||
for raw_user_id in user_ids:
|
||||
if raw_user_id is None:
|
||||
continue
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if user_id in seen:
|
||||
continue
|
||||
seen.add(user_id)
|
||||
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "me", user_id)
|
||||
if include_devices:
|
||||
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
|
||||
invalidate_local_webapp_user_payload(settings, "devices", user_id)
|
||||
if keys:
|
||||
await cache_delete(settings, *keys)
|
||||
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
|
||||
|
||||
|
||||
def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
@@ -91,8 +77,8 @@ def _validate_model_payload(
|
||||
|
||||
|
||||
def _normalize_language(lang: Optional[str]) -> str:
|
||||
value = (lang or "ru").split("-")[0].lower()
|
||||
return value if value in {"ru", "en"} else "ru"
|
||||
value = normalize_locale_language_code(lang, prefer_known_base=False)
|
||||
return value if is_valid_locale_language_code(value) else "ru"
|
||||
|
||||
|
||||
def _format_remaining(seconds: int, lang: str) -> str:
|
||||
|
||||
@@ -86,8 +86,7 @@ async def _load_devices_payload(
|
||||
"max_devices": max_devices,
|
||||
"max_devices_label": _format_devices_limit(max_devices),
|
||||
"devices": [
|
||||
_serialize_device(device, index)
|
||||
for index, device in enumerate(devices, start=1)
|
||||
_serialize_device(device, index) for index, device in enumerate(devices, start=1)
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_status,
|
||||
validate_panel_subscription_guides_config,
|
||||
)
|
||||
|
||||
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS = 30
|
||||
|
||||
|
||||
async def warm_subscription_guides_config(app: web.Application) -> None:
|
||||
try:
|
||||
await _subscription_guides_status_shared(app)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to warm subscription guides config: %s", exc)
|
||||
|
||||
|
||||
async def subscription_guides_route(request: web.Request) -> web.Response:
|
||||
_require_user_id(request)
|
||||
status = await _subscription_guides_status_shared(request.app)
|
||||
payload = {
|
||||
"enabled": bool(status.get("enabled")),
|
||||
"config": status.get("config") if status.get("enabled") else None,
|
||||
"source": status.get("source"),
|
||||
}
|
||||
if status.get("error"):
|
||||
payload["error"] = status["error"]
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def public_subscription_guides_route(request: web.Request) -> web.Response:
|
||||
share_token = subscription_dal.normalize_install_share_token(
|
||||
request.match_info.get("share_token")
|
||||
)
|
||||
if not share_token:
|
||||
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
|
||||
|
||||
subscription = await _public_subscription_payload(request, share_token)
|
||||
if not subscription.get("active"):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"enabled": False,
|
||||
"config": None,
|
||||
"source": None,
|
||||
"subscription": subscription,
|
||||
"error": "subscription_unavailable",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
status = await _subscription_guides_status_shared(request.app)
|
||||
payload = {
|
||||
"enabled": bool(status.get("enabled")),
|
||||
"config": status.get("config") if status.get("enabled") else None,
|
||||
"source": status.get("source"),
|
||||
"subscription": subscription,
|
||||
}
|
||||
if status.get("error"):
|
||||
payload["error"] = status["error"]
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def _subscription_guides_status_shared(app: web.Application) -> Dict[str, Any]:
|
||||
settings: Settings = app["settings"]
|
||||
cache = app.setdefault("subscription_guides_config_cache", {})
|
||||
lock: asyncio.Lock = app.setdefault("subscription_guides_config_lock", asyncio.Lock())
|
||||
fingerprint = _subscription_guides_settings_fingerprint(settings)
|
||||
now = time.monotonic()
|
||||
|
||||
cached = cache.get("status")
|
||||
if cached is not None and cache.get("fingerprint") == fingerprint:
|
||||
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
|
||||
async with lock:
|
||||
cached = cache.get("status")
|
||||
if cached is not None and cache.get("fingerprint") == fingerprint:
|
||||
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
|
||||
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
|
||||
):
|
||||
return cached
|
||||
|
||||
status = await _load_subscription_guides_status(app, settings)
|
||||
cache["fingerprint"] = fingerprint
|
||||
cache["status"] = status
|
||||
cache["ts"] = time.monotonic()
|
||||
return status
|
||||
|
||||
|
||||
async def _load_subscription_guides_status(
|
||||
app: web.Application,
|
||||
settings: Settings,
|
||||
) -> Dict[str, Any]:
|
||||
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
|
||||
return {"enabled": False, "config": None, "source": None, "error": None}
|
||||
|
||||
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
|
||||
json_override_enabled = bool(
|
||||
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
|
||||
)
|
||||
if admin_json and json_override_enabled:
|
||||
return subscription_guides_status(settings)
|
||||
|
||||
if bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)):
|
||||
panel_status = await _subscription_guides_status_from_panel_config(app, settings)
|
||||
if panel_status.get("enabled"):
|
||||
return panel_status
|
||||
|
||||
return subscription_guides_status(settings)
|
||||
|
||||
|
||||
async def _subscription_guides_status_from_panel_config(
|
||||
app: web.Application,
|
||||
settings: Settings,
|
||||
) -> Dict[str, Any]:
|
||||
panel_service = _panel_service_from_app(app)
|
||||
if panel_service is None:
|
||||
return {
|
||||
"enabled": False,
|
||||
"config": None,
|
||||
"source": "panel",
|
||||
"error": "Panel service is unavailable",
|
||||
}
|
||||
|
||||
try:
|
||||
config_uuid = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or "").strip()
|
||||
if not config_uuid:
|
||||
config_uuid = await _default_panel_subscription_page_config_uuid(panel_service)
|
||||
config_uuid = config_uuid or PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
|
||||
detail = await panel_service.get_subscription_page_config_by_uuid(config_uuid)
|
||||
if detail is None and config_uuid != PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
|
||||
detail = await panel_service.get_subscription_page_config_by_uuid(
|
||||
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
|
||||
)
|
||||
if detail is None:
|
||||
raise SubscriptionGuidesConfigError(
|
||||
f"Panel subscription page config {config_uuid} is unavailable"
|
||||
)
|
||||
config = validate_panel_subscription_guides_config(detail)
|
||||
except (SubscriptionGuidesConfigError, Exception) as exc:
|
||||
logger.warning("Failed to load subscription guides config from Remnawave Panel: %s", exc)
|
||||
return {"enabled": False, "config": None, "source": "panel", "error": str(exc)}
|
||||
|
||||
return {"enabled": True, "config": config, "source": "panel", "error": None}
|
||||
|
||||
|
||||
async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> str:
|
||||
get_list = getattr(panel_service, "get_subscription_page_config_list", None)
|
||||
if not callable(get_list):
|
||||
return ""
|
||||
payload = await get_list()
|
||||
configs = (payload or {}).get("configs")
|
||||
if not isinstance(configs, list):
|
||||
return ""
|
||||
|
||||
candidates: list[Dict[str, Any]] = [item for item in configs if isinstance(item, dict)]
|
||||
for item in candidates:
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
if uuid == PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
|
||||
return uuid
|
||||
|
||||
candidates.sort(key=lambda item: int(item.get("viewPosition") or 0))
|
||||
for item in candidates:
|
||||
uuid = str(item.get("uuid") or "").strip()
|
||||
if uuid:
|
||||
return uuid
|
||||
return ""
|
||||
|
||||
|
||||
async def _public_subscription_payload(
|
||||
request: web.Request,
|
||||
share_token: str,
|
||||
) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = _panel_service_from_app(request.app)
|
||||
raw_link = ""
|
||||
username = ""
|
||||
resolved_short_uuid = ""
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
local_sub = await subscription_dal.get_subscription_by_install_share_token(
|
||||
session,
|
||||
share_token,
|
||||
)
|
||||
|
||||
if (
|
||||
local_sub
|
||||
and getattr(local_sub, "panel_user_uuid", None)
|
||||
and _local_subscription_is_publicly_active(local_sub)
|
||||
and panel_service
|
||||
):
|
||||
panel_user = await panel_service.get_user_by_uuid(local_sub.panel_user_uuid)
|
||||
if panel_user:
|
||||
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
|
||||
username = str(panel_user.get("username") or "").strip()
|
||||
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
|
||||
|
||||
display_link, connect_url = await prepare_config_links(settings, raw_link)
|
||||
return {
|
||||
"active": bool(display_link),
|
||||
"config_link": display_link,
|
||||
"connect_url": connect_url or display_link,
|
||||
"panel_short_uuid": resolved_short_uuid or None,
|
||||
"install_share_token": share_token,
|
||||
"username": username,
|
||||
"share_url": _public_install_url(request, share_token),
|
||||
}
|
||||
|
||||
|
||||
def _panel_service_from_app(app: web.Application) -> Any:
|
||||
subscription_service: Optional[SubscriptionService] = app.get("subscription_service")
|
||||
panel_service = (
|
||||
getattr(subscription_service, "panel_service", None) if subscription_service else None
|
||||
)
|
||||
return panel_service or app.get("panel_service")
|
||||
|
||||
|
||||
def _subscription_guides_settings_fingerprint(settings: Settings) -> Tuple[Any, ...]:
|
||||
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "")
|
||||
return (
|
||||
bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)),
|
||||
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)),
|
||||
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)),
|
||||
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", "") or ""),
|
||||
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or ""),
|
||||
hashlib.sha256(admin_json.encode("utf-8")).hexdigest(),
|
||||
str(getattr(settings, "PANEL_API_URL", "") or ""),
|
||||
bool(getattr(settings, "PANEL_API_KEY", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _local_subscription_is_publicly_active(subscription: Any) -> bool:
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
if end_date and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
return bool(
|
||||
getattr(subscription, "is_active", False)
|
||||
and end_date
|
||||
and end_date > datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
def _public_install_url(request: web.Request, share_token: str) -> str:
|
||||
settings: Settings = request.app["settings"]
|
||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if configured_base:
|
||||
parts = urlsplit(configured_base)
|
||||
if parts.scheme and parts.netloc:
|
||||
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
else:
|
||||
base = configured_base.rstrip("/")
|
||||
else:
|
||||
host = (
|
||||
request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
|
||||
)
|
||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
base = f"{proto}://{host}"
|
||||
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||
|
||||
|
||||
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
|
||||
headers = request.headers
|
||||
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
|
||||
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
user_agent = headers.get(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
|
||||
)
|
||||
return {
|
||||
"host": host,
|
||||
"x-forwarded-host": host,
|
||||
"x-forwarded-proto": proto,
|
||||
"user-agent": user_agent,
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-site": "none",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}
|
||||
@@ -6,6 +6,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/install", index_route)
|
||||
app.router.add_get("/trial", index_route)
|
||||
app.router.add_get("/open-app", app_deeplink_route)
|
||||
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
@@ -15,15 +19,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)
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -156,7 +156,6 @@ async def admin_panel_actions_callback_handler(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
)
|
||||
await callback.answer(_("admin_sync_initiated_from_panel"))
|
||||
elif action == "queue_status":
|
||||
await show_queue_status_handler(callback, i18n_data)
|
||||
elif action == "view_payments":
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
@@ -25,6 +26,44 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _user_email(user: Optional[User]) -> str:
|
||||
return str(getattr(user, "email", None) or "").strip()
|
||||
|
||||
|
||||
def _format_user_with_email(
|
||||
*,
|
||||
first_name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
fallback: str = "",
|
||||
) -> str:
|
||||
parts = []
|
||||
if first_name:
|
||||
parts.append(first_name)
|
||||
if username:
|
||||
parts.append(f"(@{username})")
|
||||
|
||||
display = " ".join(parts).strip() or str(fallback or "").strip()
|
||||
clean_email = str(email or "").strip()
|
||||
if clean_email:
|
||||
display = (
|
||||
f"{display} · {clean_email}" if display and display != clean_email else clean_email
|
||||
)
|
||||
return hd.quote(display)
|
||||
|
||||
|
||||
def _format_log_entry_user(log_entry: MessageLog, translate) -> str:
|
||||
fallback = (
|
||||
translate("system_or_unknown_user") if not log_entry.user_id else f"ID: {log_entry.user_id}"
|
||||
)
|
||||
return _format_user_with_email(
|
||||
first_name=log_entry.telegram_first_name,
|
||||
username=log_entry.telegram_username,
|
||||
email=_user_email(getattr(log_entry, "author_user", None)),
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
async def display_logs_menu(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
):
|
||||
@@ -94,19 +133,7 @@ async def _display_formatted_logs(
|
||||
|
||||
log_entries_text = []
|
||||
for log_entry_model in logs:
|
||||
user_display_parts = []
|
||||
if log_entry_model.telegram_first_name:
|
||||
user_display_parts.append(log_entry_model.telegram_first_name)
|
||||
if log_entry_model.telegram_username:
|
||||
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
|
||||
|
||||
user_display = " ".join(user_display_parts).strip()
|
||||
if not user_display:
|
||||
user_display = (
|
||||
_("system_or_unknown_user")
|
||||
if not log_entry_model.user_id
|
||||
else f"ID: {log_entry_model.user_id}"
|
||||
)
|
||||
user_display = _format_log_entry_user(log_entry_model, _)
|
||||
|
||||
user_id_display = (
|
||||
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
|
||||
@@ -270,10 +297,11 @@ async def process_user_id_for_logs_handler(
|
||||
return
|
||||
|
||||
target_user_id = user_model_for_logs.user_id
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username
|
||||
else (user_model_for_logs.email or f"ID {target_user_id}")
|
||||
user_display_name = _format_user_with_email(
|
||||
first_name=user_model_for_logs.first_name,
|
||||
username=user_model_for_logs.username,
|
||||
email=user_model_for_logs.email,
|
||||
fallback=f"ID {target_user_id}",
|
||||
)
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
@@ -319,10 +347,11 @@ async def view_user_logs_paginated_handler(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username
|
||||
else (user_model_for_logs.email or f"ID {target_user_id}")
|
||||
user_display_name = _format_user_with_email(
|
||||
first_name=user_model_for_logs.first_name,
|
||||
username=user_model_for_logs.username,
|
||||
email=user_model_for_logs.email,
|
||||
fallback=f"ID {target_user_id}",
|
||||
)
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
@@ -392,6 +421,7 @@ async def export_logs_csv_handler(
|
||||
_("admin_csv_header_user_id"),
|
||||
_("admin_csv_header_telegram_username"),
|
||||
_("admin_csv_header_telegram_first_name"),
|
||||
_("admin_csv_header_email"),
|
||||
_("admin_csv_header_event_type"),
|
||||
_("admin_csv_header_content"),
|
||||
_("admin_csv_header_is_admin_event"),
|
||||
@@ -417,6 +447,7 @@ async def export_logs_csv_handler(
|
||||
log.user_id or "",
|
||||
log.telegram_username or "",
|
||||
log.telegram_first_name or "",
|
||||
_user_email(getattr(log, "author_user", None)),
|
||||
log.event_type or "",
|
||||
content_clean,
|
||||
"Yes" if log.is_admin_event else "No",
|
||||
|
||||
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(
|
||||
_(
|
||||
|
||||
@@ -16,7 +16,12 @@ from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.states.user_states import UserPromoStates
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -133,10 +138,12 @@ async def process_promo_code_input(
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
db_user = await user_dal.get_user_by_id(session, user.id)
|
||||
await notification_service.notify_suspicious_promo_attempt(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
suspicious_input=code_input,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -160,12 +167,30 @@ async def process_promo_code_input(
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user.id)
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
response_to_user_text = append_install_share_link_text(
|
||||
response_to_user_text,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for promo user %s.",
|
||||
user.id,
|
||||
)
|
||||
install_share_url = None
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
else:
|
||||
await session.commit()
|
||||
|
||||
@@ -17,12 +17,16 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
@@ -715,6 +719,23 @@ async def start_command_handler(
|
||||
),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
if install_share_url:
|
||||
try:
|
||||
await session.commit()
|
||||
promo_success_text = append_install_share_link_text(
|
||||
promo_success_text,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Failed to persist install guide share token for promo user %s.",
|
||||
user_id,
|
||||
)
|
||||
install_share_url = None
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
|
||||
@@ -726,6 +747,7 @@ async def start_command_handler(
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -891,7 +913,12 @@ async def select_language_callback_handler(
|
||||
|
||||
try:
|
||||
lang_payload = callback.data.split("_", 2)[2]
|
||||
lang_code, _, return_target = lang_payload.partition(":")
|
||||
raw_lang_code, _, return_target = lang_payload.partition(":")
|
||||
lang_code = normalize_locale_language_code(
|
||||
raw_lang_code,
|
||||
set(i18n.locales_data.keys()),
|
||||
prefer_known_base=True,
|
||||
)
|
||||
except IndexError:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
@@ -899,6 +926,13 @@ async def select_language_callback_handler(
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
if lang_code not in i18n.locales_data:
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
"Unsupported language.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
|
||||
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import subscription_dal, user_billing_dal
|
||||
from db.models import Subscription
|
||||
@@ -326,6 +330,7 @@ async def select_tariff_period_callback(
|
||||
settings,
|
||||
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
||||
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -378,6 +383,7 @@ async def select_tariff_package_callback(
|
||||
settings,
|
||||
sale_mode=sale_mode,
|
||||
back_callback=back_callback,
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -490,6 +496,7 @@ async def select_tariff_premium_package_callback(
|
||||
settings,
|
||||
sale_mode=f"premium_topup@{tariff.key}",
|
||||
back_callback="tariff_topup:list",
|
||||
user_id=callback.from_user.id,
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method_traffic"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -518,10 +525,14 @@ async def hwid_devices_list_callback(
|
||||
await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
|
||||
return
|
||||
tariff = config.require(active["tariff_key"])
|
||||
if tariff.billing_model != "period":
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
|
||||
if not packages:
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
renewal_available = bool(active.get("device_topup_renewal_available"))
|
||||
markup = get_hwid_device_packages_keyboard(
|
||||
tariff,
|
||||
packages,
|
||||
@@ -529,14 +540,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:
|
||||
|
||||
@@ -14,7 +14,12 @@ from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
ensure_user_install_guide_links,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -74,6 +79,7 @@ async def request_trial_confirmation_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -104,9 +110,23 @@ async def request_trial_confirmation_handler(
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
final_message_text_in_chat = append_install_share_link_text(
|
||||
final_message_text_in_chat,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
|
||||
# Send notification to admin about new trial
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_trial_activation(
|
||||
user_id,
|
||||
end_date_obj,
|
||||
username=db_user.username if db_user else callback.from_user.username,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
)
|
||||
# Mark ad attribution trial if exists
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
@@ -139,6 +159,7 @@ async def request_trial_confirmation_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -214,6 +235,7 @@ async def confirm_activate_trial_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -243,6 +265,13 @@ async def confirm_activate_trial_handler(
|
||||
config_link=config_link_for_trial,
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
||||
install_share_url = install_links.public_share_url
|
||||
final_message_text_in_chat = append_install_share_link_text(
|
||||
final_message_text_in_chat,
|
||||
_,
|
||||
install_share_url,
|
||||
)
|
||||
else:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
@@ -266,6 +295,7 @@ async def confirm_activate_trial_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -293,7 +323,13 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_trial_activation(
|
||||
user_id,
|
||||
end_date_obj,
|
||||
username=db_user.username if db_user else callback.from_user.username,
|
||||
email=getattr(db_user, "email", None) if db_user else None,
|
||||
)
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
from bot.utils.install_links import bot_install_guide_url
|
||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||
from config.settings import Settings
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
@@ -54,8 +57,9 @@ def payment_methods_back_callback(
|
||||
return f"tariff:package:{tariff_key}:{value}"
|
||||
if sale_base == "premium_topup" and tariff_key:
|
||||
return f"tariff:premium_package:{tariff_key}:{value}"
|
||||
if sale_base in {"hwid_device", "hwid_devices"} and tariff_key:
|
||||
return f"hwid_devices:package:{tariff_key}:{value}"
|
||||
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
|
||||
action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package"
|
||||
return f"hwid_devices:{action}:{tariff_key}:{value}"
|
||||
if sale_base == "tariff_upgrade" and tariff_key:
|
||||
amount = str(price) if price is not None else value
|
||||
return f"tariff_change:pay:{tariff_key}:{amount}"
|
||||
@@ -76,17 +80,34 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str:
|
||||
return f"tariff:select:{tariff_key}{context_suffix}"
|
||||
if sale_base in {"topup", "premium_topup"}:
|
||||
return "tariff_topup:list"
|
||||
if sale_base in {"hwid_device", "hwid_devices"}:
|
||||
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
|
||||
return "hwid_devices:list"
|
||||
return subscription_options_callback(context)
|
||||
|
||||
|
||||
def _trial_activation_button(lang: str, i18n_instance, settings: Settings) -> InlineKeyboardButton:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
trial_url = subscription_mini_app_trial_url(settings) or settings.SUBSCRIPTION_MINI_APP_URL
|
||||
return InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"),
|
||||
web_app=WebAppInfo(url=trial_url),
|
||||
)
|
||||
return InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"),
|
||||
callback_data="main_action:request_trial",
|
||||
)
|
||||
|
||||
|
||||
def get_main_menu_inline_keyboard(
|
||||
lang: str, i18n_instance, settings: Settings, show_trial_button: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if show_trial_button and settings.TRIAL_ENABLED:
|
||||
builder.row(_trial_activation_button(lang, i18n_instance, settings))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
@@ -129,11 +150,7 @@ def get_bot_interface_inline_keyboard(
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if show_trial_button and settings.TRIAL_ENABLED:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_activate_trial_button"), callback_data="main_action:request_trial"
|
||||
)
|
||||
)
|
||||
builder.row(_trial_activation_button(lang, i18n_instance, settings))
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
@@ -231,14 +248,18 @@ def get_language_selection_keyboard(
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
|
||||
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",
|
||||
callback_data=f"set_lang_en{callback_suffix}",
|
||||
)
|
||||
builder.button(
|
||||
text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",
|
||||
callback_data=f"set_lang_ru{callback_suffix}",
|
||||
)
|
||||
if hasattr(i18n_instance, "language_options"):
|
||||
languages = i18n_instance.language_options()
|
||||
else:
|
||||
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
|
||||
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
|
||||
for language in languages:
|
||||
lang_code = language["code"]
|
||||
checked = " ✅" if current_lang == lang_code else ""
|
||||
builder.button(
|
||||
text=f"{language['flag']} {language['label']}{checked}",
|
||||
callback_data=f"set_lang_{lang_code}{callback_suffix}",
|
||||
)
|
||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -405,6 +426,7 @@ def get_hwid_device_packages_keyboard(
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
back_callback: str = "main_action:my_subscription",
|
||||
renewal: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
@@ -417,7 +439,10 @@ def get_hwid_device_packages_keyboard(
|
||||
price=package.price,
|
||||
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
),
|
||||
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
|
||||
callback_data=(
|
||||
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
|
||||
f"{tariff.key}:{package.count}"
|
||||
),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -436,6 +461,8 @@ def get_payment_method_keyboard(
|
||||
settings: Settings,
|
||||
sale_mode: str = "subscription",
|
||||
back_callback: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -454,7 +481,16 @@ def get_payment_method_keyboard(
|
||||
|
||||
for method in settings.payment_methods_order:
|
||||
spec = get_provider_spec(method)
|
||||
if not spec or not spec.callback_prefix or not spec.is_enabled(settings):
|
||||
if (
|
||||
not spec
|
||||
or not spec.callback_prefix
|
||||
or not spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
require_configured=False,
|
||||
)
|
||||
):
|
||||
continue
|
||||
callback_data = spec.callback_data(
|
||||
value=value_str,
|
||||
@@ -689,13 +725,29 @@ def get_connect_and_main_keyboard(
|
||||
config_link: Optional[str],
|
||||
connect_button_url: Optional[str] = None,
|
||||
preserve_message: bool = False,
|
||||
install_share_url: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
install_url = bot_install_guide_url(settings)
|
||||
button_target = connect_button_url or config_link
|
||||
|
||||
if button_target:
|
||||
if install_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
web_app=WebAppInfo(url=install_url),
|
||||
)
|
||||
)
|
||||
if install_share_url:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("install_guide_share_button"),
|
||||
url=install_share_url,
|
||||
)
|
||||
)
|
||||
elif button_target:
|
||||
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
|
||||
+7
-39
@@ -5,20 +5,17 @@ from typing import Awaitable, Callable, Optional
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.exceptions import TelegramNetworkError
|
||||
from aiogram.types import BotCommand, MenuButtonDefault, MenuButtonWebApp, WebAppInfo
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.app.controllers.dispatcher_controller import build_dispatcher
|
||||
from bot.app.factories.build_services import build_core_services
|
||||
from bot.app.web.web_server import build_and_start_web_app
|
||||
from bot.handlers.admin.sync_admin import perform_sync
|
||||
from bot.infra.redis import close_redis
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.routers import build_root_router
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.settings_override_service import load_overrides_from_db
|
||||
from bot.services.locale_override_service import load_locale_overrides
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
from config.settings import Settings
|
||||
from db.database_setup import init_db_connection
|
||||
from db.database_setup import init_db, init_db_connection
|
||||
|
||||
TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0
|
||||
|
||||
@@ -156,6 +153,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
|
||||
async def _configure_mini_app_menu() -> None:
|
||||
menu_text = i18n_instance.gettext(
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
@@ -169,6 +167,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
|
||||
logging.info("STARTUP: Mini app domain registered and default menu button restored.")
|
||||
|
||||
await _run_telegram_startup_step(
|
||||
"registering mini app menu button",
|
||||
_configure_mini_app_menu,
|
||||
@@ -186,6 +185,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
await bot.set_my_commands(bot_commands)
|
||||
logging.info("STARTUP: bot command descriptions set.")
|
||||
|
||||
await _run_telegram_startup_step(
|
||||
"setting bot commands",
|
||||
_configure_bot_commands,
|
||||
@@ -200,42 +200,9 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Failed to initialize message queue manager.")
|
||||
|
||||
# Automatic sync on startup — runs in background so the dispatcher can
|
||||
# start serving Telegram webhooks immediately even if the panel is slow.
|
||||
# perform_sync is single-flight, so concurrent admin-triggered runs will
|
||||
# be skipped while this one is in progress.
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
async def _background_startup_sync(
|
||||
*,
|
||||
panel_service: PanelApiService,
|
||||
session_factory: sessionmaker,
|
||||
settings: Settings,
|
||||
i18n_instance: JsonI18n,
|
||||
) -> None:
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
sync_result = await perform_sync(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
settings=settings,
|
||||
i18n_instance=i18n_instance,
|
||||
)
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "N/A")
|
||||
if status == "completed":
|
||||
logging.info(f"STARTUP: Background sync completed successfully. Details: {details}")
|
||||
elif status == "skipped":
|
||||
logging.info(f"STARTUP: Background sync skipped: {details}")
|
||||
else:
|
||||
logging.warning(
|
||||
f"STARTUP: Background sync finished with status '{status}'. Details: {details}"
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("STARTUP: Background sync failed.")
|
||||
|
||||
|
||||
async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
|
||||
|
||||
@@ -299,9 +266,10 @@ async def run_bot(settings_param: Settings):
|
||||
if local_async_session_factory is None:
|
||||
logging.critical("Failed to initialize database connection and session factory. Exiting.")
|
||||
return
|
||||
await load_overrides_from_db(settings_param, local_async_session_factory)
|
||||
await init_db(settings_param, local_async_session_factory)
|
||||
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
|
||||
i18n_instance = extra["i18n_instance"]
|
||||
await load_locale_overrides(i18n_instance, local_async_session_factory)
|
||||
|
||||
# Get bot username for YooKassa default return URL if needed
|
||||
actual_bot_username = "your_bot_username"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update, User
|
||||
@@ -10,14 +13,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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -28,6 +28,8 @@ class ProviderEnvConfig(BaseSettings):
|
||||
env vars it consumes — no edits in the global ``Settings`` required.
|
||||
"""
|
||||
|
||||
ADMIN_ONLY_ENABLED: bool = False
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
@@ -36,6 +38,15 @@ class ProviderEnvConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
def provider_runtime_enabled(config: Any, *admin_only_attrs: str) -> bool:
|
||||
"""Return True when a provider should run for public or admin-only payments."""
|
||||
|
||||
if bool(getattr(config, "ENABLED", False)):
|
||||
return True
|
||||
attrs = admin_only_attrs or ("ADMIN_ONLY_ENABLED",)
|
||||
return any(bool(getattr(config, attr, False)) for attr in attrs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfigBundle:
|
||||
"""Functional config + presentation overrides for a single provider."""
|
||||
@@ -68,6 +79,9 @@ class ProviderManifestField:
|
||||
attr: Optional[str] = (
|
||||
None # attribute name on the target model; defaults to key without env_prefix
|
||||
)
|
||||
i18n_label_key: Optional[str] = None
|
||||
i18n_description_key: Optional[str] = None
|
||||
i18n_subsection_key: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,6 +113,11 @@ class WebAppPaymentContext:
|
||||
description: str
|
||||
sale_mode: str
|
||||
traffic_gb: Optional[float] = None
|
||||
hwid_valid_from: Optional[Any] = None
|
||||
hwid_valid_until: Optional[Any] = None
|
||||
hwid_pricing_period_months: Optional[int] = None
|
||||
hwid_proration_ratio: Optional[float] = None
|
||||
hwid_full_price: Optional[float] = None
|
||||
|
||||
|
||||
EnabledPredicate = Callable[[Any], bool]
|
||||
@@ -135,11 +154,23 @@ class PaymentProviderSpec:
|
||||
config_class: Optional[Type[ProviderEnvConfig]] = None
|
||||
presentation_class: Optional[Type[ProviderEnvConfig]] = None
|
||||
manifest_fields: Sequence[ProviderManifestField] = ()
|
||||
enabled_manifest_key: Optional[str] = None
|
||||
admin_only_manifest_key: Optional[str] = None
|
||||
admin_only_config_attr: str = "ADMIN_ONLY_ENABLED"
|
||||
admin_only_enabled: Optional[EnabledPredicate] = None
|
||||
|
||||
@property
|
||||
def settings_key(self) -> str:
|
||||
return self.id.upper()
|
||||
|
||||
@property
|
||||
def enabled_field_key(self) -> str:
|
||||
return self.enabled_manifest_key or f"{self.settings_key}_ENABLED"
|
||||
|
||||
@property
|
||||
def admin_only_field_key(self) -> str:
|
||||
return self.admin_only_manifest_key or f"{self.settings_key}_ADMIN_ONLY_ENABLED"
|
||||
|
||||
@property
|
||||
def default_telegram_emoji(self) -> str:
|
||||
return self.telegram_emoji or self.emoji
|
||||
@@ -148,7 +179,7 @@ class PaymentProviderSpec:
|
||||
def method_ids(self) -> tuple[str, ...]:
|
||||
return (self.id, *tuple(self.aliases))
|
||||
|
||||
def is_enabled(self, source: Any) -> bool:
|
||||
def _predicate_value(self, predicate: EnabledPredicate, source: Any) -> bool:
|
||||
# If this spec carries a provider-local config_class, prefer the live
|
||||
# config bundle so callers can pass plain Settings without having to
|
||||
# know about provider-local env layouts.
|
||||
@@ -157,8 +188,46 @@ class PaymentProviderSpec:
|
||||
|
||||
bundle = get_provider_bundle(self.service_key)
|
||||
if bundle and bundle.config is not None:
|
||||
return bool(self.enabled(bundle.config))
|
||||
return bool(self.enabled(source))
|
||||
return bool(predicate(bundle.config))
|
||||
return bool(predicate(source))
|
||||
|
||||
def is_enabled(self, source: Any) -> bool:
|
||||
return self._predicate_value(self.enabled, source)
|
||||
|
||||
def is_admin_only_enabled(self, source: Any) -> bool:
|
||||
if self.admin_only_enabled is not None:
|
||||
return self._predicate_value(self.admin_only_enabled, source)
|
||||
if self.config_class is not None and self.service_key:
|
||||
from .registry import get_provider_bundle
|
||||
|
||||
bundle = get_provider_bundle(self.service_key)
|
||||
if bundle and bundle.config is not None:
|
||||
return bool(getattr(bundle.config, self.admin_only_config_attr, False))
|
||||
return bool(getattr(source, self.admin_only_field_key, False))
|
||||
|
||||
def is_effectively_enabled(self, source: Any) -> bool:
|
||||
return self.is_enabled(source) or self.is_admin_only_enabled(source)
|
||||
|
||||
def _is_admin_user(
|
||||
self,
|
||||
source: Any,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
) -> bool:
|
||||
if is_admin is not None:
|
||||
return bool(is_admin)
|
||||
if user_id is None:
|
||||
return False
|
||||
try:
|
||||
normalized_user_id = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
try:
|
||||
admin_ids = {int(item) for item in (getattr(source, "ADMIN_IDS", None) or [])}
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return normalized_user_id in admin_ids
|
||||
|
||||
def is_service_configured(self, app: Any) -> bool:
|
||||
if not self.requires_configured_service:
|
||||
@@ -171,6 +240,43 @@ class PaymentProviderSpec:
|
||||
def is_visible(self, source: Any, app: Any) -> bool:
|
||||
return self.is_enabled(source) and self.is_service_configured(app)
|
||||
|
||||
def is_available_to_user(
|
||||
self,
|
||||
source: Any,
|
||||
app: Any = None,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
require_configured: bool = True,
|
||||
) -> bool:
|
||||
public_enabled = self.is_enabled(source)
|
||||
admin_only_visible = self.is_admin_only_enabled(source) and self._is_admin_user(
|
||||
source,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if not (public_enabled or admin_only_visible):
|
||||
return False
|
||||
if require_configured and app is not None and not self.is_service_configured(app):
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_visible_for_user(
|
||||
self,
|
||||
source: Any,
|
||||
app: Any,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
) -> bool:
|
||||
return self.is_available_to_user(
|
||||
source,
|
||||
app,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
require_configured=True,
|
||||
)
|
||||
|
||||
def load_router(self) -> Any:
|
||||
return self.router
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from .base import (
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
PaymentSuccessRequest,
|
||||
@@ -37,9 +38,12 @@ from .shared import (
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
quote_hwid_callback_parts,
|
||||
render_payment_link,
|
||||
sale_mode_base,
|
||||
sale_mode_is_traffic,
|
||||
sale_mode_tariff_key,
|
||||
)
|
||||
|
||||
@@ -119,7 +123,7 @@ class CryptoPayService:
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.config.ENABLED and self.config.TOKEN)
|
||||
return bool(provider_runtime_enabled(self.config) and self.config.TOKEN)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
@@ -154,13 +158,14 @@ class CryptoPayService:
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
@@ -176,7 +181,17 @@ class CryptoPayService:
|
||||
"provider": "cryptopay",
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_gb": amounts.purchased_gb,
|
||||
"purchased_hwid_devices": amounts.purchased_hwid_devices,
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio")
|
||||
if hwid_quote
|
||||
else None,
|
||||
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -191,7 +206,7 @@ class CryptoPayService:
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if is_traffic else None,
|
||||
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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="💳",
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
@@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona
|
||||
|
||||
|
||||
class EmailAuthService:
|
||||
def __init__(self, settings: Settings):
|
||||
def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None):
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
|
||||
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
||||
attempts: list[SmtpAttempt] = []
|
||||
@@ -465,6 +467,7 @@ class EmailAuthService:
|
||||
language_code=language_code,
|
||||
magic_link=magic_link,
|
||||
purpose=purpose,
|
||||
i18n=self.i18n,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
|
||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
|
||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
|
||||
from config.settings import Settings
|
||||
|
||||
_BG = "#05070a"
|
||||
@@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str:
|
||||
|
||||
|
||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
||||
return normalize_locale_language_code(
|
||||
language_code or settings.DEFAULT_LANGUAGE or "ru",
|
||||
prefer_known_base=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"),
|
||||
|
||||
@@ -597,6 +597,54 @@ class PanelApiService:
|
||||
return f"{base_sub_url}/{client_type.lower()}"
|
||||
return base_sub_url
|
||||
|
||||
async def get_subscription_page_config_by_short_uuid(
|
||||
self,
|
||||
short_uuid: str,
|
||||
request_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not short_uuid:
|
||||
return None
|
||||
endpoint = f"/subscriptions/subpage-config/{short_uuid}"
|
||||
payload = {"requestHeaders": request_headers or {}}
|
||||
response_data = await self._request(
|
||||
"GET",
|
||||
endpoint,
|
||||
json=payload,
|
||||
log_full_response=False,
|
||||
)
|
||||
if response_data and not response_data.get("error"):
|
||||
return response_data.get("response", response_data)
|
||||
logging.error(
|
||||
f"Failed to get subscription page config for short UUID {short_uuid}. Response: {response_data}" # noqa: E501
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_subscription_page_config_list(self) -> Optional[Dict[str, Any]]:
|
||||
endpoint = "/subscription-page-configs"
|
||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||
if response_data and not response_data.get("error"):
|
||||
return response_data.get("response", response_data)
|
||||
logging.error(
|
||||
f"Failed to get subscription page config list from panel. Response: {response_data}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_subscription_page_config_by_uuid(
|
||||
self,
|
||||
config_uuid: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
config_uuid = str(config_uuid or "").strip()
|
||||
if not config_uuid:
|
||||
return None
|
||||
endpoint = f"/subscription-page-configs/{config_uuid}"
|
||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||
if response_data and not response_data.get("error"):
|
||||
return response_data.get("response", response_data)
|
||||
logging.error(
|
||||
f"Failed to get subscription page config {config_uuid} from panel. Response: {response_data}" # noqa: E501
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
|
||||
if self._devices_cache.ttl_seconds <= 0:
|
||||
return await self._get_user_devices_uncached(user_uuid)
|
||||
|
||||
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from db.dal import tariff_dal, user_dal
|
||||
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_subscription_expiring
|
||||
@@ -63,13 +63,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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Helpers for Telegram bot install-guide links."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.utils.mini_app_url import (
|
||||
subscription_mini_app_install_url,
|
||||
subscription_public_install_url,
|
||||
)
|
||||
from config.subscription_guides_config import subscription_guides_available
|
||||
from db.dal import subscription_dal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallGuideLinks:
|
||||
personal_url: Optional[str] = None
|
||||
public_share_url: Optional[str] = None
|
||||
|
||||
|
||||
def bot_install_guides_enabled(settings: Any) -> bool:
|
||||
return bool(
|
||||
getattr(settings, "SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED", False)
|
||||
and subscription_guides_available(settings)
|
||||
and subscription_mini_app_install_url(settings)
|
||||
)
|
||||
|
||||
|
||||
def bot_install_guide_url(settings: Any) -> Optional[str]:
|
||||
if not bot_install_guides_enabled(settings):
|
||||
return None
|
||||
return subscription_mini_app_install_url(settings)
|
||||
|
||||
|
||||
async def ensure_user_install_guide_links(
|
||||
session: AsyncSession,
|
||||
settings: Any,
|
||||
user_id: int,
|
||||
panel_user_uuid: Optional[str] = None,
|
||||
local_subscription: Optional[Any] = None,
|
||||
) -> InstallGuideLinks:
|
||||
personal_url = bot_install_guide_url(settings)
|
||||
if not personal_url:
|
||||
return InstallGuideLinks()
|
||||
|
||||
public_share_url = None
|
||||
try:
|
||||
local_sub = (
|
||||
local_subscription
|
||||
if local_subscription is not None
|
||||
else await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
user_id,
|
||||
panel_user_uuid,
|
||||
)
|
||||
)
|
||||
if local_sub is not None:
|
||||
share_token = await subscription_dal.ensure_install_share_token(session, local_sub)
|
||||
public_share_url = subscription_public_install_url(settings, share_token)
|
||||
except Exception:
|
||||
logging.exception("Failed to resolve install guide share link for user %s.", user_id)
|
||||
|
||||
return InstallGuideLinks(personal_url=personal_url, public_share_url=public_share_url)
|
||||
|
||||
|
||||
def append_install_share_link_text(
|
||||
text: str,
|
||||
translator: Any,
|
||||
public_share_url: Optional[str],
|
||||
) -> str:
|
||||
if not public_share_url:
|
||||
return text
|
||||
try:
|
||||
share_line = translator(
|
||||
"install_guide_share_link_line",
|
||||
install_share_link=public_share_url,
|
||||
)
|
||||
except Exception:
|
||||
share_line = f"\n\nInstall guide:\n<code>{public_share_url}</code>"
|
||||
return f"{text}{share_line}"
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal.subscription_dal import normalize_install_share_token
|
||||
|
||||
|
||||
def append_query_params(base_url: str, params: dict[str, str]) -> str:
|
||||
@@ -31,3 +32,36 @@ def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[s
|
||||
return None
|
||||
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
|
||||
return append_query_params(base, {"topup": normalized})
|
||||
|
||||
|
||||
def subscription_mini_app_path_url(settings: Settings, path: str) -> Optional[str]:
|
||||
"""Return a Mini App URL with ``path`` appended to the configured app base."""
|
||||
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
normalized_path = f"/{str(path or '').lstrip('/')}"
|
||||
return f"{base.rstrip('/')}{normalized_path}"
|
||||
|
||||
|
||||
def subscription_mini_app_install_url(settings: Settings) -> Optional[str]:
|
||||
"""Return the personal embedded install guide URL."""
|
||||
return subscription_mini_app_path_url(settings, "/install")
|
||||
|
||||
|
||||
def subscription_mini_app_trial_url(settings: Settings) -> Optional[str]:
|
||||
"""Return the trial activation URL inside the Mini App."""
|
||||
return subscription_mini_app_path_url(settings, "/trial")
|
||||
|
||||
|
||||
def subscription_public_install_url(settings: Settings, share_token: str) -> Optional[str]:
|
||||
"""Return the public install guide URL for a normalized share token."""
|
||||
token = normalize_install_share_token(share_token)
|
||||
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
|
||||
if not token or not base:
|
||||
return None
|
||||
parts = urlsplit(base)
|
||||
if parts.scheme and parts.netloc:
|
||||
public_base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
else:
|
||||
public_base = base.rstrip("/")
|
||||
return f"{public_base.rstrip('/')}/s/{quote(token)}"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user