Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a66d67e44 | ||
|
|
c1e2fe2c95 | ||
|
|
ea4ee4c4a7 | ||
|
|
fbc3e193bf | ||
|
|
3541f2f78b | ||
|
|
acc222da41 | ||
|
|
8c0e778388 | ||
|
|
c6c5352813 | ||
|
|
067d6fb59f | ||
|
|
c3e55bc853 | ||
|
|
09be82aad5 | ||
|
|
49781af921 | ||
|
|
604ba873dc | ||
|
|
923ff9b208 | ||
|
|
7e7a2e58a2 | ||
|
|
e27886e15d | ||
|
|
7fe8e676cd | ||
|
|
6803c7801f | ||
|
|
cab963dcdc | ||
|
|
001e54cfe2 | ||
|
|
5e257c0d3a | ||
|
|
19f0f27a3b | ||
|
|
6fbb8eebec | ||
|
|
32616c80ab | ||
|
|
3186be1e3e | ||
|
|
1329eb4fe2 | ||
|
|
913b2d428e |
+4
-1
@@ -56,7 +56,10 @@ PANEL_API_URL=https://panel.yourdomain.tld/api
|
||||
PANEL_API_KEY=
|
||||
|
||||
# Shared secret for validating incoming Remnawave webhooks.
|
||||
# Use the same value when configuring the webhook in Remnawave panel.
|
||||
# Create or set this secret in Remnawave Panel, then paste the same value here
|
||||
# or into Admin -> System -> Settings -> Remnawave Panel.
|
||||
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
|
||||
# for example: https://app.example.com/webhook/panel
|
||||
PANEL_WEBHOOK_SECRET=
|
||||
|
||||
# Host port that publishes the backend webhook server from Docker Compose.
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
.gitattributes text eol=lf
|
||||
*.sh text eol=lf
|
||||
deploy/docker/frontend/*.sh text eol=lf
|
||||
frontend/src/*.js text eol=lf
|
||||
frontend/src/**/*.js text eol=lf
|
||||
frontend/src/**/*.svelte text eol=lf
|
||||
frontend/scripts/*.mjs text eol=lf
|
||||
frontend/scripts/**/*.mjs text eol=lf
|
||||
|
||||
@@ -92,11 +92,13 @@ docker compose logs -f backend worker frontend
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||
|
||||
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
|
||||
@@ -143,12 +143,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
|
||||
return None, None
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
|
||||
if loaded_user is None:
|
||||
return str(payment_user_id)
|
||||
def _user_display_label(
|
||||
loaded_user: Any,
|
||||
fallback_user_id: Optional[int],
|
||||
*,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Human-facing name: TG profile name, else email, else user id."""
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if tid is not None:
|
||||
if loaded_user is not None and tid is not None:
|
||||
fn = (getattr(loaded_user, "first_name", None) or "").strip()
|
||||
ln = (getattr(loaded_user, "last_name", None) or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
@@ -157,10 +163,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
un = (getattr(loaded_user, "username", None) or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
return str(payment_user_id)
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
elif loaded_user is not None:
|
||||
email = (getattr(loaded_user, "email", None) or "").strip()
|
||||
if email:
|
||||
return email
|
||||
fn = (first_name or "").strip()
|
||||
ln = (last_name or "").strip()
|
||||
full = f"{fn} {ln}".strip()
|
||||
if full:
|
||||
return full
|
||||
un = (username or "").strip()
|
||||
if un:
|
||||
return un if un.startswith("@") else f"@{un}"
|
||||
email_value = (email or "").strip()
|
||||
if email_value:
|
||||
return email_value
|
||||
if fallback_user_id is None:
|
||||
return None
|
||||
return str(fallback_user_id)
|
||||
|
||||
|
||||
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
|
||||
label = _user_display_label(loaded_user, payment_user_id)
|
||||
if label:
|
||||
return label
|
||||
return str(payment_user_id)
|
||||
|
||||
|
||||
@@ -229,16 +255,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
|
||||
|
||||
|
||||
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
|
||||
author_user = entry.__dict__.get("author_user")
|
||||
target_user = entry.__dict__.get("target_user")
|
||||
user_id = int(entry.user_id) if entry.user_id is not None else None
|
||||
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
|
||||
return {
|
||||
"log_id": int(entry.log_id),
|
||||
"user_id": int(entry.user_id) if entry.user_id else None,
|
||||
"user_id": user_id,
|
||||
"user_label": _user_display_label(
|
||||
author_user,
|
||||
user_id,
|
||||
first_name=entry.telegram_first_name,
|
||||
username=entry.telegram_username,
|
||||
),
|
||||
"telegram_username": entry.telegram_username,
|
||||
"telegram_first_name": entry.telegram_first_name,
|
||||
"email": getattr(getattr(entry, "author_user", None), "email", None),
|
||||
"email": getattr(author_user, "email", None),
|
||||
"event_type": entry.event_type,
|
||||
"content": entry.content,
|
||||
"is_admin_event": bool(entry.is_admin_event),
|
||||
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
|
||||
"target_user_id": target_user_id,
|
||||
"target_user_label": _user_display_label(target_user, target_user_id),
|
||||
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ class SettingField:
|
||||
i18n_label_key: Optional[str] = None
|
||||
i18n_description_key: Optional[str] = None
|
||||
i18n_subsection_key: Optional[str] = None
|
||||
webhook_path: Optional[str] = None
|
||||
webhook_requires_base_url: bool = False
|
||||
webhook_provider_id: Optional[str] = None
|
||||
webhook_hint_i18n_key: Optional[str] = None
|
||||
webhook_hint: str = ""
|
||||
|
||||
|
||||
SETTINGS_MANIFEST: List[SettingField] = [
|
||||
@@ -84,44 +89,44 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField(
|
||||
"PANEL_API_URL",
|
||||
"url",
|
||||
"general",
|
||||
"remnawave",
|
||||
"URL API Remnawave",
|
||||
"Например, https://panel.example.com/api.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_KEY",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"API-ключ Remnawave",
|
||||
"Секретный ключ API панели.",
|
||||
secret=True,
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_WEBHOOK_SECRET",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"Секрет вебхуков Remnawave",
|
||||
"Используется для проверки входящих вебхуков панели.",
|
||||
secret=True,
|
||||
subsection="Remnawave",
|
||||
webhook_path="/webhook/panel",
|
||||
webhook_requires_base_url=True,
|
||||
webhook_provider_id="remnawave",
|
||||
webhook_hint_i18n_key="admin_settings_panel_webhook_url_hint",
|
||||
webhook_hint="Use this URL as WEBHOOK_URL in Remnawave Panel.",
|
||||
),
|
||||
SettingField(
|
||||
"USER_SQUAD_UUIDS",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"Internal Squads по умолчанию",
|
||||
"UUID через запятую для legacy-режима без JSON-каталога тарифов.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
SettingField(
|
||||
"USER_EXTERNAL_SQUAD_UUID",
|
||||
"string",
|
||||
"general",
|
||||
"remnawave",
|
||||
"External Squad по умолчанию",
|
||||
"Необязательный UUID External Squad для новых пользователей.",
|
||||
subsection="Remnawave",
|
||||
),
|
||||
# ─── Web app appearance ────────────────────────────────────────
|
||||
SettingField(
|
||||
@@ -230,6 +235,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
|
||||
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 1 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 3 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 6 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашающему: 12 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 1 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 3 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 6 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Бонус приглашённому: 12 мес.",
|
||||
min=0,
|
||||
subsection="legacy_tariffs",
|
||||
),
|
||||
SettingField(
|
||||
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
|
||||
),
|
||||
@@ -333,62 +402,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 1 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 3 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 6 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашающему: 12 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 1 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 3 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 6 мес.",
|
||||
min=0,
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||
"int",
|
||||
"referral",
|
||||
"Бонус приглашённому: 12 мес.",
|
||||
min=0,
|
||||
),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
@@ -396,6 +409,13 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"notifications",
|
||||
"Включены уведомления о подписке",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED",
|
||||
"bool",
|
||||
"notifications",
|
||||
"Дублировать уведомления о подписке на email",
|
||||
"Письма отправляются только пользователям с привязанным email и рабочим SMTP.",
|
||||
),
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"
|
||||
),
|
||||
@@ -692,6 +712,7 @@ def manifest_payload() -> List[dict]:
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
"appearance": 2,
|
||||
"remnawave": 3,
|
||||
"pricing": 11,
|
||||
"payments": 4,
|
||||
"trial": 5,
|
||||
@@ -758,6 +779,15 @@ def manifest_payload() -> List[dict]:
|
||||
item["default"] = default_value
|
||||
if webhook_metadata:
|
||||
item.update(webhook_metadata)
|
||||
if field.webhook_path:
|
||||
item["webhook_path"] = field.webhook_path
|
||||
item["webhook_requires_base_url"] = field.webhook_requires_base_url
|
||||
if field.webhook_provider_id:
|
||||
item["provider_id"] = field.webhook_provider_id
|
||||
if field.webhook_hint_i18n_key:
|
||||
item["webhook_hint_i18n_key"] = field.webhook_hint_i18n_key
|
||||
if field.webhook_hint:
|
||||
item["webhook_hint"] = field.webhook_hint
|
||||
if field.choices:
|
||||
item["choices"] = [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ from bot.app.web.webapp import (
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
support as _support,
|
||||
telegram_notifications as _telegram_notifications,
|
||||
)
|
||||
|
||||
_MODULES = (
|
||||
@@ -25,6 +26,7 @@ _MODULES = (
|
||||
_assets,
|
||||
_auth,
|
||||
_account,
|
||||
_telegram_notifications,
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
|
||||
@@ -107,6 +107,33 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
|
||||
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
||||
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
ROBOTS_TX = """User-agent: *
|
||||
Disallow: /
|
||||
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Disallow: /
|
||||
"""
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/telegram/nonce",
|
||||
|
||||
@@ -8,6 +8,7 @@ from .auth import (
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
@@ -416,6 +417,8 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
except Exception:
|
||||
logger.exception("Failed to send account Telegram linked notification")
|
||||
|
||||
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response_payload: Dict[str, Any] = {
|
||||
"ok": True,
|
||||
|
||||
@@ -23,6 +23,12 @@ async def health_route(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def robots_txt_route(request: web.Request) -> web.Response:
|
||||
response = web.Response(text=ROBOTS_TX, content_type="text/plain")
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
async def css_asset_route(request: web.Request) -> web.Response:
|
||||
return await _css_asset_route(request, base_name="subscription_webapp")
|
||||
|
||||
@@ -869,6 +875,7 @@ async def _security_headers_middleware(request: web.Request, handler):
|
||||
)
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||
@@ -436,6 +437,9 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
first_name=linked_user_for_panel.first_name,
|
||||
)
|
||||
|
||||
if final_user_id:
|
||||
await _probe_telegram_notifications_for_user_id(request, int(final_user_id))
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||
_clear_telegram_oauth_state_cookie(response)
|
||||
@@ -531,6 +535,7 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True)
|
||||
await _probe_telegram_notifications_for_user_id(request, int(authenticated_user_id))
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
|
||||
|
||||
|
||||
@@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response:
|
||||
"devices",
|
||||
user_id,
|
||||
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
|
||||
lambda: _load_devices_payload(subscription_service, session, user_id),
|
||||
lambda: _load_devices_payload(
|
||||
subscription_service,
|
||||
session,
|
||||
user_id,
|
||||
fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip()
|
||||
or None,
|
||||
),
|
||||
)
|
||||
if isinstance(result, dict) and result.get("ok") is True:
|
||||
return web.json_response({"ok": True, **(result.get("payload") or {})})
|
||||
@@ -45,16 +51,12 @@ async def _load_devices_payload(
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
fallback_panel_user_uuid: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
panel_user_uuid = active.get("user_id") if active else None
|
||||
panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip()
|
||||
if not panel_user_uuid:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": 400,
|
||||
"error": "subscription_not_active",
|
||||
"message": "Subscription is not active",
|
||||
}
|
||||
return _empty_inactive_devices_payload()
|
||||
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if not panel_service:
|
||||
@@ -82,6 +84,7 @@ async def _load_devices_payload(
|
||||
"ok": True,
|
||||
"payload": {
|
||||
"enabled": True,
|
||||
"subscription_active": _devices_subscription_is_active(active),
|
||||
"current_devices": len(devices),
|
||||
"max_devices": max_devices,
|
||||
"max_devices_label": _format_devices_limit(max_devices),
|
||||
@@ -92,6 +95,31 @@ async def _load_devices_payload(
|
||||
}
|
||||
|
||||
|
||||
def _empty_inactive_devices_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"payload": {
|
||||
"enabled": True,
|
||||
"subscription_active": False,
|
||||
"current_devices": 0,
|
||||
"max_devices": None,
|
||||
"max_devices_label": _format_devices_limit(None),
|
||||
"devices": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool:
|
||||
if not active:
|
||||
return False
|
||||
end_date = active.get("end_date")
|
||||
if not isinstance(end_date, datetime):
|
||||
return False
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
return end_date > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def disconnect_device_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
|
||||
@@ -3,6 +3,7 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/robots.txt", robots_txt_route)
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
@@ -84,6 +85,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post(
|
||||
"/api/account/telegram/notifications/probe",
|
||||
account_telegram_notifications_probe_route,
|
||||
)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
|
||||
@@ -3,6 +3,12 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from config.subscription_guides_config import subscription_guides_available
|
||||
from config.webapp_themes_config import public_themes_catalog_payload
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
normalize_telegram_notification_status,
|
||||
telegram_notifications_need_prompt,
|
||||
telegram_notifications_start_link,
|
||||
)
|
||||
|
||||
|
||||
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
|
||||
@@ -72,6 +78,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
telegram_notifications_status = normalize_telegram_notification_status(
|
||||
getattr(db_user, "telegram_notifications_status", None)
|
||||
)
|
||||
telegram_notifications_link = telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
)
|
||||
return {
|
||||
"user": {
|
||||
"id": user_id,
|
||||
@@ -83,6 +95,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_notifications_status": telegram_notifications_status,
|
||||
"telegram_notifications_enabled": (
|
||||
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
),
|
||||
"telegram_notifications_need_prompt": telegram_notifications_need_prompt(db_user),
|
||||
"telegram_notifications_start_link": telegram_notifications_link,
|
||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||
"first_name": db_user.first_name,
|
||||
"language_code": lang,
|
||||
@@ -156,12 +174,79 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
}
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
return []
|
||||
|
||||
return sorted(int(months) for months in settings.subscription_options)
|
||||
|
||||
|
||||
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
for months in sorted(int(month) for month in tariff.enabled_periods):
|
||||
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||
friend_days = tariff.referral_referee_bonus_days(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
continue
|
||||
details.append(
|
||||
{
|
||||
"id": f"{tariff.key}:{months}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"months": int(months),
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"inviter_days": int(inviter_days or 0),
|
||||
"friend_days": int(friend_days or 0),
|
||||
}
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if not tariffs_config:
|
||||
return []
|
||||
|
||||
period_tariffs = [
|
||||
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||
]
|
||||
if len(period_tariffs) <= 1:
|
||||
return (
|
||||
_serialize_tariff_period_referral_bonus_details(period_tariffs[0], lang)
|
||||
if period_tariffs
|
||||
else []
|
||||
)
|
||||
|
||||
summaries: List[Dict[str, Any]] = []
|
||||
for tariff in period_tariffs:
|
||||
details = _serialize_tariff_period_referral_bonus_details(tariff, lang)
|
||||
if not details:
|
||||
continue
|
||||
inviter_values = [int(item["inviter_days"]) for item in details]
|
||||
friend_values = [int(item["friend_days"]) for item in details]
|
||||
summaries.append(
|
||||
{
|
||||
"id": f"tariff:{tariff.key}",
|
||||
"type": "tariff_summary",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"title": tariff.name(lang),
|
||||
"inviter_min_days": min(inviter_values),
|
||||
"inviter_max_days": max(inviter_values),
|
||||
"friend_min_days": min(friend_values),
|
||||
"friend_max_days": max(friend_values),
|
||||
"details": details,
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
if settings.tariffs_config:
|
||||
return _serialize_tariff_referral_bonus_details(settings, lang)
|
||||
|
||||
details: List[Dict[str, Any]] = []
|
||||
for months in _legacy_referral_bonus_periods(settings):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
friend_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and friend_days is None:
|
||||
@@ -391,6 +476,7 @@ def _serialize_plans(
|
||||
for tariff in tariffs_config.enabled_tariffs:
|
||||
common = {
|
||||
"tariff_key": tariff.key,
|
||||
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"description": tariff.description(lang),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
probe_telegram_notifications,
|
||||
telegram_notifications_start_link,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
async def _probe_telegram_notifications_for_user_id(
|
||||
request: web.Request,
|
||||
user_id: int,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "access_denied",
|
||||
"enabled": False,
|
||||
"start_link": telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
),
|
||||
}
|
||||
result = await probe_telegram_notifications(
|
||||
session=session,
|
||||
bot=request.app["bot"],
|
||||
settings=settings,
|
||||
i18n=request.app.get("i18n"),
|
||||
user=db_user,
|
||||
bot_username=request.app.get("bot_username") or "",
|
||||
force=force,
|
||||
)
|
||||
await session.commit()
|
||||
status = str(result.get("status") or "")
|
||||
await _invalidate_webapp_user_caches(settings, int(db_user.user_id))
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"status": status,
|
||||
"enabled": status == TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"start_link": result.get("start_link"),
|
||||
}
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Telegram notification probe failed")
|
||||
return {
|
||||
"ok": False,
|
||||
"status": "unknown",
|
||||
"enabled": False,
|
||||
"start_link": telegram_notifications_start_link(
|
||||
request.app.get("bot_username") or ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def account_telegram_notifications_probe_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
force = True
|
||||
result = await _probe_telegram_notifications_for_user_id(request, user_id, force=force)
|
||||
if result.get("status") == "access_denied":
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
return web.json_response({"ok": True, "telegram_notifications": result})
|
||||
@@ -14,6 +14,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.utils.text_sanitizer import panel_description_from_profile
|
||||
from config.settings import Settings
|
||||
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
||||
from db.models import Subscription, User
|
||||
|
||||
@@ -928,6 +929,7 @@ async def _perform_sync_impl(
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
await acquire_subscription_background_sync_lock(session)
|
||||
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
|
||||
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
|
||||
users_by_user_id = sync_indexes["users_by_user_id"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
@@ -76,31 +76,10 @@ async def referral_command_handler(
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
bonus_info_parts = []
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
bonus_details_str = _("referral_not_available_for_traffic")
|
||||
else:
|
||||
if settings.subscription_options:
|
||||
for months_period_key, _price in sorted(settings.subscription_options.items()):
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_(
|
||||
"referral_bonus_per_period",
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None
|
||||
else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None
|
||||
else _("no_bonus_placeholder"),
|
||||
)
|
||||
)
|
||||
|
||||
bonus_details_str = (
|
||||
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
|
||||
)
|
||||
bonus_details_str = _build_referral_bonus_details_text(settings, _, current_lang)
|
||||
|
||||
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
|
||||
|
||||
@@ -208,6 +187,132 @@ async def referral_action_handler(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
Translator = Callable[..., str]
|
||||
|
||||
|
||||
def _period_bonus_text(
|
||||
translator: Translator,
|
||||
*,
|
||||
months: int,
|
||||
inviter_days: Optional[int],
|
||||
referee_days: Optional[int],
|
||||
) -> str:
|
||||
return translator(
|
||||
"referral_bonus_per_period",
|
||||
months=months,
|
||||
inviter_bonus_days=(
|
||||
inviter_days if inviter_days is not None else translator("no_bonus_placeholder")
|
||||
),
|
||||
referee_bonus_days=(
|
||||
referee_days if referee_days is not None else translator("no_bonus_placeholder")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tariff_period_bonus_entries(tariff: Any) -> list[dict[str, Optional[int]]]:
|
||||
entries: list[dict[str, Optional[int]]] = []
|
||||
for months in sorted(int(month) for month in getattr(tariff, "enabled_periods", [])):
|
||||
inviter_days = tariff.referral_inviter_bonus_days(months)
|
||||
referee_days = tariff.referral_referee_bonus_days(months)
|
||||
if inviter_days is None and referee_days is None:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"months": months,
|
||||
"inviter_days": inviter_days,
|
||||
"referee_days": referee_days,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _legacy_period_bonus_entries(settings: Settings) -> list[dict[str, Optional[int]]]:
|
||||
entries: list[dict[str, Optional[int]]] = []
|
||||
for months, _price in sorted(settings.subscription_options.items()):
|
||||
inviter_days = settings.referral_bonus_inviter.get(months)
|
||||
referee_days = settings.referral_bonus_referee.get(months)
|
||||
if inviter_days is None and referee_days is None:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"months": int(months),
|
||||
"inviter_days": inviter_days,
|
||||
"referee_days": referee_days,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _bonus_days_range(translator: Translator, values: list[int]) -> str:
|
||||
return translator(
|
||||
"referral_bonus_days_range",
|
||||
min_days=min(values),
|
||||
max_days=max(values),
|
||||
)
|
||||
|
||||
|
||||
def _build_referral_bonus_details_text(
|
||||
settings: Settings, translator: Translator, current_lang: str
|
||||
) -> str:
|
||||
tariffs_config = settings.tariffs_config
|
||||
if not tariffs_config:
|
||||
bonus_info_parts = [
|
||||
_period_bonus_text(
|
||||
translator,
|
||||
months=int(entry["months"] or 0),
|
||||
inviter_days=entry["inviter_days"],
|
||||
referee_days=entry["referee_days"],
|
||||
)
|
||||
for entry in _legacy_period_bonus_entries(settings)
|
||||
]
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
period_tariffs = [
|
||||
tariff for tariff in tariffs_config.enabled_tariffs if tariff.billing_model == "period"
|
||||
]
|
||||
if len(period_tariffs) <= 1:
|
||||
entries = _tariff_period_bonus_entries(period_tariffs[0]) if period_tariffs else []
|
||||
bonus_info_parts = [
|
||||
_period_bonus_text(
|
||||
translator,
|
||||
months=int(entry["months"] or 0),
|
||||
inviter_days=entry["inviter_days"],
|
||||
referee_days=entry["referee_days"],
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
bonus_info_parts = []
|
||||
for tariff in period_tariffs:
|
||||
entries = _tariff_period_bonus_entries(tariff)
|
||||
if not entries:
|
||||
continue
|
||||
inviter_values = [int(entry["inviter_days"] or 0) for entry in entries]
|
||||
referee_values = [int(entry["referee_days"] or 0) for entry in entries]
|
||||
bonus_info_parts.append(
|
||||
translator(
|
||||
"referral_bonus_tariff_range",
|
||||
tariff_name=tariff.name(current_lang),
|
||||
inviter_bonus_range=_bonus_days_range(translator, inviter_values),
|
||||
referee_bonus_range=_bonus_days_range(translator, referee_values),
|
||||
)
|
||||
)
|
||||
return (
|
||||
"\n".join(bonus_info_parts)
|
||||
if bonus_info_parts
|
||||
else translator("referral_no_bonuses_configured")
|
||||
)
|
||||
|
||||
|
||||
def _build_webapp_referral_link(
|
||||
base_url: Optional[str], referral_code: Optional[str]
|
||||
) -> Optional[str]:
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
@@ -392,11 +393,12 @@ async def ensure_required_channel_subscription(
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(
|
||||
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
r"^(?!ref_|promo_|admin_user_|ticket_|notifications$|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
|
||||
).as_("ad_param_match")
|
||||
)
|
||||
)
|
||||
@@ -414,6 +416,7 @@ async def start_command_handler(
|
||||
ad_param_match: Optional[re.Match] = None,
|
||||
admin_user_match: Optional[re.Match] = None,
|
||||
ticket_match: Optional[re.Match] = None,
|
||||
notifications_match: Optional[re.Match] = None,
|
||||
):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -500,6 +503,7 @@ async def start_command_handler(
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
should_open_referral_from_start = False
|
||||
ad_start_param: Optional[str] = None
|
||||
notifications_start_requested = bool(notifications_match)
|
||||
|
||||
if ref_match:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
@@ -522,6 +526,8 @@ async def start_command_handler(
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
elif notifications_start_requested:
|
||||
logging.info("User %s started bot from notifications deep-link.", user_id)
|
||||
elif page_ref_match:
|
||||
should_open_referral_from_start = True
|
||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||
@@ -532,18 +538,24 @@ async def start_command_handler(
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
notification_status_now = datetime.now(timezone.utc)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
is_existing_user = db_user is not None
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
"telegram_id": user_id,
|
||||
"username": sanitized_username,
|
||||
"first_name": sanitized_first_name,
|
||||
"last_name": sanitized_last_name,
|
||||
"language_code": current_lang,
|
||||
"referred_by_id": referred_by_user_id,
|
||||
"registration_date": datetime.now(timezone.utc),
|
||||
"telegram_notifications_status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"telegram_notifications_checked_at": notification_status_now,
|
||||
"telegram_notifications_enabled_at": notification_status_now,
|
||||
"telegram_notifications_blocked_at": None,
|
||||
}
|
||||
try:
|
||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||
@@ -631,6 +643,13 @@ async def start_command_handler(
|
||||
update_payload = {}
|
||||
if db_user.language_code != current_lang:
|
||||
update_payload["language_code"] = current_lang
|
||||
if db_user.telegram_id != user_id:
|
||||
update_payload["telegram_id"] = user_id
|
||||
if db_user.telegram_notifications_status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||
update_payload["telegram_notifications_status"] = TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
update_payload["telegram_notifications_checked_at"] = notification_status_now
|
||||
update_payload["telegram_notifications_enabled_at"] = notification_status_now
|
||||
update_payload["telegram_notifications_blocked_at"] = None
|
||||
# Set referral only if not already set AND user is not currently active.
|
||||
# This allows previously subscribed but currently inactive users to be attributed.
|
||||
if referred_by_user_id and db_user.referred_by_id is None:
|
||||
@@ -684,9 +703,16 @@ async def start_command_handler(
|
||||
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
|
||||
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
|
||||
if (
|
||||
not settings.DISABLE_WELCOME_MESSAGE
|
||||
and not open_referral_page_for_existing_user
|
||||
and not notifications_start_requested
|
||||
):
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
if notifications_start_requested:
|
||||
await message.answer(_("telegram_notifications_started"), parse_mode="HTML")
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
|
||||
@@ -114,6 +114,20 @@ def _with_subscription_purchase_description(
|
||||
return f"{description}\n\n{text}"
|
||||
|
||||
|
||||
def _format_premium_bytes(value: object) -> str:
|
||||
try:
|
||||
bytes_value = max(0, int(value or 0))
|
||||
except (TypeError, ValueError):
|
||||
bytes_value = 0
|
||||
return f"{bytes_value / 2**30:.2f} GB"
|
||||
|
||||
|
||||
def _format_premium_usage_limit(active: dict[str, object]) -> str:
|
||||
used = _format_premium_bytes(active.get("premium_used_bytes"))
|
||||
limit = _format_premium_bytes(active.get("premium_limit_bytes"))
|
||||
return f"{used} из {limit}"
|
||||
|
||||
|
||||
async def display_subscription_options(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
@@ -456,7 +470,7 @@ async def tariff_topup_list_callback(
|
||||
if len(labels) > len(visible):
|
||||
premium_lines.append(f"• ... еще {len(labels) - len(visible)}")
|
||||
premium_lines.append(
|
||||
f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||
f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501
|
||||
)
|
||||
text = get_text("choose_payment_method_traffic")
|
||||
if carryover_lines:
|
||||
@@ -1065,7 +1079,7 @@ async def my_subscription_command_handler(
|
||||
text += (
|
||||
"\n\n🚀 <b>Premium-серверы</b>\n"
|
||||
f"Статус: <b>{premium_status}</b>\n"
|
||||
f"Лимит: <b>{active.get('premium_used')} из {active.get('premium_limit')}</b>\n"
|
||||
f"Лимит: <b>{_format_premium_usage_limit(active)}</b>\n"
|
||||
f"Осталось: <b>{premium_left / 2**30:.2f} GB</b>\n"
|
||||
f"Докупленный остаток: <b>{premium_balance / 2**30:.2f} GB</b>\n"
|
||||
"Отдельный лимит действует на:\n"
|
||||
|
||||
@@ -307,6 +307,7 @@ class JsonI18n:
|
||||
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_content: Optional[str] = None
|
||||
self._overrides_file_next_check = 0.0
|
||||
self._overrides_file_check_interval_seconds = 1.0
|
||||
self._load_locales()
|
||||
@@ -419,6 +420,7 @@ class JsonI18n:
|
||||
if self._overrides_file_mtime_ns is None:
|
||||
return False
|
||||
self._overrides_file_mtime_ns = None
|
||||
self._overrides_file_content = None
|
||||
logging.info(
|
||||
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||
"the DB fallback is reloaded"
|
||||
@@ -432,19 +434,8 @@ class JsonI18n:
|
||||
)
|
||||
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
|
||||
content = self._overrides_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logging.warning(
|
||||
"Failed to read locale overrides file %s: %s",
|
||||
@@ -453,7 +444,27 @@ class JsonI18n:
|
||||
)
|
||||
return False
|
||||
|
||||
if (
|
||||
not force
|
||||
and stat.st_mtime_ns == self._overrides_file_mtime_ns
|
||||
and content == self._overrides_file_content
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
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
|
||||
self._overrides_file_content = content
|
||||
return False
|
||||
|
||||
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||
self._overrides_file_content = content
|
||||
self.set_locale_overrides(payload)
|
||||
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||
return True
|
||||
|
||||
@@ -16,7 +16,13 @@ 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
|
||||
|
||||
from .common import Translator, format_human_units, make_translator, sale_mode_base
|
||||
from .common import (
|
||||
Translator,
|
||||
format_human_units,
|
||||
make_translator,
|
||||
sale_mode_base,
|
||||
sale_mode_tariff_key,
|
||||
)
|
||||
|
||||
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
|
||||
activation_months or 1,
|
||||
current_payment_db_id=req.payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
tariff_key=sale_mode_tariff_key(req.sale_mode),
|
||||
)
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Optional
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from db.dal import payment_dal, user_dal
|
||||
from db.models import Payment
|
||||
|
||||
@@ -55,11 +56,21 @@ async def notify_user_payment_failed(
|
||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
translator = make_translator(i18n, language)
|
||||
message_text = translator(message_key)
|
||||
try:
|
||||
await bot.send_message(payment.user_id, translator(message_key))
|
||||
await bot.send_message(payment.user_id, message_text)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Webhook helper: failed to notify user %s about %s.",
|
||||
payment.user_id,
|
||||
message_key,
|
||||
)
|
||||
if db_user:
|
||||
await send_user_notification_email(
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
user=db_user,
|
||||
subject_key="email_payment_failed_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ from bot.services.lknpd_service import LknpdService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
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
|
||||
@@ -75,6 +76,9 @@ from .shared import (
|
||||
from .shared import (
|
||||
sale_mode_base as _sale_mode_base,
|
||||
)
|
||||
from .shared import (
|
||||
sale_mode_tariff_key as _sale_mode_tariff_key,
|
||||
)
|
||||
|
||||
|
||||
class YooKassaConfig(ProviderEnvConfig):
|
||||
@@ -749,6 +753,7 @@ async def process_successful_payment(
|
||||
months_for_activation or int(subscription_months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
skip_if_active_before_payment=False,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
||||
@@ -949,7 +954,20 @@ async def process_cancelled_payment(
|
||||
user_lang = db_user.language_code
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
await bot.send_message(user_id, _("payment_failed"))
|
||||
message_text = _("payment_failed")
|
||||
try:
|
||||
await bot.send_message(user_id, message_text)
|
||||
except Exception:
|
||||
logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
|
||||
if db_user:
|
||||
await send_user_notification_email(
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
user=db_user,
|
||||
subject_key="email_payment_failed_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
|
||||
)
|
||||
|
||||
except Exception as e_process_cancel:
|
||||
logging.error(
|
||||
@@ -1202,13 +1220,32 @@ async def yookassa_webhook_route(request: web.Request):
|
||||
get_back_to_payment_methods_keyboard,
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(
|
||||
i18n_lang, i18n_instance
|
||||
),
|
||||
)
|
||||
message_text = _("payment_method_bound_success")
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=message_text,
|
||||
reply_markup=get_back_to_payment_methods_keyboard(
|
||||
i18n_lang, i18n_instance
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to notify user %s "
|
||||
"about payment method binding.",
|
||||
user_id,
|
||||
)
|
||||
if db_user:
|
||||
await send_user_notification_email(
|
||||
settings=settings,
|
||||
i18n=i18n_instance,
|
||||
user=db_user,
|
||||
subject_key="email_payment_method_bound_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(
|
||||
settings.SUBSCRIPTION_MINI_APP_URL or None
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
|
||||
@@ -202,6 +202,41 @@ def _format_traffic(traffic_gb: Optional[float]) -> str:
|
||||
return str(int(value)) if value.is_integer() else f"{value:g}"
|
||||
|
||||
|
||||
_ALLOWED_INLINE_TAGS = {
|
||||
"b": "strong",
|
||||
"strong": "strong",
|
||||
"i": "em",
|
||||
"em": "em",
|
||||
"u": "u",
|
||||
"s": "s",
|
||||
"code": "code",
|
||||
}
|
||||
_INLINE_TAG_RE = re.compile(r"</?(?:b|strong|i|em|u|s|code)>", re.IGNORECASE)
|
||||
_ANY_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _telegram_html_to_email_html(value: str) -> str:
|
||||
"""Escape arbitrary text while preserving the tiny Telegram HTML subset we use."""
|
||||
source = str(value or "")
|
||||
chunks: list[str] = []
|
||||
cursor = 0
|
||||
for match in _INLINE_TAG_RE.finditer(source):
|
||||
chunks.append(html.escape(source[cursor : match.start()]))
|
||||
raw_tag = match.group(0)
|
||||
closing = raw_tag.startswith("</")
|
||||
tag_name = raw_tag.strip("</>").lower()
|
||||
mapped = _ALLOWED_INLINE_TAGS.get(tag_name)
|
||||
if mapped:
|
||||
chunks.append(f"</{mapped}>" if closing else f"<{mapped}>")
|
||||
cursor = match.end()
|
||||
chunks.append(html.escape(source[cursor:]))
|
||||
return "".join(chunks).replace("\n", "<br>")
|
||||
|
||||
|
||||
def _telegram_html_to_text(value: str) -> str:
|
||||
return html.unescape(_ANY_TAG_RE.sub("", str(value or "")))
|
||||
|
||||
|
||||
def _format_minutes(seconds: int) -> int:
|
||||
return max(1, int(seconds) // 60)
|
||||
|
||||
@@ -351,12 +386,15 @@ def render_payment_success(
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
is_traffic = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] in {
|
||||
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||
is_traffic = sale_base in {
|
||||
"traffic",
|
||||
"traffic_package",
|
||||
"topup",
|
||||
"premium_topup",
|
||||
}
|
||||
is_hwid = sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
||||
is_tariff_upgrade = sale_base == "tariff_upgrade"
|
||||
amount_text = _format_amount(amount, currency)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
@@ -370,7 +408,12 @@ def render_payment_success(
|
||||
cta_label = _t_text(i18n, lang, "email_payment_success_cta")
|
||||
|
||||
if is_traffic:
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_traffic", traffic_gb=traffic_label)
|
||||
intro_key = (
|
||||
"email_payment_success_intro_premium_topup"
|
||||
if sale_base == "premium_topup"
|
||||
else "email_payment_success_intro_traffic"
|
||||
)
|
||||
intro = _t_text(i18n, lang, intro_key, traffic_gb=traffic_label)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_traffic")
|
||||
period_value = _t_text(
|
||||
i18n, lang, "email_payment_success_traffic_value", traffic_gb=traffic_label
|
||||
@@ -383,6 +426,30 @@ def render_payment_success(
|
||||
traffic_gb=traffic_label,
|
||||
end_date=end_date,
|
||||
)
|
||||
elif is_hwid:
|
||||
devices_count = max(0, int(months or 0))
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_hwid", count=devices_count)
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_hwid")
|
||||
period_value = _t_text(i18n, lang, "email_payment_success_hwid_value", count=devices_count)
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_hwid",
|
||||
amount=amount_text,
|
||||
count=devices_count,
|
||||
end_date=end_date,
|
||||
)
|
||||
elif is_tariff_upgrade:
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_tariff_upgrade")
|
||||
period_label = _t_text(i18n, lang, "email_payment_success_row_operation")
|
||||
period_value = _t_text(i18n, lang, "email_payment_success_tariff_upgrade_value")
|
||||
text = _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_payment_success_text_tariff_upgrade",
|
||||
amount=amount_text,
|
||||
end_date=end_date,
|
||||
)
|
||||
else:
|
||||
months_int = int(months or 0)
|
||||
intro = _t_text(i18n, lang, "email_payment_success_intro_subscription", months=months_int)
|
||||
@@ -434,6 +501,66 @@ def render_payment_success(
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_user_notification(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
subject: str,
|
||||
message_text: str,
|
||||
dashboard_url: Optional[str] = None,
|
||||
cta_label: Optional[str] = None,
|
||||
heading: Optional[str] = None,
|
||||
intro: Optional[str] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
final_subject = (subject or "").strip() or _t_text(
|
||||
i18n, lang, "email_user_notification_subject"
|
||||
)
|
||||
final_heading = (heading or "").strip() or final_subject
|
||||
final_intro = (intro or "").strip() or _t_text(i18n, lang, "email_user_notification_intro")
|
||||
final_cta_label = (cta_label or "").strip() or _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_user_notification_cta",
|
||||
)
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
message_html = (
|
||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||
f'white-space:pre-wrap;">{_telegram_html_to_email_html(message_text)}</div>'
|
||||
)
|
||||
body_parts = [message_html]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(
|
||||
_cta_button_html(label=final_cta_label, url=safe_dashboard_url, accent=accent)
|
||||
)
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=final_subject,
|
||||
heading=final_heading,
|
||||
intro_html=html.escape(final_intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
|
||||
if safe_dashboard_url:
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
_t_text(
|
||||
i18n, lang, "email_user_notification_text_dashboard", url=safe_dashboard_url
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def render_subscription_expiring(
|
||||
settings: Settings,
|
||||
*,
|
||||
@@ -497,6 +624,107 @@ def render_subscription_expiring(
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def _subscription_lifecycle_title(
|
||||
i18n: JsonI18n,
|
||||
lang: str,
|
||||
notification_key: str,
|
||||
*,
|
||||
days_left: Optional[int],
|
||||
hours_before: Optional[int],
|
||||
) -> str:
|
||||
if notification_key == "before_2d_autorenew":
|
||||
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_autorenew")
|
||||
if notification_key == "expired":
|
||||
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired")
|
||||
if notification_key == "expired_24h_after":
|
||||
return _t_text(i18n, lang, "email_subscription_lifecycle_subject_expired_after")
|
||||
if hours_before is not None:
|
||||
return _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_subscription_lifecycle_subject_before_hours",
|
||||
hours=hours_before,
|
||||
)
|
||||
return _t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_subscription_lifecycle_subject_before_days",
|
||||
days=max(0, int(days_left or 0)),
|
||||
)
|
||||
|
||||
|
||||
def render_subscription_lifecycle_notification(
|
||||
settings: Settings,
|
||||
*,
|
||||
language_code: Optional[str],
|
||||
notification_key: str,
|
||||
message_text: str,
|
||||
end_date_text: str,
|
||||
dashboard_url: Optional[str],
|
||||
mirrored_from_telegram: bool = False,
|
||||
days_left: Optional[int] = None,
|
||||
hours_before: Optional[int] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
subject = _subscription_lifecycle_title(
|
||||
i18n,
|
||||
lang,
|
||||
notification_key,
|
||||
days_left=days_left,
|
||||
hours_before=hours_before,
|
||||
)
|
||||
intro_key = (
|
||||
"email_subscription_lifecycle_intro_mirrored"
|
||||
if mirrored_from_telegram
|
||||
else "email_subscription_lifecycle_intro_direct"
|
||||
)
|
||||
intro = _t_text(i18n, lang, intro_key)
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
|
||||
|
||||
rows = [
|
||||
(_t_text(i18n, lang, "email_subscription_lifecycle_row_end_date"), end_date),
|
||||
]
|
||||
message_html = (
|
||||
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
|
||||
f"border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};"
|
||||
f'white-space:pre-wrap;">{html.escape(message_text or "")}</div>'
|
||||
)
|
||||
body_parts = [_info_rows_html(rows), message_html]
|
||||
if safe_dashboard_url:
|
||||
body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent))
|
||||
|
||||
rendered = _layout(
|
||||
settings=settings,
|
||||
preheader=subject,
|
||||
heading=subject,
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
|
||||
text_lines = [subject, "", message_text]
|
||||
if safe_dashboard_url:
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
_t_text(
|
||||
i18n,
|
||||
lang,
|
||||
"email_subscription_lifecycle_text_renew",
|
||||
url=safe_dashboard_url,
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
|
||||
|
||||
def _support_email(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
|
||||
@@ -328,12 +328,112 @@ class PanelApiService:
|
||||
async def _get_user_by_uuid_uncached(
|
||||
self, user_uuid: str, log_response: bool = False
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
lookup = await self.get_user_by_uuid_lookup(user_uuid, log_response=log_response)
|
||||
if lookup.get("ok") and isinstance(lookup.get("user"), dict):
|
||||
return lookup["user"]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _panel_response_details(response_data: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(response_data, dict):
|
||||
return {}
|
||||
details = response_data.get("details")
|
||||
return details if isinstance(details, dict) else {}
|
||||
|
||||
@classmethod
|
||||
def _panel_response_error_code(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not isinstance(response_data, dict):
|
||||
return None
|
||||
details = cls._panel_response_details(response_data)
|
||||
error_code = (
|
||||
response_data.get("errorCode")
|
||||
or response_data.get("code")
|
||||
or details.get("errorCode")
|
||||
or details.get("code")
|
||||
)
|
||||
return str(error_code) if error_code else None
|
||||
|
||||
@classmethod
|
||||
def _panel_response_message(cls, response_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not isinstance(response_data, dict):
|
||||
return None
|
||||
details = cls._panel_response_details(response_data)
|
||||
message = (
|
||||
response_data.get("message")
|
||||
or details.get("message")
|
||||
or details.get("error")
|
||||
or details.get("raw_response_text")
|
||||
)
|
||||
if message is None:
|
||||
return None
|
||||
message = str(message).replace("\n", " ").strip()
|
||||
return message[:500] if message else None
|
||||
|
||||
@classmethod
|
||||
def _is_user_not_found_response(cls, response_data: Optional[Dict[str, Any]]) -> bool:
|
||||
if not isinstance(response_data, dict):
|
||||
return False
|
||||
status_code = response_data.get("status_code")
|
||||
error_code = cls._panel_response_error_code(response_data)
|
||||
if error_code in {"A040", "A062", "USER_NOT_FOUND", "NOT_FOUND"}:
|
||||
return True
|
||||
return status_code == 404
|
||||
|
||||
@classmethod
|
||||
def _describe_user_lookup_failure(
|
||||
cls,
|
||||
response_data: Optional[Dict[str, Any]],
|
||||
*,
|
||||
not_found: bool,
|
||||
) -> str:
|
||||
if not isinstance(response_data, dict):
|
||||
return "classification=panel_lookup_failed response=empty"
|
||||
|
||||
classification = "confirmed_not_found" if not_found else "panel_lookup_failed"
|
||||
parts = [f"classification={classification}"]
|
||||
status_code = response_data.get("status_code")
|
||||
if status_code is not None:
|
||||
parts.append(f"status_code={status_code}")
|
||||
error_code = cls._panel_response_error_code(response_data)
|
||||
if error_code:
|
||||
parts.append(f"error_code={error_code}")
|
||||
message = cls._panel_response_message(response_data)
|
||||
if message:
|
||||
parts.append(f"message={message}")
|
||||
return " ".join(parts)
|
||||
|
||||
async def get_user_by_uuid_lookup(
|
||||
self, user_uuid: str, log_response: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch a panel user and preserve whether a miss was confirmed.
|
||||
|
||||
``get_user_by_uuid`` historically returned ``None`` both for a real
|
||||
404/not-found and for transient panel/API failures. Callers that may
|
||||
mutate local state need this richer result to avoid treating an outage
|
||||
as a deleted panel user.
|
||||
"""
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
full_response = await self._request("GET", endpoint, log_full_response=log_response)
|
||||
if full_response and not full_response.get("error") and "response" in full_response:
|
||||
return full_response.get("response")
|
||||
return {
|
||||
"ok": True,
|
||||
"user": full_response.get("response"),
|
||||
"not_found": False,
|
||||
"failure_reason": None,
|
||||
"response": full_response,
|
||||
}
|
||||
|
||||
return None
|
||||
not_found = self._is_user_not_found_response(full_response)
|
||||
return {
|
||||
"ok": False,
|
||||
"user": None,
|
||||
"not_found": not_found,
|
||||
"failure_reason": self._describe_user_lookup_failure(
|
||||
full_response,
|
||||
not_found=not_found,
|
||||
),
|
||||
"response": full_response,
|
||||
}
|
||||
|
||||
async def get_user(
|
||||
self,
|
||||
|
||||
@@ -3,12 +3,15 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiohttp import web
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, sessionmaker
|
||||
|
||||
from bot.infra.webhook_queue import enqueue_webhook_event
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
@@ -16,18 +19,40 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscribe_only_markup,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_lifecycle_notifications import (
|
||||
SubscriptionLifecycleNotificationService,
|
||||
SubscriptionNotificationStage,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import tariff_dal, user_dal
|
||||
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||
from db.models import Subscription, User
|
||||
|
||||
from .email_auth_service import EmailAuthService
|
||||
from .email_templates import render_subscription_expiring
|
||||
from .panel_api_service import PanelApiService
|
||||
|
||||
EVENT_MAP = {
|
||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
||||
"user.expires_in_72_hours": SubscriptionNotificationStage(
|
||||
key="before_3d",
|
||||
message_key="subscription_72h_notification",
|
||||
days_left=3,
|
||||
),
|
||||
"user.expires_in_48_hours": SubscriptionNotificationStage(
|
||||
key="before_2d",
|
||||
message_key="subscription_48h_notification",
|
||||
days_left=2,
|
||||
),
|
||||
"user.expires_in_24_hours": SubscriptionNotificationStage(
|
||||
key="before_1d",
|
||||
message_key="subscription_24h_notification",
|
||||
days_left=1,
|
||||
),
|
||||
}
|
||||
ACTIONABLE_EVENTS = frozenset(
|
||||
{
|
||||
*EVENT_MAP.keys(),
|
||||
"user.expired",
|
||||
"user.expired_24_hours_ago",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PanelWebhookService:
|
||||
@@ -48,6 +73,11 @@ class PanelWebhookService:
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
)
|
||||
self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS)
|
||||
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||
logging.error(
|
||||
@@ -102,113 +132,201 @@ class PanelWebhookService:
|
||||
)
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
if not telegram_id:
|
||||
logging.warning("Panel webhook without telegramId received")
|
||||
return
|
||||
user_id = int(telegram_id)
|
||||
|
||||
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
||||
return
|
||||
|
||||
if event_name not in ACTIONABLE_EVENTS:
|
||||
logging.info(
|
||||
"Panel webhook event %s ignored: event is not used for subscription "
|
||||
"notifications; %s",
|
||||
event_name,
|
||||
self._payload_log_context(user_payload),
|
||||
)
|
||||
return
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, user_id)
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
internal_user_id = db_user.user_id if db_user else user_id
|
||||
db_user = await self._user_for_payload(session, user_payload)
|
||||
sub = await self._subscription_for_payload(session, user_payload, db_user)
|
||||
telegram_id = self._payload_telegram_id(user_payload)
|
||||
internal_user_id = (
|
||||
int(db_user.user_id)
|
||||
if db_user
|
||||
else int(getattr(sub, "user_id", 0) or telegram_id or 0)
|
||||
)
|
||||
lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
user_email = (db_user.email or "").strip() if db_user else ""
|
||||
if not sub:
|
||||
if not telegram_id:
|
||||
local_user_id = getattr(db_user, "user_id", None) if db_user else None
|
||||
logging.warning(
|
||||
"Panel webhook event %s cannot be matched to a local subscription; "
|
||||
"notification skipped. %s local_user_id=%s. Possible causes: "
|
||||
"panel user was created outside the bot, subscription was deleted "
|
||||
"or not synced, panel identifiers changed, or skip_notifications "
|
||||
"is enabled for the local subscription.",
|
||||
event_name,
|
||||
self._payload_log_context(user_payload),
|
||||
local_user_id or "N/A",
|
||||
)
|
||||
return
|
||||
await self._send_legacy_without_dedupe(
|
||||
event_name,
|
||||
user_payload,
|
||||
int(telegram_id),
|
||||
lang,
|
||||
db_user,
|
||||
)
|
||||
return
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
end_date_text = self._payload_expire_date(user_payload)
|
||||
|
||||
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:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, internal_user_id
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(
|
||||
session, sub
|
||||
if event_name in EVENT_MAP:
|
||||
stage = EVENT_MAP[event_name]
|
||||
days_left = int(stage.days_left or 0)
|
||||
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:
|
||||
subscription_service = getattr(self, "subscription_service", None)
|
||||
if subscription_service:
|
||||
async with self.async_session_factory() as renewal_session:
|
||||
active_sub = (
|
||||
await subscription_dal.get_active_subscription_by_user_id(
|
||||
renewal_session,
|
||||
internal_user_id,
|
||||
)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||
if ok:
|
||||
await session.commit()
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h event, if auto-renew is enabled, show special notice with cancel button
|
||||
if days_left == 2:
|
||||
async with self.async_session_factory() as session:
|
||||
from db.dal import subscription_dal
|
||||
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, internal_user_id
|
||||
)
|
||||
if (
|
||||
active_sub
|
||||
and active_sub.auto_renew_enabled
|
||||
and active_sub.provider == "yookassa"
|
||||
):
|
||||
try:
|
||||
ok = await subscription_service.charge_subscription_renewal(
|
||||
renewal_session,
|
||||
active_sub,
|
||||
)
|
||||
# If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501
|
||||
if ok:
|
||||
await renewal_session.commit()
|
||||
return
|
||||
await renewal_session.rollback()
|
||||
except Exception:
|
||||
await renewal_session.rollback()
|
||||
logging.exception("Auto-renew attempt (24h) failed")
|
||||
except Exception:
|
||||
logging.exception("Auto-renew trigger (24h) failed pre-check")
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
# For 48h, auto-renew users get a cancel button instead.
|
||||
if days_left == 2:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session,
|
||||
internal_user_id,
|
||||
)
|
||||
logging.info(
|
||||
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
|
||||
user_id,
|
||||
bool(sub),
|
||||
getattr(sub, "auto_renew_enabled", None) if sub else None,
|
||||
getattr(sub, "provider", None) if sub else None,
|
||||
internal_user_id,
|
||||
bool(active_sub),
|
||||
getattr(active_sub, "auto_renew_enabled", None) if active_sub else None,
|
||||
getattr(active_sub, "provider", None) if active_sub else None,
|
||||
)
|
||||
if sub and sub.auto_renew_enabled and sub.provider == "yookassa":
|
||||
if (
|
||||
active_sub
|
||||
and active_sub.auto_renew_enabled
|
||||
and active_sub.provider == "yookassa"
|
||||
):
|
||||
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"autorenew_48h_charge_tomorrow_notice",
|
||||
reply_markup=cancel_kb,
|
||||
user_name=first_name,
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
SubscriptionNotificationStage(
|
||||
key="before_2d_autorenew",
|
||||
message_key="autorenew_48h_charge_tomorrow_notice",
|
||||
days_left=2,
|
||||
),
|
||||
user=db_user,
|
||||
telegram_markup=cancel_kb,
|
||||
extra_text=hwid_renewal_note,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
await session.commit()
|
||||
return
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
msg_key,
|
||||
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(
|
||||
recipient=user_email,
|
||||
lang=lang,
|
||||
days_left=days_left,
|
||||
end_date_text=user_payload.get("expireAt", "")[:10],
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
user=db_user,
|
||||
telegram_markup=markup,
|
||||
extra_text=hwid_renewal_note,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
elif event_name == "user.expired":
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
await session.commit()
|
||||
elif event_name == "user.expired":
|
||||
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
days_left=0,
|
||||
),
|
||||
user=db_user,
|
||||
telegram_markup=markup,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
await session.commit()
|
||||
elif (
|
||||
event_name == "user.expired_24_hours_ago"
|
||||
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||
):
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub,
|
||||
SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
days_left=0,
|
||||
),
|
||||
user=db_user,
|
||||
telegram_markup=markup,
|
||||
end_date_text=end_date_text,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _send_legacy_without_dedupe(
|
||||
self,
|
||||
event_name: str,
|
||||
user_payload: dict,
|
||||
user_id: int,
|
||||
lang: str,
|
||||
db_user: Optional[User],
|
||||
) -> None:
|
||||
first_name = getattr(db_user, "first_name", None) or f"User {user_id}"
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
if event_name in EVENT_MAP:
|
||||
stage = EVENT_MAP[event_name]
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
stage.message_key,
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=self._payload_expire_date(user_payload),
|
||||
)
|
||||
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=self._payload_expire_date(user_payload),
|
||||
)
|
||||
elif (
|
||||
event_name == "user.expired_24_hours_ago"
|
||||
and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE
|
||||
@@ -219,33 +337,138 @@ class PanelWebhookService:
|
||||
"subscription_expired_yesterday_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
end_date=self._payload_expire_date(user_payload),
|
||||
)
|
||||
|
||||
async def _send_subscription_expiring_email(
|
||||
async def _user_for_payload(
|
||||
self,
|
||||
*,
|
||||
recipient: str,
|
||||
lang: str,
|
||||
days_left: int,
|
||||
end_date_text: str,
|
||||
) -> None:
|
||||
"""Best-effort branded reminder; silently no-ops without SMTP config."""
|
||||
if not self.settings.email_auth_configured:
|
||||
return
|
||||
try:
|
||||
content = render_subscription_expiring(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
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,
|
||||
session: AsyncSession,
|
||||
user_payload: dict,
|
||||
) -> Optional[User]:
|
||||
telegram_id = self._payload_telegram_id(user_payload)
|
||||
if telegram_id:
|
||||
user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||
if user:
|
||||
return user
|
||||
user = await user_dal.get_user_by_id(session, telegram_id)
|
||||
if user:
|
||||
return user
|
||||
|
||||
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||
if panel_uuid:
|
||||
user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
if user:
|
||||
return user
|
||||
|
||||
email = str(user_payload.get("email") or "").strip()
|
||||
if email:
|
||||
return await user_dal.get_user_by_email(session, email)
|
||||
return None
|
||||
|
||||
async def _subscription_for_payload(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_payload: dict,
|
||||
db_user: Optional[User],
|
||||
) -> Optional[Subscription]:
|
||||
conditions = []
|
||||
if db_user:
|
||||
conditions.append(Subscription.user_id == db_user.user_id)
|
||||
panel_uuid = self._payload_panel_uuid(user_payload)
|
||||
if panel_uuid:
|
||||
conditions.append(Subscription.panel_user_uuid == panel_uuid)
|
||||
if not conditions:
|
||||
return None
|
||||
base_stmt = (
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.skip_notifications == False,
|
||||
or_(*conditions),
|
||||
)
|
||||
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)
|
||||
.options(selectinload(Subscription.user))
|
||||
)
|
||||
|
||||
expire_at = self._payload_expire_datetime(user_payload)
|
||||
if expire_at is not None:
|
||||
window_stmt = (
|
||||
base_stmt.where(
|
||||
Subscription.end_date >= expire_at - timedelta(days=1),
|
||||
Subscription.end_date <= expire_at + timedelta(days=1),
|
||||
)
|
||||
.order_by(Subscription.end_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(window_stmt)
|
||||
found = result.scalars().first()
|
||||
if found:
|
||||
return found
|
||||
|
||||
stmt = base_stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
def _payload_telegram_id(user_payload: dict) -> Optional[int]:
|
||||
raw = user_payload.get("telegramId")
|
||||
try:
|
||||
value = int(raw or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _payload_panel_uuid(user_payload: dict) -> str:
|
||||
return str(
|
||||
user_payload.get("uuid")
|
||||
or user_payload.get("userUuid")
|
||||
or user_payload.get("shortUuid")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
@staticmethod
|
||||
def _payload_expire_date(user_payload: dict) -> str:
|
||||
return str(user_payload.get("expireAt") or "")[:10]
|
||||
|
||||
@staticmethod
|
||||
def _payload_log_context(user_payload: dict) -> str:
|
||||
telegram_id = PanelWebhookService._payload_telegram_id(user_payload)
|
||||
panel_uuid = PanelWebhookService._payload_panel_uuid(user_payload)
|
||||
email = PanelWebhookService._mask_email(str(user_payload.get("email") or "").strip())
|
||||
expire_at = str(user_payload.get("expireAt") or "").strip()
|
||||
payload_keys = ",".join(sorted(str(key) for key in user_payload.keys())) or "none"
|
||||
return (
|
||||
f"telegramId={telegram_id or 'N/A'} "
|
||||
f"panel_uuid={panel_uuid or 'N/A'} "
|
||||
f"email={email or 'N/A'} "
|
||||
f"expireAt={expire_at or 'N/A'} "
|
||||
f"payload_keys={payload_keys}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mask_email(email: str) -> str:
|
||||
if not email:
|
||||
return ""
|
||||
local_part, separator, domain = email.partition("@")
|
||||
if not separator or not domain:
|
||||
return "present"
|
||||
visible = local_part[:2] if len(local_part) > 2 else local_part[:1]
|
||||
return f"{visible}***@{domain}"
|
||||
|
||||
@staticmethod
|
||||
def _payload_expire_datetime(user_payload: dict) -> Optional[datetime]:
|
||||
raw = str(user_payload.get("expireAt") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
try:
|
||||
value = datetime.fromisoformat(raw[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
async def handle_webhook(
|
||||
self, raw_body: bytes, signature_header: Optional[str]
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
|
||||
@@ -33,6 +34,7 @@ class ReferralService:
|
||||
purchased_subscription_months: int,
|
||||
current_payment_db_id: Optional[int] = None,
|
||||
skip_if_active_before_payment: bool = True,
|
||||
tariff_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
referee_final_end_date: Optional[datetime] = None
|
||||
@@ -94,11 +96,9 @@ class ReferralService:
|
||||
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
|
||||
)
|
||||
|
||||
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
|
||||
purchased_subscription_months
|
||||
)
|
||||
referee_bonus_days = self.settings.referral_bonus_referee.get(
|
||||
purchased_subscription_months
|
||||
inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
|
||||
purchased_subscription_months,
|
||||
tariff_key=tariff_key,
|
||||
)
|
||||
|
||||
if inviter_bonus_days and inviter_bonus_days > 0:
|
||||
@@ -142,18 +142,36 @@ class ReferralService:
|
||||
inviter_user_model.language_code or default_lang_for_placeholder
|
||||
)
|
||||
_i = lambda k, **kw: self.i18n.gettext(inviter_lang, k, **kw)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i(
|
||||
"referral_bonus_inviter_notification_extended",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
||||
message_text = _i(
|
||||
"referral_bonus_inviter_notification_extended",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=new_end_date_inviter.strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(inviter_user_id, message_text)
|
||||
except Exception as e_notify_inviter:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
)
|
||||
await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=inviter_user_model,
|
||||
subject_key="email_referral_bonus_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(
|
||||
getattr(
|
||||
self.settings,
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"",
|
||||
)
|
||||
or None
|
||||
),
|
||||
)
|
||||
except Exception as e_notify_inviter:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
f"Failed to prepare bonus notification for inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
@@ -210,13 +228,34 @@ class ReferralService:
|
||||
_i = lambda k, **kw: self.i18n.gettext(
|
||||
inviter_lang, k, **kw
|
||||
)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i(
|
||||
"referral_bonus_inviter_notification_new_sub",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
||||
message_text = _i(
|
||||
"referral_bonus_inviter_notification_new_sub",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=bonus_end_date.strftime("%Y-%m-%d"),
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
message_text,
|
||||
)
|
||||
except Exception as e_notify_inviter:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501
|
||||
)
|
||||
await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=inviter_user_model,
|
||||
subject_key="email_referral_bonus_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(
|
||||
getattr(
|
||||
self.settings,
|
||||
"SUBSCRIPTION_MINI_APP_URL",
|
||||
"",
|
||||
)
|
||||
or None
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -263,6 +302,35 @@ class ReferralService:
|
||||
|
||||
raise
|
||||
|
||||
def _referral_bonus_days_for_payment(
|
||||
self,
|
||||
purchased_subscription_months: int,
|
||||
*,
|
||||
tariff_key: Optional[str] = None,
|
||||
) -> tuple[Optional[int], Optional[int]]:
|
||||
months = int(purchased_subscription_months)
|
||||
tariffs_config = getattr(self.settings, "tariffs_config", None)
|
||||
if tariff_key and tariffs_config:
|
||||
try:
|
||||
tariff = tariffs_config.require(str(tariff_key))
|
||||
except Exception:
|
||||
logging.warning(
|
||||
"Referral bonuses skipped: tariff %s was not found.",
|
||||
tariff_key,
|
||||
)
|
||||
return None, None
|
||||
if tariff.billing_model != "period":
|
||||
return None, None
|
||||
return (
|
||||
tariff.referral_inviter_bonus_days(months),
|
||||
tariff.referral_referee_bonus_days(months),
|
||||
)
|
||||
|
||||
return (
|
||||
self.settings.referral_bonus_inviter.get(months),
|
||||
self.settings.referral_bonus_referee.get(months),
|
||||
)
|
||||
|
||||
async def generate_referral_link(
|
||||
self, session: AsyncSession, bot_username: str, inviter_user_id: int
|
||||
) -> Optional[str]:
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import render_subscription_lifecycle_notification
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
mark_telegram_notifications_status,
|
||||
normalize_telegram_notification_status,
|
||||
telegram_notification_status_from_error,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription, User
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationStage:
|
||||
key: str
|
||||
message_key: str
|
||||
days_left: Optional[int] = None
|
||||
hours_before: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationDelivery:
|
||||
telegram_sent: bool = False
|
||||
email_sent: bool = False
|
||||
|
||||
@property
|
||||
def any_sent(self) -> bool:
|
||||
return self.telegram_sent or self.email_sent
|
||||
|
||||
|
||||
class SubscriptionLifecycleNotificationService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
bot: Bot,
|
||||
i18n: JsonI18n,
|
||||
*,
|
||||
email_service: Optional[EmailAuthService] = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
self.email_service = email_service
|
||||
|
||||
async def send_stage(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
telegram_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
extra_text: str = "",
|
||||
end_date_text: Optional[str] = None,
|
||||
sent_at: Optional[datetime] = None,
|
||||
) -> SubscriptionNotificationDelivery:
|
||||
if sent_at is None:
|
||||
sent_at = datetime.now(timezone.utc)
|
||||
|
||||
resolved_user = user or getattr(sub, "user", None)
|
||||
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
final_end_date_text = end_date_text
|
||||
if final_end_date_text is None:
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
|
||||
recipient_email = self._email_recipient(resolved_user)
|
||||
telegram_user_name = self._telegram_display_name(resolved_user, user_id)
|
||||
email_user_name = self._email_display_name(
|
||||
resolved_user,
|
||||
recipient_email=recipient_email,
|
||||
fallback=telegram_user_name,
|
||||
)
|
||||
|
||||
kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text}
|
||||
if stage.hours_before is not None:
|
||||
kwargs["hours"] = stage.hours_before
|
||||
|
||||
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
|
||||
email_kwargs = {**kwargs, "user_name": email_user_name}
|
||||
email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs)
|
||||
final_extra_text = str(extra_text or "").strip()
|
||||
if final_extra_text:
|
||||
message_text = f"{message_text}\n\n{final_extra_text}"
|
||||
email_message_text = f"{email_message_text}\n\n{final_extra_text}"
|
||||
|
||||
telegram_sent = await self._send_telegram(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
resolved_user,
|
||||
lang=lang,
|
||||
message_text=message_text,
|
||||
markup=telegram_markup or get_subscribe_only_markup(lang, self.i18n),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
email_sent = await self._send_email(
|
||||
session,
|
||||
sub,
|
||||
stage,
|
||||
resolved_user,
|
||||
lang=lang,
|
||||
message_text=email_message_text,
|
||||
end_date_text=final_end_date_text,
|
||||
recipient=recipient_email,
|
||||
telegram_sent=telegram_sent,
|
||||
sent_at=sent_at,
|
||||
)
|
||||
return SubscriptionNotificationDelivery(
|
||||
telegram_sent=telegram_sent,
|
||||
email_sent=email_sent,
|
||||
)
|
||||
|
||||
async def _send_telegram(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
user: Optional[User],
|
||||
*,
|
||||
lang: str,
|
||||
message_text: str,
|
||||
markup: Optional[InlineKeyboardMarkup],
|
||||
sent_at: datetime,
|
||||
) -> bool:
|
||||
chat_id = self._telegram_chat_id(user, getattr(sub, "user_id", None))
|
||||
if chat_id is None:
|
||||
return False
|
||||
if user:
|
||||
status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
if status in {TELEGRAM_NOTIFICATIONS_NEEDS_START, TELEGRAM_NOTIFICATIONS_BLOCKED}:
|
||||
return False
|
||||
if await self._already_sent(session, sub.subscription_id, stage.key, "telegram"):
|
||||
return False
|
||||
try:
|
||||
await self.bot.send_message(chat_id, message_text, reply_markup=markup)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
delivery_status = telegram_notification_status_from_error(exc)
|
||||
if user and delivery_status:
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
delivery_status,
|
||||
)
|
||||
if delivery_status:
|
||||
logging.warning(
|
||||
"Skipping subscription notification %s for unreachable Telegram user %s: %s",
|
||||
stage.key,
|
||||
chat_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to Telegram user %s",
|
||||
stage.key,
|
||||
chat_id,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to Telegram user %s",
|
||||
stage.key,
|
||||
chat_id,
|
||||
)
|
||||
return False
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
self._channel_key(stage.key, "telegram"),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
if user:
|
||||
status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
if status != TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=chat_id,
|
||||
checked_at=sent_at,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _send_email(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
user: Optional[User],
|
||||
*,
|
||||
lang: str,
|
||||
message_text: str,
|
||||
end_date_text: str,
|
||||
recipient: str,
|
||||
telegram_sent: bool,
|
||||
sent_at: datetime,
|
||||
) -> bool:
|
||||
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
|
||||
return False
|
||||
if not getattr(self.settings, "email_auth_configured", False):
|
||||
return False
|
||||
if not recipient:
|
||||
return False
|
||||
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
|
||||
return False
|
||||
|
||||
try:
|
||||
content = render_subscription_lifecycle_notification(
|
||||
self.settings,
|
||||
language_code=lang,
|
||||
notification_key=stage.key,
|
||||
message_text=message_text,
|
||||
end_date_text=end_date_text,
|
||||
dashboard_url=self._renewal_dashboard_url(recipient, sub),
|
||||
mirrored_from_telegram=telegram_sent,
|
||||
days_left=stage.days_left,
|
||||
hours_before=stage.hours_before,
|
||||
i18n=self.i18n,
|
||||
)
|
||||
email_service = self.email_service or EmailAuthService(self.settings, self.i18n)
|
||||
await email_service.send_rendered_email(email=recipient, content=content)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to email %s",
|
||||
stage.key,
|
||||
recipient,
|
||||
)
|
||||
return False
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
self._channel_key(stage.key, "email"),
|
||||
sent_at=sent_at,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _already_sent(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
subscription_id: int,
|
||||
stage_key: str,
|
||||
channel: str,
|
||||
) -> bool:
|
||||
channel_key = self._channel_key(stage_key, channel)
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
subscription_id,
|
||||
channel_key,
|
||||
):
|
||||
return True
|
||||
|
||||
# Legacy rows were stored without a channel. Treat them as Telegram-only
|
||||
# history so existing installs do not re-send old bot messages, while
|
||||
# still allowing the newly introduced email channel to catch up.
|
||||
return channel == "telegram" and await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
subscription_id,
|
||||
stage_key,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _channel_key(stage_key: str, channel: str) -> str:
|
||||
return f"{stage_key}:{channel}"
|
||||
|
||||
@staticmethod
|
||||
def _email_recipient(user: Optional[User]) -> str:
|
||||
return str(getattr(user, "email", "") or "").strip().lower() if user else ""
|
||||
|
||||
@staticmethod
|
||||
def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str:
|
||||
return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _email_display_name(
|
||||
user: Optional[User],
|
||||
*,
|
||||
recipient_email: str,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback
|
||||
|
||||
def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]:
|
||||
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return None
|
||||
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
query.update(
|
||||
{
|
||||
"login": "email_code",
|
||||
"login_email": recipient_email,
|
||||
"after_login": "renew",
|
||||
"renew": "1",
|
||||
}
|
||||
)
|
||||
tariff_key = self._renewal_tariff_key(sub)
|
||||
if tariff_key:
|
||||
query["renew_tariff"] = tariff_key
|
||||
else:
|
||||
query.pop("renew_tariff", None)
|
||||
|
||||
return urlunsplit(
|
||||
(
|
||||
parsed.scheme,
|
||||
parsed.netloc,
|
||||
parsed.path or "/",
|
||||
urlencode(query),
|
||||
parsed.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _renewal_tariff_key(sub: Subscription) -> str:
|
||||
provider = str(getattr(sub, "provider", "") or "").strip().lower()
|
||||
status = str(getattr(sub, "status_from_panel", "") or "").strip().upper()
|
||||
if provider == "trial" or status == "TRIAL":
|
||||
return ""
|
||||
return str(getattr(sub, "tariff_key", "") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
|
||||
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
|
||||
try:
|
||||
chat_id = int(candidate or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if chat_id > 0:
|
||||
return chat_id
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -15,8 +14,22 @@ from bot.infra.redis import redis_lock
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_lifecycle_notifications import (
|
||||
SubscriptionLifecycleNotificationService,
|
||||
SubscriptionNotificationStage,
|
||||
)
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.telegram_notifications import (
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
mark_telegram_notifications_status,
|
||||
normalize_telegram_notification_status,
|
||||
telegram_notification_status_from_error,
|
||||
)
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from config.settings import Settings
|
||||
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Subscription
|
||||
|
||||
@@ -26,13 +39,6 @@ EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
|
||||
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionNotificationStage:
|
||||
key: str
|
||||
message_key: str
|
||||
hours_before: Optional[int] = None
|
||||
|
||||
|
||||
class SubscriptionNotificationWorker:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -49,6 +55,11 @@ class SubscriptionNotificationWorker:
|
||||
self.i18n = i18n
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.lifecycle_notifications = SubscriptionLifecycleNotificationService(
|
||||
settings,
|
||||
bot,
|
||||
i18n,
|
||||
)
|
||||
self._stopped = asyncio.Event()
|
||||
|
||||
async def run(self) -> None:
|
||||
@@ -66,6 +77,7 @@ class SubscriptionNotificationWorker:
|
||||
else:
|
||||
started = time.monotonic()
|
||||
async with self.session_factory() as session:
|
||||
await acquire_subscription_background_sync_lock(session)
|
||||
await self.expiry_tick(session)
|
||||
await self.trial_traffic_tick(session)
|
||||
await session.commit()
|
||||
@@ -114,18 +126,10 @@ class SubscriptionNotificationWorker:
|
||||
stage = self.stage_for_subscription(sub, now)
|
||||
if stage is None:
|
||||
continue
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
await self.lifecycle_notifications.send_stage(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
):
|
||||
continue
|
||||
if not await self._send_expiry_notification(sub, stage):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
stage.key,
|
||||
sub,
|
||||
stage,
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
@@ -164,6 +168,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key=f"before_{days_before}d",
|
||||
message_key=message_key,
|
||||
days_left=days_before,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -175,6 +180,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired",
|
||||
message_key="subscription_expired_notification",
|
||||
days_left=0,
|
||||
)
|
||||
if (
|
||||
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
|
||||
@@ -183,6 +189,7 @@ class SubscriptionNotificationWorker:
|
||||
return SubscriptionNotificationStage(
|
||||
key="expired_24h_after",
|
||||
message_key="subscription_expired_yesterday_notification",
|
||||
days_left=0,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -208,11 +215,22 @@ class SubscriptionNotificationWorker:
|
||||
.order_by(Subscription.end_date.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
if await subscription_dal.has_subscription_notification(
|
||||
legacy_sent = await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
):
|
||||
)
|
||||
telegram_done = legacy_sent or await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:telegram",
|
||||
)
|
||||
email_done = await subscription_dal.has_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:email",
|
||||
)
|
||||
if telegram_done and email_done:
|
||||
continue
|
||||
|
||||
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
|
||||
@@ -234,14 +252,30 @@ class SubscriptionNotificationWorker:
|
||||
|
||||
if limit <= 0 or used < limit:
|
||||
continue
|
||||
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
|
||||
continue
|
||||
await subscription_dal.record_subscription_notification(
|
||||
delivery = await self._send_trial_traffic_depleted(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted",
|
||||
sent_at=now,
|
||||
sub,
|
||||
used=used,
|
||||
limit=limit,
|
||||
send_telegram=not telegram_done,
|
||||
send_email=not email_done,
|
||||
)
|
||||
if not delivery["telegram"] and not delivery["email"]:
|
||||
continue
|
||||
if delivery["telegram"]:
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:telegram",
|
||||
sent_at=now,
|
||||
)
|
||||
if delivery["email"]:
|
||||
await subscription_dal.record_subscription_notification(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
"trial_traffic_depleted:email",
|
||||
sent_at=now,
|
||||
)
|
||||
|
||||
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
|
||||
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
|
||||
@@ -257,68 +291,72 @@ class SubscriptionNotificationWorker:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def _send_expiry_notification(
|
||||
self,
|
||||
sub: Subscription,
|
||||
stage: SubscriptionNotificationStage,
|
||||
) -> bool:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
user_name = getattr(user, "first_name", None) or f"User {user_id}"
|
||||
end_date = self._as_utc(getattr(sub, "end_date", None))
|
||||
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
kwargs = {"user_name": user_name, "end_date": end_date_text}
|
||||
if stage.hours_before is not None:
|
||||
kwargs["hours"] = stage.hours_before
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(stage.message_key, **kwargs),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send subscription notification %s to user %s",
|
||||
stage.key,
|
||||
user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_traffic_depleted(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
used: int,
|
||||
limit: int,
|
||||
) -> bool:
|
||||
send_telegram: bool = True,
|
||||
send_email: bool = True,
|
||||
) -> dict[str, bool]:
|
||||
user_id = int(getattr(sub, "user_id", 0) or 0)
|
||||
if user_id <= 0:
|
||||
return False
|
||||
user = getattr(sub, "user", None)
|
||||
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
|
||||
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
remaining = max(0, limit - used)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
translate(
|
||||
"trial_traffic_depleted_notification",
|
||||
used=hd.quote(self._fmt_bytes(used)),
|
||||
remaining=hd.quote(self._fmt_bytes(remaining)),
|
||||
limit_total=hd.quote(self._fmt_bytes(limit)),
|
||||
),
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
parse_mode="HTML",
|
||||
message_text = translate(
|
||||
"trial_traffic_depleted_notification",
|
||||
used=hd.quote(self._fmt_bytes(used)),
|
||||
remaining=hd.quote(self._fmt_bytes(remaining)),
|
||||
limit_total=hd.quote(self._fmt_bytes(limit)),
|
||||
)
|
||||
telegram_sent = False
|
||||
email_sent = False
|
||||
telegram_chat_id = int(getattr(user, "telegram_id", 0) or user_id or 0)
|
||||
telegram_status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
can_try_telegram = telegram_status not in {
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
}
|
||||
if send_telegram and telegram_chat_id > 0 and can_try_telegram:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
telegram_chat_id,
|
||||
message_text,
|
||||
reply_markup=get_subscribe_only_markup(lang, self.i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
telegram_sent = True
|
||||
except Exception as exc:
|
||||
status = telegram_notification_status_from_error(exc)
|
||||
if status and user and user_id:
|
||||
await mark_telegram_notifications_status(session, user_id, status)
|
||||
logging.exception(
|
||||
"Failed to send trial traffic depleted warning to user %s",
|
||||
telegram_chat_id,
|
||||
)
|
||||
else:
|
||||
if user and telegram_status != TELEGRAM_NOTIFICATIONS_ENABLED and user_id:
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
user_id,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=telegram_chat_id,
|
||||
)
|
||||
if send_email and user:
|
||||
email_sent = await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=user,
|
||||
subject_key="email_trial_traffic_depleted_subject",
|
||||
message_text=message_text,
|
||||
dashboard_url=(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or None),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
|
||||
return False
|
||||
return {"telegram": telegram_sent, "email": email_sent}
|
||||
|
||||
def _max_before_window(self) -> timedelta:
|
||||
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
|
||||
|
||||
@@ -357,6 +357,15 @@ class HwidDeviceMixin:
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
)
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
|
||||
months=purchased_devices,
|
||||
traffic_gb=None,
|
||||
payment_amount=payment_amount,
|
||||
end_date=valid_until,
|
||||
provider=provider,
|
||||
)
|
||||
return {
|
||||
"subscription_id": updated_sub.subscription_id,
|
||||
"end_date": updated_sub.end_date,
|
||||
|
||||
@@ -3,6 +3,175 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
|
||||
class SubscriptionLifecycleMixin:
|
||||
async def _lookup_panel_user_for_subscription_details(
|
||||
self,
|
||||
panel_user_uuid: str,
|
||||
) -> Tuple[Optional[Dict[str, Any]], bool, str]:
|
||||
lookup_method = getattr(self.panel_service, "get_user_by_uuid_lookup", None)
|
||||
if callable(lookup_method):
|
||||
try:
|
||||
lookup = await lookup_method(panel_user_uuid, log_response=False)
|
||||
except TypeError:
|
||||
try:
|
||||
lookup = await lookup_method(panel_user_uuid)
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to fetch panel user %s for subscription details",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None, False, self._panel_lookup_exception_reason(exc)
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to fetch panel user %s for subscription details",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None, False, self._panel_lookup_exception_reason(exc)
|
||||
|
||||
if isinstance(lookup, dict) and ("ok" in lookup or "not_found" in lookup):
|
||||
user = lookup.get("user")
|
||||
if lookup.get("ok") and isinstance(user, dict):
|
||||
return user, False, ""
|
||||
reason = str(lookup.get("failure_reason") or "classification=panel_lookup_failed")
|
||||
return None, bool(lookup.get("not_found")), reason
|
||||
|
||||
try:
|
||||
panel_user = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to fetch panel user %s for subscription details",
|
||||
panel_user_uuid,
|
||||
)
|
||||
return None, False, self._panel_lookup_exception_reason(exc)
|
||||
return (panel_user if isinstance(panel_user, dict) else None), False, ""
|
||||
|
||||
@staticmethod
|
||||
def _panel_lookup_exception_reason(exc: Exception) -> str:
|
||||
message = str(exc).replace("\n", " ").strip()
|
||||
if len(message) > 300:
|
||||
message = f"{message[:300]}..."
|
||||
reason = f"classification=panel_lookup_failed exception={type(exc).__name__}"
|
||||
if message:
|
||||
reason = f"{reason} message={message}"
|
||||
return reason
|
||||
|
||||
@staticmethod
|
||||
def _display_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _device_topup_renewal_available(
|
||||
extra_hwid_devices: int,
|
||||
extra_hwid_valid_until: Optional[Any],
|
||||
subscription_end_date: Optional[Any],
|
||||
) -> bool:
|
||||
if not isinstance(extra_hwid_valid_until, datetime) or not isinstance(
|
||||
subscription_end_date, datetime
|
||||
):
|
||||
return False
|
||||
valid_until = (
|
||||
extra_hwid_valid_until
|
||||
if extra_hwid_valid_until.tzinfo
|
||||
else extra_hwid_valid_until.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
end_date = (
|
||||
subscription_end_date
|
||||
if subscription_end_date.tzinfo
|
||||
else subscription_end_date.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
return bool(int(extra_hwid_devices or 0) > 0 and valid_until < end_date)
|
||||
|
||||
async def _local_active_subscription_details_fallback(
|
||||
self,
|
||||
db_user: User,
|
||||
local_active_sub: Subscription,
|
||||
) -> Dict[str, Any]:
|
||||
panel_sub_id = str(local_active_sub.panel_subscription_uuid or "").strip()
|
||||
config_link_raw = (
|
||||
await self.panel_service.get_subscription_link(panel_sub_id) if panel_sub_id else None
|
||||
)
|
||||
display_link, connect_button_url = await prepare_config_links(
|
||||
self.settings,
|
||||
config_link_raw,
|
||||
)
|
||||
tariff = None
|
||||
if local_active_sub.tariff_key and self._tariffs_config():
|
||||
try:
|
||||
tariff = self._resolve_tariff(local_active_sub.tariff_key)
|
||||
except Exception:
|
||||
tariff = None
|
||||
language = db_user.language_code or self.settings.DEFAULT_LANGUAGE
|
||||
premium_access = (
|
||||
await self.premium_access_for_tariff(tariff)
|
||||
if tariff
|
||||
else {"squad_uuids": [], "squad_labels": [], "node_labels": []}
|
||||
)
|
||||
premium_baseline = int(local_active_sub.premium_baseline_bytes or 0)
|
||||
premium_topup_balance = int(local_active_sub.premium_topup_balance_bytes or 0)
|
||||
premium_topup_used = int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0)
|
||||
premium_bonus_bytes = int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0)
|
||||
return {
|
||||
"user_id": db_user.panel_user_uuid,
|
||||
"panel_subscription_uuid": local_active_sub.panel_subscription_uuid,
|
||||
"panel_short_uuid": local_active_sub.panel_subscription_uuid,
|
||||
"end_date": local_active_sub.end_date,
|
||||
"status_from_panel": local_active_sub.status_from_panel or "LOCAL_CACHE",
|
||||
"config_link": display_link,
|
||||
"connect_button_url": connect_button_url,
|
||||
"traffic_limit_bytes": local_active_sub.traffic_limit_bytes,
|
||||
"traffic_used_bytes": local_active_sub.traffic_used_bytes,
|
||||
"traffic_limit_strategy": "",
|
||||
"tariff_key": local_active_sub.tariff_key,
|
||||
"tariff_name": tariff.name(language) if tariff else None,
|
||||
"tariff_description": tariff.description(language) if tariff else None,
|
||||
"premium_title": tariff.premium_name(language) if tariff else None,
|
||||
"billing_model": tariff.billing_model
|
||||
if tariff
|
||||
else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
|
||||
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes,
|
||||
"topup_balance_bytes": local_active_sub.topup_balance_bytes,
|
||||
"regular_bonus_bytes": int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0),
|
||||
"regular_unlimited_override": bool(
|
||||
getattr(local_active_sub, "regular_unlimited_override", False)
|
||||
),
|
||||
"premium_baseline_bytes": premium_baseline,
|
||||
"premium_topup_balance_bytes": premium_topup_balance,
|
||||
"premium_topup_used_bytes": premium_topup_used,
|
||||
"premium_used_bytes": local_active_sub.premium_used_bytes,
|
||||
"premium_bonus_bytes": premium_bonus_bytes,
|
||||
"premium_unlimited_override": bool(
|
||||
getattr(local_active_sub, "premium_unlimited_override", False)
|
||||
),
|
||||
"premium_limit_bytes": self._premium_effective_limit_bytes(
|
||||
premium_baseline,
|
||||
premium_topup_balance,
|
||||
premium_topup_used,
|
||||
premium_bonus_bytes,
|
||||
),
|
||||
"premium_is_limited": bool(local_active_sub.premium_is_limited),
|
||||
"premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None),
|
||||
"premium_squad_labels": premium_access.get("squad_labels") or [],
|
||||
"premium_node_labels": premium_access.get("node_labels") or [],
|
||||
"period_start_at": local_active_sub.period_start_at,
|
||||
"is_throttled": bool(local_active_sub.is_throttled),
|
||||
"base_hwid_device_limit": local_active_sub.hwid_device_limit,
|
||||
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": None,
|
||||
"extra_hwid_devices_valid_until_text": None,
|
||||
"extra_hwid_devices_next_valid_from": None,
|
||||
"device_topup_renewal_available": False,
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": False,
|
||||
"max_devices": self._effective_hwid_limit(
|
||||
local_active_sub.hwid_device_limit,
|
||||
int(local_active_sub.extra_hwid_devices or 0),
|
||||
),
|
||||
}
|
||||
|
||||
async def switch_tariff_without_payment(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -308,6 +477,17 @@ class SubscriptionLifecycleMixin:
|
||||
)
|
||||
result["end_date"] = sub.end_date
|
||||
result["is_active"] = sub.is_active
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if db_user:
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="tariff_upgrade",
|
||||
months=0,
|
||||
traffic_gb=None,
|
||||
payment_amount=payment_amount,
|
||||
end_date=sub.end_date,
|
||||
provider=provider,
|
||||
)
|
||||
return result
|
||||
|
||||
tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None
|
||||
@@ -568,6 +748,9 @@ class SubscriptionLifecycleMixin:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_uuid
|
||||
)
|
||||
preserve_tariff_limits = bool(
|
||||
active_sub and active_sub.tariff_key and self._tariffs_config()
|
||||
)
|
||||
if not active_sub or not active_sub.end_date:
|
||||
logging.info(
|
||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
||||
@@ -612,6 +795,7 @@ class SubscriptionLifecycleMixin:
|
||||
|
||||
if (
|
||||
apply_main_traffic_limit
|
||||
and not preserve_tariff_limits
|
||||
and updated_sub_model
|
||||
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
||||
):
|
||||
@@ -626,7 +810,9 @@ class SubscriptionLifecycleMixin:
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
expire_at=new_end_date_obj,
|
||||
traffic_limit_bytes=(
|
||||
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
|
||||
self.settings.user_traffic_limit_bytes
|
||||
if apply_main_traffic_limit and not preserve_tariff_limits
|
||||
else None
|
||||
),
|
||||
include_uuid=False,
|
||||
include_default_squads=False,
|
||||
@@ -663,14 +849,36 @@ class SubscriptionLifecycleMixin:
|
||||
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||
(
|
||||
panel_user_data,
|
||||
panel_user_confirmed_absent,
|
||||
panel_lookup_failure_reason,
|
||||
) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
|
||||
|
||||
if not panel_user_data:
|
||||
if panel_user_confirmed_absent:
|
||||
logging.warning(
|
||||
"Panel user %s confirmed absent on panel for user %s. "
|
||||
"Clearing local linkage. reason=%s",
|
||||
panel_user_uuid,
|
||||
user_id,
|
||||
panel_lookup_failure_reason,
|
||||
)
|
||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
return None
|
||||
logging.warning(
|
||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501
|
||||
"Panel user %s lookup failed for user %s; treating it as a panel access/API "
|
||||
"problem and preserving local linkage/subscription. reason=%s",
|
||||
panel_user_uuid,
|
||||
user_id,
|
||||
panel_lookup_failure_reason,
|
||||
)
|
||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
if local_active_sub:
|
||||
return await self._local_active_subscription_details_fallback(
|
||||
db_user,
|
||||
local_active_sub,
|
||||
)
|
||||
return None
|
||||
|
||||
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
|
||||
@@ -836,6 +1044,14 @@ class SubscriptionLifecycleMixin:
|
||||
if expected_hwid_limit is not None:
|
||||
hwid_limit = expected_hwid_limit
|
||||
|
||||
extra_hwid_valid_until = hwid_entitlement_summary.get("active_until")
|
||||
extra_hwid_next_valid_from = hwid_entitlement_summary.get("next_valid_from")
|
||||
device_topup_renewal_available = self._device_topup_renewal_available(
|
||||
active_extra_hwid_devices,
|
||||
extra_hwid_valid_until,
|
||||
panel_end_date,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
|
||||
@@ -896,8 +1112,12 @@ class SubscriptionLifecycleMixin:
|
||||
if local_active_sub
|
||||
else None,
|
||||
"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"),
|
||||
"extra_hwid_devices_valid_until": extra_hwid_valid_until,
|
||||
"extra_hwid_devices_valid_until_text": self._display_datetime_text(
|
||||
extra_hwid_valid_until
|
||||
),
|
||||
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from,
|
||||
"device_topup_renewal_available": device_topup_renewal_available,
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
|
||||
@@ -93,7 +93,7 @@ class PaymentContextMixin:
|
||||
"""Best-effort branded email confirming the payment. No-op if SMTP or
|
||||
the user's email aren't set. Failures are logged and swallowed so the
|
||||
payment flow is never blocked by mail delivery."""
|
||||
if not self.settings.email_auth_configured:
|
||||
if not getattr(self.settings, "email_auth_configured", False):
|
||||
return
|
||||
recipient = (db_user.email or "").strip() if db_user else ""
|
||||
if not recipient:
|
||||
|
||||
@@ -269,6 +269,15 @@ class TrafficMixin:
|
||||
purchased_bytes=purchase_bytes,
|
||||
kind="topup",
|
||||
)
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="topup",
|
||||
months=0,
|
||||
traffic_gb=float(traffic_gb),
|
||||
payment_amount=payment_amount,
|
||||
end_date=getattr(updated_sub, "end_date", None),
|
||||
provider=provider,
|
||||
)
|
||||
return {
|
||||
"subscription_id": sub.subscription_id,
|
||||
"traffic_limit_bytes": new_limit,
|
||||
@@ -376,6 +385,15 @@ class TrafficMixin:
|
||||
purchased_bytes=purchase_bytes,
|
||||
kind="premium_topup",
|
||||
)
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
sale_mode="premium_topup",
|
||||
months=0,
|
||||
traffic_gb=float(traffic_gb),
|
||||
payment_amount=payment_amount,
|
||||
end_date=getattr(sub, "end_date", None),
|
||||
provider=provider,
|
||||
)
|
||||
return {
|
||||
"subscription_id": sub.subscription_id,
|
||||
"premium_limit_bytes": premium_limit,
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
@@ -15,9 +15,11 @@ from bot.infra.redis import redis_lock
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.user_email_notifications import send_user_notification_email
|
||||
from bot.utils.date_utils import month_start
|
||||
from bot.utils.mini_app_url import subscription_mini_app_topup_url
|
||||
from config.settings import Settings
|
||||
from db.advisory_locks import acquire_subscription_background_sync_lock
|
||||
from db.dal import subscription_dal, tariff_dal, user_dal
|
||||
from db.models import Subscription
|
||||
|
||||
@@ -31,6 +33,10 @@ 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
|
||||
TARIFF_WORKER_DB_RETRY_ATTEMPTS = 3
|
||||
TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS = 0.5
|
||||
POSTGRES_RETRYABLE_SQLSTATES = {"40001", "40P01"}
|
||||
POSTGRES_RETRYABLE_ERROR_NAMES = {"DeadlockDetectedError", "SerializationError"}
|
||||
|
||||
|
||||
class TariffTrafficWorker:
|
||||
@@ -97,6 +103,36 @@ class TariffTrafficWorker:
|
||||
button = InlineKeyboardButton(text=_(fallback_key), callback_data="tariff_topup:list")
|
||||
return InlineKeyboardMarkup(inline_keyboard=[[button]])
|
||||
|
||||
async def _send_traffic_warning_email(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
subject_key: str,
|
||||
message_text: str,
|
||||
kind: str,
|
||||
) -> None:
|
||||
try:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
except Exception:
|
||||
logging.exception("TariffTrafficWorker: failed to load user %s for email", user_id)
|
||||
return
|
||||
if not user:
|
||||
return
|
||||
await send_user_notification_email(
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
user=user,
|
||||
subject_key=subject_key,
|
||||
message_text=message_text,
|
||||
dashboard_url=subscription_mini_app_topup_url(self.settings, kind),
|
||||
cta_label_key=(
|
||||
"email_traffic_warning_premium_cta"
|
||||
if kind == "premium"
|
||||
else "email_traffic_warning_regular_cta"
|
||||
),
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
if not self.settings.tariffs_config:
|
||||
return
|
||||
@@ -111,12 +147,14 @@ class TariffTrafficWorker:
|
||||
logging.info("TariffTrafficWorker tick skipped: Redis lock is held")
|
||||
else:
|
||||
started = time.monotonic()
|
||||
async with self.session_factory() as session:
|
||||
await self.traffic_period_tick(session)
|
||||
await session.commit()
|
||||
async with self.session_factory() as session:
|
||||
await self.legacy_throttle_recovery_tick(session)
|
||||
await session.commit()
|
||||
await self._run_db_tick_with_retry(
|
||||
"traffic_period",
|
||||
self.traffic_period_tick,
|
||||
)
|
||||
await self._run_db_tick_with_retry(
|
||||
"legacy_throttle_recovery",
|
||||
self.legacy_throttle_recovery_tick,
|
||||
)
|
||||
logging.info(
|
||||
"metric worker_tick_duration_seconds=%.3f worker=tariff",
|
||||
time.monotonic() - started,
|
||||
@@ -134,16 +172,80 @@ class TariffTrafficWorker:
|
||||
def stop(self) -> None:
|
||||
self._stopped.set()
|
||||
|
||||
async def _run_db_tick_with_retry(
|
||||
self,
|
||||
tick_name: str,
|
||||
tick: Callable[[AsyncSession], Awaitable[None]],
|
||||
) -> None:
|
||||
for attempt in range(1, TARIFF_WORKER_DB_RETRY_ATTEMPTS + 1):
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
await acquire_subscription_background_sync_lock(session)
|
||||
await tick(session)
|
||||
await session.commit()
|
||||
return
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
if (
|
||||
attempt < TARIFF_WORKER_DB_RETRY_ATTEMPTS
|
||||
and self._is_retryable_db_exception(exc)
|
||||
):
|
||||
delay = TARIFF_WORKER_DB_RETRY_BASE_SLEEP_SECONDS * attempt
|
||||
logging.warning(
|
||||
"TariffTrafficWorker %s retrying after database concurrency "
|
||||
"error, attempt %s/%s: %s",
|
||||
tick_name,
|
||||
attempt + 1,
|
||||
TARIFF_WORKER_DB_RETRY_ATTEMPTS,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable_db_exception(exc: BaseException) -> bool:
|
||||
pending: list[BaseException] = [exc]
|
||||
seen: set[int] = set()
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
current_id = id(current)
|
||||
if current_id in seen:
|
||||
continue
|
||||
seen.add(current_id)
|
||||
|
||||
sqlstate = getattr(current, "sqlstate", None) or getattr(current, "pgcode", None)
|
||||
if sqlstate in POSTGRES_RETRYABLE_SQLSTATES:
|
||||
return True
|
||||
|
||||
error_name = type(current).__name__
|
||||
message = str(current).lower()
|
||||
if (
|
||||
error_name in POSTGRES_RETRYABLE_ERROR_NAMES
|
||||
or "deadlock detected" in message
|
||||
or "could not serialize access" in message
|
||||
):
|
||||
return True
|
||||
|
||||
for attr in ("orig", "__cause__", "__context__"):
|
||||
nested = getattr(current, attr, None)
|
||||
if isinstance(nested, BaseException):
|
||||
pending.append(nested)
|
||||
|
||||
return False
|
||||
|
||||
async def traffic_period_tick(self, session: AsyncSession) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
self._premium_node_usage_tick_cache = {}
|
||||
warning_period_start = month_start(now)
|
||||
result = await session.execute(
|
||||
select(Subscription).where(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.tariff_key.is_not(None),
|
||||
)
|
||||
.order_by(Subscription.subscription_id.asc())
|
||||
)
|
||||
subs = list(result.scalars().all())
|
||||
if not subs:
|
||||
@@ -454,6 +556,8 @@ class TariffTrafficWorker:
|
||||
return
|
||||
ratio = used_val / limit_val
|
||||
levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95]))
|
||||
if 100 not in levels:
|
||||
levels.append(100)
|
||||
for level in levels:
|
||||
threshold = level / 100
|
||||
if ratio < threshold:
|
||||
@@ -474,30 +578,32 @@ class TariffTrafficWorker:
|
||||
level=level,
|
||||
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
|
||||
)
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
left_pct = max(0, 100 - level)
|
||||
tariff_name = hd.quote(str(tariff.name(user_lang)))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
if level < 100:
|
||||
text = _(
|
||||
"traffic_warning_regular_almost",
|
||||
tariff_name=tariff_name,
|
||||
left_pct=left_pct,
|
||||
**usage,
|
||||
)
|
||||
subject_key = "email_traffic_warning_regular_almost_subject"
|
||||
else:
|
||||
text = _(
|
||||
"traffic_warning_regular_depleted",
|
||||
tariff_name=tariff_name,
|
||||
**usage,
|
||||
)
|
||||
subject_key = "email_traffic_warning_regular_depleted_subject"
|
||||
if self.bot:
|
||||
try:
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
left_pct = max(0, 100 - level)
|
||||
tariff_name = hd.quote(str(tariff.name(user_lang)))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
if level < 100:
|
||||
text = _(
|
||||
"traffic_warning_regular_almost",
|
||||
tariff_name=tariff_name,
|
||||
left_pct=left_pct,
|
||||
**usage,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"traffic_warning_regular_depleted",
|
||||
tariff_name=tariff_name,
|
||||
**usage,
|
||||
)
|
||||
markup = self._traffic_topup_markup(user_lang, "regular")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
@@ -507,6 +613,13 @@ class TariffTrafficWorker:
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send traffic warning to user %s", sub.user_id)
|
||||
await self._send_traffic_warning_email(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
subject_key=subject_key,
|
||||
message_text=text,
|
||||
kind="regular",
|
||||
)
|
||||
if ratio >= 1.0 and not sub.is_throttled:
|
||||
logging.info(
|
||||
"Tariff traffic limit reached for user %s subscription %s. "
|
||||
@@ -930,31 +1043,31 @@ class TariffTrafficWorker:
|
||||
level=PREMIUM_WARNING_DEPLETED_LEVEL,
|
||||
traffic_limit_bytes=None,
|
||||
)
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_depleted",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_depleted",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
@@ -966,6 +1079,13 @@ class TariffTrafficWorker:
|
||||
logging.exception(
|
||||
"Failed to send premium traffic depleted warning to user %s", sub.user_id
|
||||
)
|
||||
await self._send_traffic_warning_email(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
subject_key="email_traffic_warning_premium_depleted_subject",
|
||||
message_text=text,
|
||||
kind="premium",
|
||||
)
|
||||
return
|
||||
|
||||
for level in levels:
|
||||
@@ -989,43 +1109,51 @@ class TariffTrafficWorker:
|
||||
level=storage_level,
|
||||
traffic_limit_bytes=None,
|
||||
)
|
||||
if not self.bot:
|
||||
continue
|
||||
try:
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
left_pct = max(0, 100 - int(level))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_almost",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
left_pct=left_pct,
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to send premium traffic warning to user %s", sub.user_id)
|
||||
user_lang = await self._user_lang(session, sub.user_id)
|
||||
_ = (
|
||||
(lambda k, **kw: self.i18n.gettext(user_lang, k, **kw))
|
||||
if self.i18n
|
||||
else (lambda k, **kw: k)
|
||||
)
|
||||
access = await self.subscription_service.premium_access_for_tariff(tariff)
|
||||
labels = access.get("node_labels") or access.get("squad_labels") or []
|
||||
if labels:
|
||||
visible = [hd.quote(str(x)) for x in labels[:8]]
|
||||
servers = "\n".join(f"• {label}" for label in visible)
|
||||
if len(labels) > len(visible):
|
||||
more = len(labels) - len(visible)
|
||||
servers += "\n" + _("traffic_warning_premium_servers_more", count=more)
|
||||
else:
|
||||
servers = _("traffic_warning_premium_generic_servers")
|
||||
left_pct = max(0, 100 - int(level))
|
||||
usage = self._usage_placeholders(used_val, limit_val)
|
||||
text = _(
|
||||
"traffic_warning_premium_almost",
|
||||
tariff_name=hd.quote(str(tariff.name(user_lang))),
|
||||
left_pct=left_pct,
|
||||
servers=servers,
|
||||
**usage,
|
||||
)
|
||||
if self.bot:
|
||||
try:
|
||||
markup = self._traffic_topup_markup(user_lang, "premium")
|
||||
await self.bot.send_message(
|
||||
sub.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send premium traffic warning to user %s", sub.user_id
|
||||
)
|
||||
await self._send_traffic_warning_email(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
subject_key="email_traffic_warning_premium_almost_subject",
|
||||
message_text=text,
|
||||
kind="premium",
|
||||
)
|
||||
|
||||
async def _premium_node_uuids_for_tariff(self, tariff) -> list[str]:
|
||||
cache_key = tuple(sorted(tariff.premium_squad_uuids or []))
|
||||
@@ -1159,10 +1287,12 @@ class TariffTrafficWorker:
|
||||
from Internal Squads.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Subscription).where(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.is_active == True,
|
||||
Subscription.is_throttled == True,
|
||||
)
|
||||
.order_by(Subscription.subscription_id.asc())
|
||||
)
|
||||
for sub in result.scalars().all():
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from db.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TELEGRAM_NOTIFICATIONS_UNKNOWN = "unknown"
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED = "enabled"
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START = "needs_start"
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED = "blocked"
|
||||
TELEGRAM_NOTIFICATION_STATUSES = {
|
||||
TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
}
|
||||
|
||||
|
||||
def normalize_telegram_notification_status(value: Optional[str]) -> str:
|
||||
status = str(value or "").strip().lower()
|
||||
return status if status in TELEGRAM_NOTIFICATION_STATUSES else TELEGRAM_NOTIFICATIONS_UNKNOWN
|
||||
|
||||
|
||||
def telegram_notifications_enabled(user: Optional[User]) -> bool:
|
||||
return (
|
||||
bool(getattr(user, "telegram_id", None))
|
||||
and normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
== TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
)
|
||||
|
||||
|
||||
def telegram_notifications_need_prompt(user: Optional[User]) -> bool:
|
||||
status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
return bool(getattr(user, "telegram_id", None)) and status in {
|
||||
TELEGRAM_NOTIFICATIONS_NEEDS_START,
|
||||
TELEGRAM_NOTIFICATIONS_BLOCKED,
|
||||
}
|
||||
|
||||
|
||||
def telegram_notifications_start_link(bot_username: Optional[str]) -> Optional[str]:
|
||||
username = str(bot_username or "").strip().lstrip("@")
|
||||
if not username or username == "your_bot_username":
|
||||
return None
|
||||
return f"https://t.me/{username}?start=notifications"
|
||||
|
||||
|
||||
def telegram_notification_status_from_error(exc: Exception) -> Optional[str]:
|
||||
if isinstance(exc, TelegramForbiddenError):
|
||||
return TELEGRAM_NOTIFICATIONS_BLOCKED
|
||||
if not isinstance(exc, TelegramBadRequest):
|
||||
return None
|
||||
|
||||
message = str(exc).lower()
|
||||
if any(
|
||||
token in message
|
||||
for token in (
|
||||
"bot was blocked",
|
||||
"user is deactivated",
|
||||
"forbidden",
|
||||
)
|
||||
):
|
||||
return TELEGRAM_NOTIFICATIONS_BLOCKED
|
||||
if any(
|
||||
token in message
|
||||
for token in (
|
||||
"chat not found",
|
||||
"bot can't initiate conversation",
|
||||
"bot can't initiate",
|
||||
"user not found",
|
||||
)
|
||||
):
|
||||
return TELEGRAM_NOTIFICATIONS_NEEDS_START
|
||||
return None
|
||||
|
||||
|
||||
async def mark_telegram_notifications_status(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
status: str,
|
||||
*,
|
||||
telegram_id: Optional[int] = None,
|
||||
checked_at: Optional[datetime] = None,
|
||||
) -> Optional[User]:
|
||||
normalized = normalize_telegram_notification_status(status)
|
||||
now = checked_at or datetime.now(timezone.utc)
|
||||
update_data: dict[str, Any] = {
|
||||
"telegram_notifications_status": normalized,
|
||||
"telegram_notifications_checked_at": now,
|
||||
}
|
||||
if telegram_id:
|
||||
update_data["telegram_id"] = int(telegram_id)
|
||||
if normalized == TELEGRAM_NOTIFICATIONS_ENABLED:
|
||||
update_data["telegram_notifications_enabled_at"] = now
|
||||
update_data["telegram_notifications_blocked_at"] = None
|
||||
elif normalized == TELEGRAM_NOTIFICATIONS_BLOCKED:
|
||||
update_data["telegram_notifications_blocked_at"] = now
|
||||
return await user_dal.update_user(session, user_id, update_data)
|
||||
|
||||
|
||||
async def mark_telegram_notifications_enabled_for_telegram_user(
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
) -> Optional[User]:
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, telegram_id)
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, telegram_id)
|
||||
if not db_user:
|
||||
return None
|
||||
return await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(db_user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
|
||||
def _translate(
|
||||
i18n: Optional[JsonI18n],
|
||||
language: str,
|
||||
key: str,
|
||||
fallback: str,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
if not i18n:
|
||||
return fallback.format(**kwargs) if kwargs else fallback
|
||||
return i18n.gettext(language, key, **kwargs) or fallback
|
||||
|
||||
|
||||
def _probe_keyboard(
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
language: str,
|
||||
) -> Optional[InlineKeyboardMarkup]:
|
||||
app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if not app_url:
|
||||
return None
|
||||
text = _translate(
|
||||
i18n,
|
||||
language,
|
||||
"telegram_notifications_open_app_button",
|
||||
"Open app",
|
||||
)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]]
|
||||
)
|
||||
|
||||
|
||||
async def probe_telegram_notifications(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
user: User,
|
||||
bot_username: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if not telegram_id:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
|
||||
current_status = normalize_telegram_notification_status(
|
||||
getattr(user, "telegram_notifications_status", None)
|
||||
)
|
||||
if current_status == TELEGRAM_NOTIFICATIONS_ENABLED and not force:
|
||||
return {
|
||||
"ok": True,
|
||||
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
|
||||
language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE)
|
||||
text = _translate(
|
||||
i18n,
|
||||
language,
|
||||
"telegram_notifications_enabled_message",
|
||||
"Telegram notifications are enabled.",
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
int(telegram_id),
|
||||
text,
|
||||
reply_markup=_probe_keyboard(settings, i18n, language),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
status = telegram_notification_status_from_error(exc)
|
||||
if status:
|
||||
await mark_telegram_notifications_status(session, int(user.user_id), status)
|
||||
return {
|
||||
"ok": False,
|
||||
"status": status,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
logger.warning(
|
||||
"Telegram notification probe failed for user %s / telegram %s: %s",
|
||||
user.user_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"status": TELEGRAM_NOTIFICATIONS_UNKNOWN,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
|
||||
await mark_telegram_notifications_status(
|
||||
session,
|
||||
int(user.user_id),
|
||||
TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
telegram_id=int(telegram_id),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"status": TELEGRAM_NOTIFICATIONS_ENABLED,
|
||||
"start_link": telegram_notifications_start_link(bot_username),
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_auth_service import EmailAuthService
|
||||
from bot.services.email_templates import render_user_notification
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
def _translate(
|
||||
i18n: Optional[JsonI18n],
|
||||
language: str,
|
||||
key: Optional[str],
|
||||
fallback: str = "",
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
if not key:
|
||||
return fallback
|
||||
if not i18n:
|
||||
return fallback or key
|
||||
text = i18n.gettext(language, key, **kwargs)
|
||||
return fallback if text == key and fallback else text
|
||||
|
||||
|
||||
async def send_user_notification_email(
|
||||
*,
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
user: Any,
|
||||
subject_key: str,
|
||||
message_text: str,
|
||||
dashboard_url: Optional[str] = None,
|
||||
cta_label_key: str = "email_user_notification_cta",
|
||||
subject_kwargs: Optional[dict[str, Any]] = None,
|
||||
heading_key: Optional[str] = None,
|
||||
intro_key: Optional[str] = None,
|
||||
) -> bool:
|
||||
if not getattr(settings, "email_auth_configured", False):
|
||||
return False
|
||||
recipient = str(getattr(user, "email", "") or "").strip()
|
||||
if not recipient:
|
||||
return False
|
||||
|
||||
language = (
|
||||
str(getattr(user, "language_code", "") or "").strip()
|
||||
or getattr(settings, "DEFAULT_LANGUAGE", "ru")
|
||||
or "ru"
|
||||
)
|
||||
kwargs = subject_kwargs or {}
|
||||
subject = _translate(i18n, language, subject_key, subject_key, **kwargs)
|
||||
heading = _translate(i18n, language, heading_key, subject, **kwargs)
|
||||
intro = _translate(
|
||||
i18n,
|
||||
language,
|
||||
intro_key or "email_user_notification_intro",
|
||||
"Notification from your account.",
|
||||
)
|
||||
cta_label = _translate(
|
||||
i18n,
|
||||
language,
|
||||
cta_label_key or "email_user_notification_cta",
|
||||
"Open dashboard",
|
||||
)
|
||||
|
||||
try:
|
||||
content = render_user_notification(
|
||||
settings,
|
||||
language_code=language,
|
||||
subject=subject,
|
||||
heading=heading,
|
||||
intro=intro,
|
||||
message_text=message_text,
|
||||
dashboard_url=dashboard_url,
|
||||
cta_label=cta_label,
|
||||
i18n=i18n,
|
||||
)
|
||||
await EmailAuthService(settings, i18n).send_rendered_email(
|
||||
email=recipient,
|
||||
content=content,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logging.exception("Failed to send user notification email to %s.", recipient)
|
||||
return False
|
||||
@@ -214,10 +214,10 @@ class Settings(BaseSettings):
|
||||
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
||||
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
||||
|
||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=200)
|
||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=600)
|
||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=1200)
|
||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=2400)
|
||||
|
||||
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
@@ -240,6 +240,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||
|
||||
@@ -95,6 +95,8 @@ class Tariff(BaseModel):
|
||||
monthly_gb: Optional[float] = None
|
||||
prices_rub: Dict[str, float] = Field(default_factory=dict)
|
||||
prices_stars: Dict[str, float] = Field(default_factory=dict)
|
||||
referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
|
||||
referral_bonus_days_referee: Dict[str, int] = Field(default_factory=dict)
|
||||
enabled_periods: List[int] = Field(default_factory=list)
|
||||
topup_packages: Optional[PackageSet] = None
|
||||
|
||||
@@ -129,6 +131,14 @@ class Tariff(BaseModel):
|
||||
if self.billing_model == "period":
|
||||
if self.monthly_gb is None or self.monthly_gb < 0:
|
||||
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
|
||||
self.referral_bonus_days_inviter = self._normalize_referral_bonus_map(
|
||||
self.referral_bonus_days_inviter,
|
||||
"referral_bonus_days_inviter",
|
||||
)
|
||||
self.referral_bonus_days_referee = self._normalize_referral_bonus_map(
|
||||
self.referral_bonus_days_referee,
|
||||
"referral_bonus_days_referee",
|
||||
)
|
||||
if not self.enabled_periods:
|
||||
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
|
||||
for months in self.enabled_periods:
|
||||
@@ -152,6 +162,23 @@ class Tariff(BaseModel):
|
||||
)
|
||||
return self
|
||||
|
||||
def _normalize_referral_bonus_map(
|
||||
self, values: Dict[str, int], field_name: str
|
||||
) -> Dict[str, int]:
|
||||
normalized: Dict[str, int] = {}
|
||||
for period, days in (values or {}).items():
|
||||
try:
|
||||
months = int(float(str(period).strip()))
|
||||
bonus_days = int(float(days))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry")
|
||||
if months <= 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
|
||||
if bonus_days < 0:
|
||||
raise ValueError(f"tariff {self.key}: {field_name} days must be >= 0")
|
||||
normalized[str(months)] = bonus_days
|
||||
return normalized
|
||||
|
||||
def name(self, lang: str, fallback: str = "ru") -> str:
|
||||
return self.names.get(lang) or self.names.get(fallback) or self.key
|
||||
|
||||
@@ -173,6 +200,14 @@ class Tariff(BaseModel):
|
||||
value = source.get(str(months))
|
||||
return float(value) if value is not None else None
|
||||
|
||||
def referral_inviter_bonus_days(self, months: int) -> Optional[int]:
|
||||
value = self.referral_bonus_days_inviter.get(str(int(months)))
|
||||
return int(value) if value is not None else None
|
||||
|
||||
def referral_referee_bonus_days(self, months: int) -> Optional[int]:
|
||||
value = self.referral_bonus_days_referee.get(str(int(months)))
|
||||
return int(value) if value is not None else None
|
||||
|
||||
def min_period_price_rub(self) -> Optional[float]:
|
||||
prices = [
|
||||
float(self.prices_rub[str(months)])
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# Serializes background jobs that rewrite subscription rows from panel state.
|
||||
SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID = 817512404897421338
|
||||
|
||||
|
||||
async def acquire_subscription_background_sync_lock(session: AsyncSession) -> None:
|
||||
await session.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:lock_id)"),
|
||||
{"lock_id": SUBSCRIPTION_BACKGROUND_SYNC_LOCK_ID},
|
||||
)
|
||||
@@ -408,6 +408,27 @@ async def merge_users(
|
||||
target.channel_subscription_checked_at = source.channel_subscription_checked_at
|
||||
if not target.channel_subscription_verified_for and source.channel_subscription_verified_for:
|
||||
target.channel_subscription_verified_for = source.channel_subscription_verified_for
|
||||
source_tg_status = str(getattr(source, "telegram_notifications_status", None) or "unknown")
|
||||
target_tg_status = str(getattr(target, "telegram_notifications_status", None) or "unknown")
|
||||
if source_tg_status == "enabled" and target_tg_status != "enabled":
|
||||
target.telegram_notifications_status = source_tg_status
|
||||
elif target_tg_status == "unknown" and source_tg_status != "unknown":
|
||||
target.telegram_notifications_status = source_tg_status
|
||||
if getattr(source, "telegram_notifications_checked_at", None) and (
|
||||
not getattr(target, "telegram_notifications_checked_at", None)
|
||||
or source.telegram_notifications_checked_at > target.telegram_notifications_checked_at
|
||||
):
|
||||
target.telegram_notifications_checked_at = source.telegram_notifications_checked_at
|
||||
if getattr(source, "telegram_notifications_enabled_at", None) and (
|
||||
not getattr(target, "telegram_notifications_enabled_at", None)
|
||||
or source.telegram_notifications_enabled_at > target.telegram_notifications_enabled_at
|
||||
):
|
||||
target.telegram_notifications_enabled_at = source.telegram_notifications_enabled_at
|
||||
if getattr(source, "telegram_notifications_blocked_at", None) and (
|
||||
not getattr(target, "telegram_notifications_blocked_at", None)
|
||||
or source.telegram_notifications_blocked_at > target.telegram_notifications_blocked_at
|
||||
):
|
||||
target.telegram_notifications_blocked_at = source.telegram_notifications_blocked_at
|
||||
if source.lifetime_used_traffic_bytes is not None:
|
||||
target.lifetime_used_traffic_bytes = (
|
||||
target.lifetime_used_traffic_bytes or 0
|
||||
|
||||
@@ -1047,6 +1047,20 @@ def _migration_0031_add_subscription_notifications(connection: Connection) -> No
|
||||
)
|
||||
|
||||
|
||||
def _migration_0032_add_telegram_notification_status(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
additions = {
|
||||
"telegram_notifications_status": "VARCHAR(32) NOT NULL DEFAULT 'unknown'",
|
||||
"telegram_notifications_checked_at": "TIMESTAMPTZ",
|
||||
"telegram_notifications_enabled_at": "TIMESTAMPTZ",
|
||||
"telegram_notifications_blocked_at": "TIMESTAMPTZ",
|
||||
}
|
||||
for column, ddl_type in additions.items():
|
||||
if column not in columns:
|
||||
connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}"))
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1214,6 +1228,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track sent subscription notification stages",
|
||||
upgrade=_migration_0031_add_subscription_notifications,
|
||||
),
|
||||
Migration(
|
||||
id="0032_add_telegram_notification_status",
|
||||
description="Track whether the bot can message Telegram-linked users",
|
||||
upgrade=_migration_0032_add_telegram_notification_status,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ class User(Base):
|
||||
password_set_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
|
||||
telegram_photo_url = Column(Text, nullable=True)
|
||||
telegram_notifications_status = Column(String(32), nullable=False, default="unknown")
|
||||
telegram_notifications_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_notifications_enabled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
telegram_notifications_blocked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
first_name = Column(String, nullable=True)
|
||||
last_name = Column(String, nullable=True)
|
||||
language_code = Column(String, default="ru")
|
||||
|
||||
@@ -40,8 +40,10 @@
|
||||
{ "count": 1, "price": 2500 }
|
||||
]
|
||||
},
|
||||
"prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 },
|
||||
"prices_rub": { "1": 200, "3": 600, "6": 1200, "12": 2400 },
|
||||
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
|
||||
"referral_bonus_days_inviter": { "1": 3, "3": 7, "6": 15, "12": 30 },
|
||||
"referral_bonus_days_referee": { "1": 1, "3": 3, "6": 7, "12": 15 },
|
||||
"enabled_periods": [1, 3, 6, 12],
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -130,6 +130,7 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
|
||||
|
||||
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
||||
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
|
||||
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
||||
|
||||
@@ -23,6 +23,13 @@ server {
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location = /robots.txt {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files /robots.txt =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8081;
|
||||
proxy_http_version 1.1;
|
||||
@@ -89,23 +96,27 @@ server {
|
||||
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Disallow: /
|
||||
@@ -1,4 +1,5 @@
|
||||
**/.env
|
||||
**/data/
|
||||
nginx/ssl/*
|
||||
!nginx/ssl/README.md
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
||||
|
||||
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
|
||||
|
||||
| Папка | Документация |
|
||||
| --- | --- |
|
||||
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -149,8 +149,6 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
caddy-data:
|
||||
name: remnawave-minishop-caddy-data
|
||||
caddy-config:
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -144,5 +144,3 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -148,5 +148,3 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- remnawave-shop
|
||||
depends_on:
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
ports:
|
||||
- "${WEB_SERVER_BIND:-0.0.0.0:8080}:8080"
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -93,7 +93,7 @@ services:
|
||||
environment:
|
||||
<<: *app_environment
|
||||
volumes:
|
||||
- shop-data:/app/data
|
||||
- ./data:/app/data
|
||||
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:ro
|
||||
networks:
|
||||
- remnawave-shop
|
||||
@@ -132,5 +132,3 @@ volumes:
|
||||
name: remnawave-minishop-db-data
|
||||
redis-data:
|
||||
name: remnawave-minishop-redis-data
|
||||
shop-data:
|
||||
name: remnawave-minishop-shop-data
|
||||
|
||||
@@ -79,6 +79,7 @@ export default defineConfig({
|
||||
{ label: 'Основные', slug: 'features/core' },
|
||||
{ label: 'Платежи', slug: 'features/payments' },
|
||||
{ label: 'Подписки', slug: 'features/subscriptions' },
|
||||
{ label: 'Уведомления', slug: 'features/notifications' },
|
||||
{ label: 'Тарифы', slug: 'features/tariffs' },
|
||||
{ label: 'Веб-приложение / Mini App', slug: 'features/web-app' },
|
||||
{ label: 'Telegram-авторизация', slug: 'features/telegram-auth' },
|
||||
|
||||
+11
-22
@@ -1,22 +1,11 @@
|
||||
# Cloudflare Pages rewrites for the static docs demo SPA.
|
||||
# Keep these scoped to app routes so runtime JS/CSS/assets are served directly.
|
||||
/demo/runtime /demo/runtime/app.html 200
|
||||
/demo/runtime/ /demo/runtime/app.html 200
|
||||
/demo/runtime/home /demo/runtime/app.html 200
|
||||
/demo/runtime/home/* /demo/runtime/app.html 200
|
||||
/demo/runtime/install /demo/runtime/app.html 200
|
||||
/demo/runtime/install/* /demo/runtime/app.html 200
|
||||
/demo/runtime/trial /demo/runtime/app.html 200
|
||||
/demo/runtime/trial/* /demo/runtime/app.html 200
|
||||
/demo/runtime/invite /demo/runtime/app.html 200
|
||||
/demo/runtime/invite/* /demo/runtime/app.html 200
|
||||
/demo/runtime/devices /demo/runtime/app.html 200
|
||||
/demo/runtime/devices/* /demo/runtime/app.html 200
|
||||
/demo/runtime/support /demo/runtime/app.html 200
|
||||
/demo/runtime/support/* /demo/runtime/app.html 200
|
||||
/demo/runtime/settings /demo/runtime/app.html 200
|
||||
/demo/runtime/settings/* /demo/runtime/app.html 200
|
||||
/demo/runtime/login /demo/runtime/app.html 200
|
||||
/demo/runtime/login/* /demo/runtime/app.html 200
|
||||
/demo/runtime/admin /demo/runtime/app.html 200
|
||||
/demo/runtime/admin/* /demo/runtime/app.html 200
|
||||
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
|
||||
# Use the directory entrypoint so Pages clean URLs do not loop between app.html and app.
|
||||
/demo/runtime/home/* /demo/runtime/app/ 200
|
||||
/demo/runtime/install/* /demo/runtime/app/ 200
|
||||
/demo/runtime/trial/* /demo/runtime/app/ 200
|
||||
/demo/runtime/invite/* /demo/runtime/app/ 200
|
||||
/demo/runtime/devices/* /demo/runtime/app/ 200
|
||||
/demo/runtime/support/* /demo/runtime/app/ 200
|
||||
/demo/runtime/settings/* /demo/runtime/app/ 200
|
||||
/demo/runtime/login/* /demo/runtime/app/ 200
|
||||
/demo/runtime/admin/* /demo/runtime/app/ 200
|
||||
|
||||
@@ -2,12 +2,14 @@ const frame = document.getElementById("demo-frame");
|
||||
const runtimeBase = "/demo/runtime";
|
||||
const demoBase = "/demo";
|
||||
const defaultMock = "tariffs";
|
||||
const publicRouteAliases = new Map([["/app", "/home"]]);
|
||||
const stateMocks = new Set([
|
||||
"tariffs",
|
||||
"depleted",
|
||||
"no-subscription",
|
||||
"trial",
|
||||
"devices",
|
||||
"notifications",
|
||||
"auth",
|
||||
]);
|
||||
const routeMocks = new Set([...stateMocks, "guides", "install"]);
|
||||
@@ -41,7 +43,8 @@ const routeFromPublicPath = () => {
|
||||
const publicRoute = pathname.slice(demoBase.length);
|
||||
if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime"))
|
||||
return "";
|
||||
return normalizePath(publicRoute);
|
||||
const normalized = normalizePath(publicRoute);
|
||||
return publicRouteAliases.get(normalized.toLowerCase()) || normalized;
|
||||
};
|
||||
|
||||
const routeFromParams = () => {
|
||||
@@ -88,36 +91,6 @@ const mockForRoute = (route) => {
|
||||
const initialMock = params.has("mock")
|
||||
? normalizeRouteMock(params.get("mock"))
|
||||
: normalizeRouteMock(mockForRoute(initialRoute) || defaultMock);
|
||||
if (initialMock === "trial" && initialRoute === "/trial") {
|
||||
initialRoute = "/home";
|
||||
const normalizedUrl = new URL(window.location.href);
|
||||
normalizedUrl.pathname = `${demoBase}/home`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||
);
|
||||
}
|
||||
params.set("mock", initialMock);
|
||||
params.delete("path");
|
||||
params.delete("screen");
|
||||
params.delete("admin_section");
|
||||
params.set("path", initialRoute);
|
||||
frame.src = `${runtimeBase}/app.html?${params.toString()}${window.location.hash || ""}`;
|
||||
|
||||
const routeFromRuntimeUrl = (url) => {
|
||||
if (url.origin !== window.location.origin) return "";
|
||||
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
|
||||
return "";
|
||||
const runtimePath = normalizePath(
|
||||
url.pathname.slice(runtimeBase.length) || "/home",
|
||||
);
|
||||
if (runtimePath === "/app.html") {
|
||||
return normalizePath(url.searchParams.get("path") || "/home");
|
||||
}
|
||||
return runtimePath;
|
||||
};
|
||||
|
||||
const materializedRouteFromRuntime = (route) => {
|
||||
const normalized = normalizePath(route);
|
||||
if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users";
|
||||
@@ -131,6 +104,55 @@ const materializedRouteFromRuntime = (route) => {
|
||||
|
||||
const publicPathFromRoute = (route) =>
|
||||
`${demoBase}${materializedRouteFromRuntime(route)}`;
|
||||
const canonicalizeInitialPublicUrl = (route) => {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
const lowerPathname = pathname.toLowerCase();
|
||||
if (
|
||||
lowerPathname !== demoBase &&
|
||||
!publicRouteAliases.has(pathname.slice(demoBase.length).toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const normalizedUrl = new URL(window.location.href);
|
||||
normalizedUrl.pathname = publicPathFromRoute(route);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||
);
|
||||
};
|
||||
|
||||
if (initialMock === "trial" && initialRoute === "/trial") {
|
||||
initialRoute = "/home";
|
||||
const normalizedUrl = new URL(window.location.href);
|
||||
normalizedUrl.pathname = publicPathFromRoute(initialRoute);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${normalizedUrl.pathname}${normalizedUrl.search}${normalizedUrl.hash}`,
|
||||
);
|
||||
} else {
|
||||
canonicalizeInitialPublicUrl(initialRoute);
|
||||
}
|
||||
params.set("mock", initialMock);
|
||||
params.delete("path");
|
||||
params.delete("screen");
|
||||
params.delete("admin_section");
|
||||
params.set("path", initialRoute);
|
||||
frame.src = `${runtimeBase}/app/?${params.toString()}${window.location.hash || ""}`;
|
||||
|
||||
const routeFromRuntimeUrl = (url) => {
|
||||
if (url.origin !== window.location.origin) return "";
|
||||
if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase()))
|
||||
return "";
|
||||
const runtimePath = normalizePath(
|
||||
url.pathname.slice(runtimeBase.length) || "/home",
|
||||
);
|
||||
if (runtimePath === "/app" || runtimePath === "/app.html") {
|
||||
return normalizePath(url.searchParams.get("path") || "/home");
|
||||
}
|
||||
return runtimePath;
|
||||
};
|
||||
const routeForStateMock = (mock) => {
|
||||
if (mock === "devices") return "/devices";
|
||||
if (mock === "auth") return "/login";
|
||||
@@ -141,7 +163,7 @@ const runtimeSrc = (route, searchParams = new URLSearchParams()) => {
|
||||
nextParams.delete("screen");
|
||||
nextParams.delete("admin_section");
|
||||
nextParams.set("path", normalizePath(route));
|
||||
return `${runtimeBase}/app.html?${nextParams.toString()}${window.location.hash || ""}`;
|
||||
return `${runtimeBase}/app/?${nextParams.toString()}${window.location.hash || ""}`;
|
||||
};
|
||||
const topbar = document.querySelector(".demo-topbar");
|
||||
const toggle = document.querySelector(".demo-topbar__toggle");
|
||||
|
||||
@@ -1,49 +1,16 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
demoPublicRoutes,
|
||||
demoRuntimeRoutes,
|
||||
} from "../src/lib/demoRoutes.mjs";
|
||||
|
||||
const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const distRoot = path.join(siteRoot, "dist");
|
||||
|
||||
const userRoutes = [
|
||||
"home",
|
||||
"install",
|
||||
"trial",
|
||||
"invite",
|
||||
"devices",
|
||||
"support",
|
||||
"settings",
|
||||
"login",
|
||||
"login/password",
|
||||
];
|
||||
|
||||
const adminRoutes = [
|
||||
"stats",
|
||||
"users",
|
||||
"payments",
|
||||
"promos",
|
||||
"ads",
|
||||
"broadcast",
|
||||
"logs",
|
||||
"support",
|
||||
"tariffs",
|
||||
"appearance",
|
||||
"translations",
|
||||
"backups",
|
||||
"settings",
|
||||
];
|
||||
|
||||
const demoRoutes = [
|
||||
...userRoutes.map((route) => `demo/${route}`),
|
||||
"demo/admin",
|
||||
...adminRoutes.map((route) => `demo/admin/${route}`),
|
||||
];
|
||||
|
||||
const runtimeRoutes = [
|
||||
...userRoutes.map((route) => `demo/runtime/${route}`),
|
||||
"demo/runtime/admin",
|
||||
...adminRoutes.map((route) => `demo/runtime/admin/${route}`),
|
||||
];
|
||||
const demoRoutes = demoPublicRoutes.map((route) => `demo/${route}`);
|
||||
const runtimeRoutes = demoRuntimeRoutes.map((route) => `demo/runtime/${route}`);
|
||||
|
||||
async function copyHtml(source, route) {
|
||||
const targetDir = path.join(distRoot, route);
|
||||
|
||||
@@ -19,6 +19,7 @@ const descriptions = {
|
||||
'features/core.md': 'Пользовательские и админские сценарии Remnawave Minishop.',
|
||||
'features/payments.md': 'Платежные провайдеры, кнопки оплаты и webhook-обработка.',
|
||||
'features/subscriptions.md': 'Тарифы на срок и по трафику, premium-сквады, HWID-устройства и жизненный цикл подписки.',
|
||||
'features/notifications.md': 'Каналы Telegram и email для пользовательских, админских и сервисных уведомлений Remnawave Minishop.',
|
||||
'features/tariffs.md': 'Каталог тарифов, модели на срок/по трафику, premium-сквады и HWID-устройства.',
|
||||
'features/web-app.md': 'Telegram Mini App, публичные инструкции, проксирование и реферальные ссылки.',
|
||||
'features/telegram-auth.md': 'Telegram Mini Apps initData, Telegram OAuth, BotFather и настройка входа через Telegram.',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export const demoUserRoutes = [
|
||||
"home",
|
||||
"install",
|
||||
"trial",
|
||||
"invite",
|
||||
"devices",
|
||||
"support",
|
||||
"settings",
|
||||
"login",
|
||||
"login/password",
|
||||
];
|
||||
|
||||
export const demoAdminRoutes = [
|
||||
"stats",
|
||||
"users",
|
||||
"payments",
|
||||
"promos",
|
||||
"ads",
|
||||
"broadcast",
|
||||
"logs",
|
||||
"support",
|
||||
"tariffs",
|
||||
"appearance",
|
||||
"translations",
|
||||
"backups",
|
||||
"settings",
|
||||
];
|
||||
|
||||
export const demoPublicRouteAliases = ["app"];
|
||||
|
||||
export const demoPublicRoutes = [
|
||||
...demoPublicRouteAliases,
|
||||
...demoUserRoutes,
|
||||
"admin",
|
||||
...demoAdminRoutes.map((route) => `admin/${route}`),
|
||||
];
|
||||
|
||||
export const demoRuntimeRoutes = [
|
||||
...demoUserRoutes,
|
||||
"admin",
|
||||
...demoAdminRoutes.map((route) => `admin/${route}`),
|
||||
];
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
const defaultDemoSrc = '/demo/runtime/app.html?path=/home&mock=tariffs';
|
||||
const defaultDemoSrc = '/demo/runtime/app/?path=/home&mock=tariffs';
|
||||
const docsHref = '/getting-started/demo/';
|
||||
---
|
||||
|
||||
@@ -294,6 +294,7 @@ const docsHref = '/getting-started/demo/';
|
||||
<option value="no-subscription">Нет подписки</option>
|
||||
<option value="trial">Доступна пробная подписка</option>
|
||||
<option value="devices">Лимит и докупка устройств</option>
|
||||
<option value="notifications">Telegram-уведомления</option>
|
||||
<option value="auth">Вход и регистрация</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
---
|
||||
import DemoShell from '../demo.astro';
|
||||
import { demoPublicRoutes } from '../../lib/demoRoutes.mjs';
|
||||
|
||||
export function getStaticPaths() {
|
||||
const userRoutes = [
|
||||
'home',
|
||||
'install',
|
||||
'trial',
|
||||
'invite',
|
||||
'devices',
|
||||
'support',
|
||||
'settings',
|
||||
'login',
|
||||
'login/password',
|
||||
];
|
||||
const adminRoutes = [
|
||||
'stats',
|
||||
'users',
|
||||
'payments',
|
||||
'promos',
|
||||
'ads',
|
||||
'broadcast',
|
||||
'logs',
|
||||
'support',
|
||||
'tariffs',
|
||||
'appearance',
|
||||
'translations',
|
||||
'backups',
|
||||
'settings',
|
||||
];
|
||||
|
||||
return [
|
||||
...userRoutes.map((path) => ({ params: { path } })),
|
||||
{ params: { path: 'admin' } },
|
||||
...adminRoutes.map((section) => ({ params: { path: `admin/${section}` } })),
|
||||
];
|
||||
return demoPublicRoutes.map((path) => ({ params: { path } }));
|
||||
}
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const runtimeAppHtml = path.join(process.cwd(), "public", "demo", "runtime", "app.html");
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function GET() {
|
||||
return new Response(await readFile(runtimeAppHtml, "utf8"), {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -117,13 +117,15 @@
|
||||
| --- | --- |
|
||||
| `PANEL_API_URL` | URL API панели, например `https://panel.example.com/api`. |
|
||||
| `PANEL_API_KEY` | API-ключ панели. |
|
||||
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. |
|
||||
| `PANEL_WEBHOOK_SECRET` | Секрет проверки Remnawave webhook. Задайте его в Remnawave Panel и вставьте то же значение сюда или в админку. |
|
||||
| `USER_SQUAD_UUIDS` | Internal Squads по умолчанию для legacy-режима без JSON-каталога. |
|
||||
| `USER_EXTERNAL_SQUAD_UUID` | Необязательный External Squad. |
|
||||
| `USER_TRAFFIC_LIMIT_GB` | Legacy-лимит трафика пользователя. |
|
||||
| `USER_TRAFFIC_STRATEGY` | Legacy-стратегия лимита трафика. |
|
||||
| `USER_HWID_DEVICE_LIMIT` | Legacy-лимит HWID-устройств по умолчанию. |
|
||||
|
||||
В Remnawave Panel поле `WEBHOOK_URL` должно указывать на публичный Minishop webhook: `WEBHOOK_BASE_URL` + `/webhook/panel`. Если публичный домен приложения `https://app.example.com`, итоговый адрес будет `https://app.example.com/webhook/panel`.
|
||||
|
||||
## Веб-приложение, внешний вид и Telegram Login
|
||||
|
||||
Часть внешнего вида (`WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_*`, `WEBAPP_FAVICON_*`) сохранена для совместимости, но env-значения этих полей игнорируются при загрузке. Настраивайте их в **Админка -> Внешний вид**.
|
||||
@@ -355,6 +357,8 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
|
||||
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
|
||||
|
||||
Если JSON-каталог существует и проходит валидацию, цены, периоды и реферальные бонусы period-тарифов берутся из JSON. Legacy-переменные ниже используются только без JSON-каталога.
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `TARIFFS_CONFIG_PATH` | Путь к JSON-каталогу тарифов. |
|
||||
@@ -363,7 +367,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `3_MONTHS_ENABLED` | Legacy-доступность периода 3 месяца без JSON-каталога. |
|
||||
| `6_MONTHS_ENABLED` | Legacy-доступность периода 6 месяцев без JSON-каталога. |
|
||||
| `12_MONTHS_ENABLED` | Legacy-доступность периода 12 месяцев без JSON-каталога. |
|
||||
| `RUB_PRICE_1_MONTH`, `RUB_PRICE_3_MONTHS`, `RUB_PRICE_6_MONTHS`, `RUB_PRICE_12_MONTHS` | Legacy-цены RUB. |
|
||||
| `RUB_PRICE_1_MONTH`, `RUB_PRICE_3_MONTHS`, `RUB_PRICE_6_MONTHS`, `RUB_PRICE_12_MONTHS` | Legacy-цены RUB. Дефолты: `200`, `600`, `1200`, `2400`. |
|
||||
| `STARS_PRICE_1_MONTH`, `STARS_PRICE_3_MONTHS`, `STARS_PRICE_6_MONTHS`, `STARS_PRICE_12_MONTHS` | Legacy-цены Stars. |
|
||||
| `TRAFFIC_PACKAGES` | Legacy-пакеты трафика RUB, формат `10:199,50:799`. |
|
||||
| `STARS_TRAFFIC_PACKAGES` | Legacy-пакеты трафика Stars. |
|
||||
@@ -382,9 +386,10 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
|
||||
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
|
||||
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. |
|
||||
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему. |
|
||||
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному. |
|
||||
| `REFERRAL_BONUS_DAYS_1_MONTH`, `REFERRAL_BONUS_DAYS_3_MONTHS`, `REFERRAL_BONUS_DAYS_6_MONTHS`, `REFERRAL_BONUS_DAYS_12_MONTHS` | Legacy-бонусы пригласившему без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_inviter`. |
|
||||
| `REFEREE_BONUS_DAYS_1_MONTH`, `REFEREE_BONUS_DAYS_3_MONTHS`, `REFEREE_BONUS_DAYS_6_MONTHS`, `REFEREE_BONUS_DAYS_12_MONTHS` | Legacy-бонусы приглашенному без JSON-каталога. В JSON-тарифах используйте `referral_bonus_days_referee`. |
|
||||
| `SUBSCRIPTION_NOTIFICATIONS_ENABLED` | Включает напоминания о подписке. |
|
||||
| `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED` | Дублирует пользовательские уведомления жизненного цикла подписки на email, если SMTP настроен и у пользователя есть email. |
|
||||
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
|
||||
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
|
||||
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- `WEBAPP_SESSION_SECRET` должен быть постоянным между рестартами, иначе Web App-сессии станут невалидными.
|
||||
- `WEBHOOK_SECRET_TOKEN` защищает вебхук Telegram.
|
||||
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel.
|
||||
- `PANEL_WEBHOOK_SECRET` проверяет входящие события Remnawave Panel. Секрет задается в Remnawave Panel и вставляется в настройки бота.
|
||||
- Платежные токены и webhook-секреты храните в `.env` или настройках админки с учетом доступа к серверу.
|
||||
|
||||
Сгенерировать секрет можно так:
|
||||
@@ -24,6 +24,7 @@ openssl rand -hex 32
|
||||
## Публичные URL
|
||||
|
||||
- `WEBHOOK_BASE_URL` должен вести на backend-сервер вебхуков.
|
||||
- В Remnawave Panel `WEBHOOK_URL` должен быть `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`.
|
||||
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
|
||||
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал, Remnawave-доступы и поведение `/start`;
|
||||
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
|
||||
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
||||
- legacy-цены без JSON-каталога: периоды подписки, RUB/Stars цены и пакеты трафика;
|
||||
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
|
||||
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
|
||||
- пробный период, реферальные бонусы, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
||||
- пробный период, приветственный реферальный бонус, уведомления, логирование, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
||||
|
||||
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
Редактор тарифа разделен на вкладки:
|
||||
|
||||
- **Основное**: ключ, модель `period`/`traffic`, видимость, названия и описания RU/EN, базовые Internal Squads, HWID-лимит, месячный лимит или курс конвертации;
|
||||
- **Цены**: периоды и цены для `period`, пакеты GB и цены для `traffic`;
|
||||
- **Цены**: периоды, цены и реферальные бонусы для `period`, пакеты GB и цены для `traffic`;
|
||||
- **Докупки**: обычные пакеты докупки трафика для `period`; для `traffic` отдельные докупки не нужны, пользователь повторно покупает пакеты из `traffic_packages`;
|
||||
- **Premium**: названия premium-раздела RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты premium-докупки;
|
||||
- **Устройства**: RUB/Stars пакеты докупки HWID-устройств.
|
||||
|
||||
@@ -105,4 +105,4 @@ docker compose logs -f backend
|
||||
- Код сразу устаревает: проверьте `EMAIL_CODE_TTL_SECONDS` и время на сервере.
|
||||
- Пользователь получает `rate_limited`: подождите `EMAIL_CODE_RESEND_SECONDS` или проверьте brute-force настройки.
|
||||
|
||||
Email-уведомления поддержки и платежей используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md).
|
||||
Email-уведомления поддержки, платежей и жизненного цикла подписки используют тот же SMTP-контур. Сценарий поддержки описан в [разделе тикетов](support.md), сводка по каналам - в разделе [уведомления](notifications.md).
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Уведомления
|
||||
|
||||
Minishop отправляет уведомления в Telegram и на email. Telegram-канал означает личные сообщения пользователю, сообщения администраторам из `ADMIN_IDS` или сообщения в `LOG_CHAT_ID` - зависит от события. Email работает только при настроенном SMTP и наличии email у получателя.
|
||||
|
||||
Для пользовательских строк отметка `Telegram = ✓` означает, что событие умеет отправляться в Telegram пользователю с привязанным Telegram-аккаунтом. Email-only пользователь такое Telegram-сообщение не получит.
|
||||
|
||||
Для уведомлений жизненного цикла подписки есть отдельный флаг `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED`. Если он включен, пользовательские уведомления об окончании подписки отправляются в Telegram при наличии привязанного Telegram-аккаунта и на email при наличии привязанной почты.
|
||||
|
||||
## Сводная таблица
|
||||
|
||||
| Событие | Получатель | Telegram | Email | Условия и ограничения |
|
||||
| --- | --- | :---: | :---: | --- |
|
||||
| Код входа по email и magic link | Пользователь | - | ✓ | Нужны `SMTP_*` и `EMAIL_*` настройки. Magic link также требует корректный `SUBSCRIPTION_MINI_APP_URL`. |
|
||||
| Код установки или смены пароля | Пользователь | - | ✓ | Нужны `SMTP_*` и `EMAIL_*` настройки. |
|
||||
| Успешная оплата подписки | Пользователь | ✓ | ✓ | Для `subscription`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Реферальный бонус приглашателю: активная подписка продлена | Пользователь-пригласивший | ✓ | ✓ | Отправляется после оплаты приглашенным пользователем, если бонус продлил активную подписку приглашателя. |
|
||||
| Реферальный бонус приглашателю: создана бонусная подписка | Пользователь-пригласивший | ✓ | ✓ | Отправляется после оплаты приглашенным пользователем, если у приглашателя не было активной подписки для продления. |
|
||||
| Напоминание за 3 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 3`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Напоминание за 2 дня до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 2`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Предупреждение о списании автопродления за 48 часов | Пользователь | ✓ | ✓ | Отправляется вместо обычного 48-часового напоминания для YooKassa-подписок с включенным автопродлением; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Напоминание за 1 день до окончания подписки | Пользователь | ✓ | ✓ | Работает, если `SUBSCRIPTION_NOTIFY_DAYS_BEFORE >= 1`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. Для YooKassa auto-renew 24-часовой webhook сначала пытается списать продление и может не отправить напоминание. |
|
||||
| Напоминание за несколько часов до окончания подписки | Пользователь | ✓ | ✓ | Отправляется локальным worker, если `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` задан от 1 до 23; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Уведомление в день окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_ON_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Уведомление через сутки после окончания подписки | Пользователь | ✓ | ✓ | Управляется `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE`; email требует `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`. |
|
||||
| Исчерпан трафик пробного периода | Пользователь | ✓ | ✓ | Отправляется локальным worker для trial-подписок; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Обычный трафик тарифа почти исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker` по уровням из `TARIFF_TRAFFIC_WARNING_LEVELS`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Обычный трафик тарифа исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker`, когда регулярный лимит трафика достигнут; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Premium-трафик тарифа почти исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker` по уровням из `TARIFF_TRAFFIC_WARNING_LEVELS` для premium-квоты; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Premium-трафик тарифа исчерпан | Пользователь | ✓ | ✓ | Отправляется `TariffTrafficWorker`, когда premium-квота достигнута; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Ответ администратора в тикете поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
||||
| Закрытие тикета поддержки | Пользователь | ✓ | ✓ | Telegram отправляется пользователям с Telegram-аккаунтом, email - пользователям с привязанным email. |
|
||||
| Слияние email- и Telegram-аккаунтов | Пользователь | - | ✓ | Email отправляется на итоговый email при merge из Web App-формы привязки; Telegram OAuth-link merge логируется для админов без пользовательского email-письма. |
|
||||
| Рассылка администратора пользователям | Пользователи с Telegram | ✓ | - | Отправляется через очередь из Telegram-админки или Web Admin выбранной аудитории: `all`, `active`, `inactive`. |
|
||||
| Прямое сообщение администратора пользователю | Пользователь с Telegram | ✓ | - | Отправляется из карточки пользователя в Telegram-админке или Web Admin; email-канала нет. |
|
||||
| Новый тикет поддержки | Администраторы | ✓ | ✓ | Telegram/log уведомления зависят от `LOG_SUPPORT`; email включается через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`. |
|
||||
| Ответ пользователя в тикете поддержки | Администраторы | ✓ | ✓ | Telegram/log уведомления зависят от `LOG_SUPPORT`; email включается через `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`. |
|
||||
| Новая регистрация через Telegram | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||
| Новая регистрация по email | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||
| Email привязан к Telegram-аккаунту | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||
| Telegram привязан к email-аккаунту | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||
| Email- и Telegram-аккаунты объединены | Администраторы / лог-чат | ✓ | - | Управляется `LOG_NEW_USERS`. |
|
||||
| Успешная оплата | Администраторы / лог-чат | ✓ | - | Управляется `LOG_PAYMENTS`. |
|
||||
| Активация trial | Администраторы / лог-чат | ✓ | - | Управляется `LOG_TRIAL_ACTIVATIONS`. |
|
||||
| Активация промокода | Администраторы / лог-чат | ✓ | - | Управляется `LOG_PROMO_ACTIVATIONS`. |
|
||||
| Подозрительная попытка промокода | Администраторы / лог-чат | ✓ | - | Управляется `LOG_SUSPICIOUS_ACTIVITY`. |
|
||||
| Результат ручной синхронизации с Remnawave Panel | Администратор, запустивший синхронизацию | ✓ | - | Worker отправляет короткий результат в `target_chat_id`, если синхронизация запускалась через очередь. |
|
||||
| Ошибка создания пользователя в Remnawave Panel | Администраторы | ✓ | - | Отправляется администраторам из `ADMIN_IDS`. |
|
||||
| Архив бэкапа создан | Администраторы / backup-чат | ✓ | - | Архив отправляется в `BACKUP_CHAT_ID`, а если он пустой - в `LOG_CHAT_ID`. |
|
||||
| Ошибка бэкапа | Администраторы / backup-чат | ✓ | - | Ошибка отправляется в `BACKUP_CHAT_ID`, а если он пустой - в `LOG_CHAT_ID`. |
|
||||
|
||||
## Важно про окончание подписки
|
||||
|
||||
Если пользователь привязал email к Telegram-аккаунту, уведомления об окончании подписки дублируются в оба канала: Telegram и email. Если привязан только Telegram - уйдет только Telegram. Если есть только email - уйдет только email.
|
||||
|
||||
Кнопка в email-уведомлениях об окончании подписки открывает Web App через вход по email-коду: код отправляется сразу, после подтверждения пользователь попадает к продлению подписки. Для бывшей платной подписки выбирается тот же тариф, для trial - стандартный, тариф по умолчанию или первый доступный тариф.
|
||||
|
||||
Остальные полезные пользовательские события - успешные и неуспешные оплаты, докупки трафика/HWID, платное повышение тарифа, реферальные бонусы, предупреждения по трафику и привязка способа оплаты - тоже отправляются на email, если SMTP настроен и у пользователя есть привязанная почта. Ручные админские рассылки и прямые сообщения остаются Telegram-only, потому что их содержимое и аудитория управляются администратором отдельно и могут включать Telegram-специфичный формат.
|
||||
|
||||
Для пользовательских email-уведомлений нужен настроенный SMTP-контур и email у пользователя. Уведомления жизненного цикла подписки дополнительно требуют `SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED=True`; support-уведомления администраторам дополнительно управляются `SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED`.
|
||||
|
||||
## Синхронизация каналов
|
||||
|
||||
Remnawave webhook и локальный `SubscriptionNotificationWorker` используют общий сервис отправки и общую таблицу дедупликации `subscription_notifications`.
|
||||
|
||||
Дедупликация ведется отдельно по каналам: например, `before_3d:telegram` и `before_3d:email`. Благодаря этому сбой одного канала не блокирует второй, а повторное событие из webhook или worker не отправляет уже доставленное уведомление повторно.
|
||||
|
||||
Старые записи без канала, например `before_3d`, считаются Telegram-историей. Они блокируют повторное Telegram-сообщение, но не мешают отправить email после обновления.
|
||||
|
||||
Если webhook от Remnawave приходит по пользователю без локальной подписки, используется legacy fallback: отправляется только Telegram по `telegram_id` из payload, без email и без канальной дедупликации.
|
||||
@@ -22,7 +22,7 @@ JSON-каталог может содержать несколько тариф
|
||||
- добавление, редактирование и удаление тарифов;
|
||||
- включение и выключение тарифа на витрине;
|
||||
- выбор тарифа по умолчанию;
|
||||
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, пакеты докупки трафика;
|
||||
- настройка тарифов на срок (`period`): месячный лимит, периоды, RUB/Stars цены, реферальные бонусы и пакеты докупки трафика;
|
||||
- настройка тарифов по трафику (`traffic`): пакеты GB, RUB/Stars цены, курс конвертации;
|
||||
- настройка базовых Internal Squads из списка Remnawave;
|
||||
- настройка premium-раздела: названия RU/EN, premium Internal Squads, месячный premium-лимит и RUB/Stars пакеты докупки premium-трафика;
|
||||
@@ -60,7 +60,9 @@ JSON-каталог может содержать несколько тариф
|
||||
"squad_uuids": ["uuid-1"],
|
||||
"billing_model": "period",
|
||||
"monthly_gb": 500,
|
||||
"prices_rub": { "1": 150, "3": 400 },
|
||||
"prices_rub": { "1": 200, "3": 600 },
|
||||
"referral_bonus_days_inviter": { "1": 3, "3": 7 },
|
||||
"referral_bonus_days_referee": { "1": 1, "3": 3 },
|
||||
"enabled_periods": [1, 3],
|
||||
"topup_packages": {
|
||||
"rub": [{ "gb": 10, "price": 99 }],
|
||||
@@ -108,6 +110,8 @@ JSON-каталог может содержать несколько тариф
|
||||
| `monthly_gb` | Базовый месячный лимит трафика тарифа. `0` означает безлимит. |
|
||||
| `prices_rub` | Цены периодов в рублях, ключ - количество месяцев. |
|
||||
| `prices_stars` | Цены периодов в Telegram Stars. |
|
||||
| `referral_bonus_days_inviter` | Бонус пригласившему в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||
| `referral_bonus_days_referee` | Бонус приглашенному в днях для каждого периода. Ключ - количество месяцев, как в `enabled_periods`. |
|
||||
| `enabled_periods` | Периоды, доступные для покупки. |
|
||||
| `topup_packages` | Пакеты докупки трафика именно для этого тарифа. Если поле не задано или списки пустые, докупка для тарифа не показывается в Web App и Telegram-боте. |
|
||||
|
||||
@@ -314,7 +318,15 @@ Remnawave ограничивает доступ при достижении `tra
|
||||
|
||||
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`.
|
||||
|
||||
Промокоды с бонусными днями применяются к покупке period-подписки. Реферальные бонусы по периодам также относятся к подпискам на срок; в режиме продажи трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам.
|
||||
Промокоды с бонусными днями применяются к покупке period-подписки.
|
||||
|
||||
Реферальные бонусы за оплату в JSON-каталоге задаются прямо в period-тарифе рядом с ценами периода: `referral_bonus_days_inviter` для пригласившего и `referral_bonus_days_referee` для приглашенного. Ключи этих словарей - месяцы периода (`"1"`, `"3"`, `"6"`, `"12"` или любые другие периоды тарифа, например `"2"`, `"4"`, `"8"`, `"16"`). Для `traffic`-тарифов такие бонусы не применяются.
|
||||
|
||||
Если приглашенный покупает один тариф, а пригласивший находится на другом, размер бонуса берется из тарифа и периода, который купил приглашенный. При этом подписка пригласившего только продлевается на бонусные дни: лимиты, Internal Squads и другие параметры его текущего тарифа не пересчитываются под тариф приглашенного.
|
||||
|
||||
В Web App и Telegram-меню подробные строки по периодам показываются только для legacy-режима или когда активен один period-тариф. Если включено несколько period-тарифов, Web App показывает сообщение, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазонами "от N до N дней" и раскрытием подробностей по иконке вопроса. Telegram-меню в этом случае показывает только диапазоны по каждому тарифу.
|
||||
|
||||
В legacy-режиме без JSON-каталога используются переменные `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; при продаже трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам.
|
||||
|
||||
## Привязка существующих подписок
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ services:
|
||||
- Telegram deep-link: `https://t.me/<bot>?start=ref_u<code>`;
|
||||
- Web App ссылка: `https://app.domain.com/?ref=u<code>`.
|
||||
|
||||
В разделе бонусов Web App показывает приветственный бонус за регистрацию и бонусы за оплату подписки. В legacy-режиме или при одном period-тарифе выводятся подробные строки по периодам. Если в JSON-каталоге включено несколько period-тарифов, Web App показывает, что бонус зависит от тарифа и периода оплаты друга, затем список тарифов с диапазоном "от N до N дней"; подробности по периодам раскрываются по иконке вопроса.
|
||||
|
||||
Веб-приложение учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передает его в Telegram OAuth или вход по email.
|
||||
|
||||
Для email-регистраций пользователь в Remnawave создается с username вида `em_<referral_code>`. Email добавляется в описание пользователя панели и, если API панели принимает поле `email`, передается отдельным полем. Для Telegram-регистраций используется username `tg_<telegram_id>`.
|
||||
|
||||
@@ -49,6 +49,8 @@ WEBAPP_DEFAULT_THEME=
|
||||
|
||||
`WEBAPP_DEFAULT_THEME` опционален. Если он задан и совпадает с ключом темы, он переопределяет `default: true` в `theme.json`. Если переменная пустая, дефолт выбирается из дескрипторов тем.
|
||||
|
||||
В compose-примерах `data/themes` - это локальная папка рядом с выбранным `docker-compose.yml`; она монтируется в контейнер как `/app/data/themes`. Правки в `backend/bot/app/web/themes` попадают в прод только при сборке собственного образа; опубликованный образ их не видит.
|
||||
|
||||
Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent.
|
||||
|
||||
## Контракт `theme.json`
|
||||
@@ -266,7 +268,7 @@ CSS можно писать для пользовательской части
|
||||
|
||||
2. Создайте папку в `WEBAPP_THEMES_DIR`.
|
||||
|
||||
В Docker по умолчанию это `data/themes`. Если включен bind mount `./data:/app/data`, убедитесь, что контейнер может писать в `data`.
|
||||
В Docker это `data/themes` рядом с выбранным `docker-compose.yml`, внутри контейнера путь будет `/app/data/themes`. Убедитесь, что контейнер может писать в `data`.
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes/neon
|
||||
@@ -314,6 +316,8 @@ CSS можно писать для пользовательской части
|
||||
|
||||
Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, `/install`, публичная `/s/<token>`, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов.
|
||||
|
||||
После ручного изменения CSS поднимите `assets_version` в `theme.json` или сделайте жесткую перезагрузку страницы: тема подключается с `?v=<assets_version>`, и браузер может держать старую версию.
|
||||
|
||||
9. Добавьте ассеты при необходимости.
|
||||
|
||||
Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets/<key>/...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее.
|
||||
@@ -357,6 +361,7 @@ CSS можно писать для пользовательской части
|
||||
Если CSS не применился:
|
||||
|
||||
- проверьте `css_file` и URL `/webapp-theme-css/<key>/<css_file>`;
|
||||
- если CSS уже был открыт в браузере, увеличьте `assets_version` в `theme.json` или очистите кеш;
|
||||
- убедитесь, что файл меньше 512 KiB;
|
||||
- начинайте селекторы с `.theme-key-<key>`;
|
||||
- откройте `/home?theme_preview=<key>` в новом окне, чтобы исключить сохраненный старый выбор.
|
||||
|
||||
@@ -24,14 +24,14 @@ nano .env
|
||||
| --- | --- |
|
||||
| `BOT_TOKEN` | Токен Telegram-бота. |
|
||||
| `ADMIN_IDS` | Telegram ID администраторов через запятую; без этого не попасть в Web App админку. |
|
||||
| `WEBHOOK_BASE_URL` | Публичный URL webhook-домена backend. |
|
||||
| `WEBHOOK_BASE_URL` | Публичный URL webhook-домена backend. Для Remnawave Panel `WEBHOOK_URL` будет `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`. |
|
||||
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Доступы PostgreSQL для Compose и backend. |
|
||||
| `WEBAPP_ENABLED` | Включает Web App и админку. Для первого запуска держите `True`. |
|
||||
| `WEBAPP_SESSION_SECRET` | Стабильный секрет сессий Web App. |
|
||||
| `WEBHOOK_SECRET_TOKEN` | Стабильный секретный токен вебхука Telegram. |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | Публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`. Это URL, который открывают кнопки Telegram и который указывается в BotFather; не добавляйте сюда `/api` или webhook-пути. |
|
||||
| `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` | Встроенные инструкции установки в Web App и кнопках бота. По умолчанию включены; обычно их достаточно менять в админке. |
|
||||
| `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` | Базовая интеграция с Remnawave. Эти значения стоит хранить в `.env`, но при необходимости их можно переопределить из админки. |
|
||||
| `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` | Базовая интеграция с Remnawave. Секрет вебхука задайте в Remnawave Panel и вставьте то же значение в настройки бота; эти значения стоит хранить в `.env`, но при необходимости их можно переопределить из админки. |
|
||||
|
||||
`WEBAPP_SESSION_SECRET` и `WEBHOOK_SECRET_TOKEN` можно сгенерировать так:
|
||||
|
||||
@@ -57,7 +57,7 @@ openssl rand -hex 32
|
||||
|
||||
Рекомендуемый порядок первичной настройки:
|
||||
|
||||
1. **Система -> Настройки -> Remnawave**: проверьте `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`, базовые squads.
|
||||
1. **Система -> Настройки -> Remnawave Panel**: проверьте `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`, базовые squads. Рядом с настройками Remnawave админка показывает вычисленный адрес для `WEBHOOK_URL` в панели, например `https://app.example.com/webhook/panel`.
|
||||
2. **Система -> Тарифы**: создайте JSON-каталог тарифов, выберите Internal Squads, настройте модели на срок/по трафику, premium-сквады и HWID-пакеты.
|
||||
3. **Система -> Настройки -> Инструкции подключения**: проверьте, что Remnawave Panel отдает нужный конфиг Subscription Page. JSON-переопределение включайте только если нужно временно заменить конфиг панели.
|
||||
4. **Система -> Настройки -> Платежи**: включите нужные провайдеры и заполните их ключи.
|
||||
@@ -82,9 +82,9 @@ openssl rand -hex 32
|
||||
|
||||
## Файловые данные
|
||||
|
||||
В штатном `docker-compose.yml` данные хранятся в named volume `shop-data`. Внутри него лежат тарифы, темы, логотипы и прочие файловые данные приложения.
|
||||
В compose-примерах данные монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml`. Внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
|
||||
|
||||
Если для локальной разработки включаете bind mount `./data:/app/data`, заранее создайте каталоги и отдайте их пользователю контейнера:
|
||||
Перед первым запуском создайте каталоги и отдайте их пользователю контейнера:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- [Админка: бэкапы](/demo/admin/backups)
|
||||
- [Пробный период](/demo/home?mock=trial)
|
||||
- [Докупка устройств](/demo/devices?mock=devices)
|
||||
- [Запуск бота для Telegram-уведомлений](/demo/home?mock=notifications)
|
||||
- [Вход и регистрация](/demo/login?mock=auth)
|
||||
|
||||
## Как собирается
|
||||
|
||||
@@ -331,14 +331,12 @@ distributed lock; код подготовлен к нескольким репл
|
||||
|
||||
- `postgres-data`;
|
||||
- `redis-data`;
|
||||
- `shop-data`;
|
||||
В Caddy-варианте также используются `caddy-data` и `caddy-config`.
|
||||
|
||||
`shop-data` монтируется целиком в `/app/data`; внутри него лежат тарифы, темы, логотипы и прочие
|
||||
файловые данные приложения.
|
||||
Файлы приложения монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml` в
|
||||
`/app/data`; внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
|
||||
|
||||
Если вместо именованного volume включаете bind mount `./data:/app/data`, на сервере заранее дайте права
|
||||
пользователю контейнера `10001`:
|
||||
Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
||||
|
||||
@@ -20,6 +20,8 @@ docker compose logs -f backend worker frontend
|
||||
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`.
|
||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN`, `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET`.
|
||||
|
||||
Для вебхуков Remnawave в панели укажите `WEBHOOK_URL` как `WEBHOOK_BASE_URL` + `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет создается или задается в Remnawave Panel; тот же секрет вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или позже в **Система -> Настройки -> Remnawave Panel**.
|
||||
|
||||
## Как выбрать Compose-вариант
|
||||
|
||||
Для продакшена по умолчанию берите [Caddy](deployment.md#caddy-рекомендуемый-вариант): это самый короткий путь к публичному HTTPS без ручной раскладки сертификатов.
|
||||
|
||||
@@ -125,7 +125,7 @@ docker compose \
|
||||
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
|
||||
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
|
||||
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. |
|
||||
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию; чтобы выключить — обнулите `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`. |
|
||||
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. |
|
||||
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
|
||||
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
|
||||
| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
|
||||
|
||||
+152
-1
@@ -73,6 +73,7 @@
|
||||
const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000;
|
||||
const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45;
|
||||
const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500;
|
||||
const TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS = 1500;
|
||||
import {
|
||||
activationPaymentFailed,
|
||||
createActivationHandoff,
|
||||
@@ -152,6 +153,7 @@
|
||||
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
|
||||
let activeTab = "home";
|
||||
let screen = "home";
|
||||
let emailLoginDeeplinkConsumed = false;
|
||||
let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null;
|
||||
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
|
||||
let publicInstallSubscription = null;
|
||||
@@ -166,6 +168,9 @@
|
||||
let activationPendingWatchBusy = false;
|
||||
let activationResumeRefreshBusy = false;
|
||||
let activationResumeLastCheckAt = 0;
|
||||
let telegramNotificationsBotOpenedAt = 0;
|
||||
let telegramNotificationsResumeRefreshBusy = false;
|
||||
let telegramNotificationsResumeLastCheckAt = 0;
|
||||
let promoCode = "";
|
||||
let promoBusy = false;
|
||||
let promoStatus = "";
|
||||
@@ -323,6 +328,7 @@
|
||||
devicesBusy,
|
||||
devicesStatus,
|
||||
devicesIsError,
|
||||
devicesErrorCode,
|
||||
deviceConfirmOpen,
|
||||
deviceToDisconnect,
|
||||
deviceDisconnectBusy,
|
||||
@@ -473,7 +479,13 @@
|
||||
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
||||
$: userLanguage = languageName(currentLang);
|
||||
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked");
|
||||
$: hasUnlinkedIdentity = !user?.telegram_linked || !user?.email;
|
||||
$: telegramNotificationsStatus = String(user?.telegram_notifications_status || "unknown");
|
||||
$: telegramNotificationsNeedPrompt = Boolean(
|
||||
user?.telegram_linked && user?.telegram_notifications_need_prompt
|
||||
);
|
||||
$: telegramNotificationsStartLink = String(user?.telegram_notifications_start_link || "");
|
||||
$: hasUnlinkedIdentity =
|
||||
!user?.telegram_linked || !user?.email || telegramNotificationsNeedPrompt;
|
||||
$: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
|
||||
$: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0));
|
||||
$: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee);
|
||||
@@ -747,6 +759,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTelegramNotificationsOnResume() {
|
||||
if (
|
||||
mode !== "app" ||
|
||||
!telegramNotificationsNeedPrompt ||
|
||||
!telegramNotificationsBotOpenedAt ||
|
||||
telegramNotificationsResumeRefreshBusy
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - telegramNotificationsResumeLastCheckAt <
|
||||
TELEGRAM_NOTIFICATIONS_RESUME_REFRESH_COOLDOWN_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
telegramNotificationsResumeLastCheckAt = now;
|
||||
telegramNotificationsResumeRefreshBusy = true;
|
||||
try {
|
||||
await loadData({ fresh: true, preserveView: true });
|
||||
if (!telegramNotificationsNeedPrompt) telegramNotificationsBotOpenedAt = 0;
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
} finally {
|
||||
telegramNotificationsResumeRefreshBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAppLaunchTarget() {
|
||||
appLaunchTarget = readExternalAppLaunchTarget();
|
||||
return appLaunchTarget;
|
||||
@@ -769,6 +809,7 @@
|
||||
const onActivationResume = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
void refreshPendingActivationOnResume();
|
||||
void refreshTelegramNotificationsOnResume();
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== "hidden") onActivationResume();
|
||||
@@ -1137,10 +1178,85 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openTelegramNotificationsBot() {
|
||||
const link = telegramNotificationsStartLink;
|
||||
telegramNotificationsBotOpenedAt = Date.now();
|
||||
if (!link) {
|
||||
showToast(t("wa_telegram_notifications_link_unavailable"));
|
||||
return;
|
||||
}
|
||||
const currentTg = tg || telegramSdk.refresh();
|
||||
if (currentTg?.openTelegramLink && /^https:\/\/t\.me\//i.test(link)) {
|
||||
try {
|
||||
tg = currentTg;
|
||||
currentTg.openTelegramLink(link);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to generic external opening below.
|
||||
}
|
||||
}
|
||||
openExternalLink(link);
|
||||
}
|
||||
|
||||
function currentSearchParams() {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
function readEmailCodeLoginDeeplink() {
|
||||
const params = currentSearchParams();
|
||||
if (params.get("login") !== "email_code") return null;
|
||||
const emailHint = normalizedEmail(params.get("login_email") || "");
|
||||
if (!emailHint || !emailHint.includes("@")) return null;
|
||||
return emailHint;
|
||||
}
|
||||
|
||||
function hasEmailCodeLoginDeeplink() {
|
||||
return Boolean(readEmailCodeLoginDeeplink());
|
||||
}
|
||||
|
||||
async function startEmailCodeLoginFromDeeplink() {
|
||||
if (emailLoginDeeplinkConsumed) return;
|
||||
const emailHint = readEmailCodeLoginDeeplink();
|
||||
if (!emailHint) return;
|
||||
emailLoginDeeplinkConsumed = true;
|
||||
authStore.update((s) => ({
|
||||
...s,
|
||||
email: emailHint,
|
||||
emailCode: "",
|
||||
pendingEmail: "",
|
||||
passwordLoginMode: false,
|
||||
passwordLoginFallback: false,
|
||||
}));
|
||||
await tick();
|
||||
await authStore.requestEmailCode((nextScreen) => {
|
||||
screen = nextScreen;
|
||||
});
|
||||
}
|
||||
|
||||
function readRenewalDeeplink() {
|
||||
const params = currentSearchParams();
|
||||
const shouldRenew = params.get("after_login") === "renew" || params.get("renew") === "1";
|
||||
if (!shouldRenew) return null;
|
||||
return {
|
||||
tariffKey: String(params.get("renew_tariff") || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function stripRenewalLoginQueryFromUrl() {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
const keys = ["login", "login_email", "after_login", "renew", "renew_tariff"];
|
||||
const changed = keys.some((key) => url.searchParams.has(key));
|
||||
if (!changed) return;
|
||||
for (const key of keys) url.searchParams.delete(key);
|
||||
const search = url.searchParams.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${url.pathname}${search ? `?${search}` : ""}${url.hash}`
|
||||
);
|
||||
}
|
||||
|
||||
function docsDemoParentSearchParams() {
|
||||
if (!isDocsDemo) return null;
|
||||
try {
|
||||
@@ -1316,6 +1432,7 @@
|
||||
clearToken,
|
||||
clearManualLogoutFlag,
|
||||
isManuallyLoggedOut,
|
||||
hasEmailCodeLoginDeeplink,
|
||||
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
|
||||
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
|
||||
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
|
||||
@@ -1508,6 +1625,30 @@
|
||||
stripTopupQueryFromUrl();
|
||||
}
|
||||
}
|
||||
|
||||
const renewalDeep = readRenewalDeeplink();
|
||||
if (renewalDeep) {
|
||||
const plansList = payload.plans?.length ? payload.plans : [];
|
||||
const tariffCatalogLocal = buildTariffCatalog(plansList);
|
||||
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
|
||||
activeTab = "home";
|
||||
screen = "home";
|
||||
syncAppSectionPath("home", true);
|
||||
billingStore.openPaymentModal(
|
||||
tariffModeLocal,
|
||||
tariffModeLocal && tariffCatalogLocal.length === 1,
|
||||
tariffCatalogLocal,
|
||||
payload.subscription || {},
|
||||
plansList,
|
||||
payload.payment_methods?.[0]?.id || "",
|
||||
{
|
||||
preferredTariffKey: renewalDeep.tariffKey,
|
||||
selectDefaultTariff: true,
|
||||
preferCheckout: true,
|
||||
}
|
||||
);
|
||||
stripRenewalLoginQueryFromUrl();
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -1529,6 +1670,7 @@
|
||||
screen = "login";
|
||||
activeTab = "home";
|
||||
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||
void startEmailCodeLoginFromDeeplink();
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -2124,10 +2266,14 @@
|
||||
{regularTrafficTopupBarClickable}
|
||||
{regularTrafficTopupUnlocked}
|
||||
{subscription}
|
||||
{telegramNotificationsNeedPrompt}
|
||||
{telegramNotificationsStartLink}
|
||||
{telegramNotificationsStatus}
|
||||
{termUnitLabel}
|
||||
{trafficMode}
|
||||
{trialBusy}
|
||||
{activateTrial}
|
||||
{openTelegramNotificationsBot}
|
||||
openConnectLink={openInstallOrConnect}
|
||||
{openPaymentModal}
|
||||
{openRegularTopupModal}
|
||||
@@ -2185,6 +2331,7 @@
|
||||
{devicesData}
|
||||
{devicesIsError}
|
||||
{devicesLoaded}
|
||||
{devicesErrorCode}
|
||||
{devicesStatus}
|
||||
{subscription}
|
||||
{loadDevices}
|
||||
@@ -2230,12 +2377,16 @@
|
||||
{profileEmail}
|
||||
{profileTelegramId}
|
||||
{supportUrl}
|
||||
{telegramNotificationsNeedPrompt}
|
||||
{telegramNotificationsStartLink}
|
||||
{telegramNotificationsStatus}
|
||||
{telegramProfileName}
|
||||
{user}
|
||||
{userAgreementUrl}
|
||||
{userLanguage}
|
||||
showLogout={!telegramMiniAppContext}
|
||||
linkTelegramAccount={linkTelegramFromSettings}
|
||||
{openTelegramNotificationsBot}
|
||||
logout={accountStore.logout}
|
||||
{openAdminPanel}
|
||||
{openExternalLink}
|
||||
|
||||
@@ -389,6 +389,21 @@
|
||||
usersStore.openUser(uid, { pathContext: "payments" });
|
||||
}
|
||||
|
||||
function openLogsUserCard(userId) {
|
||||
const uid = Number(userId);
|
||||
if (!Number.isFinite(uid) || uid === 0) return;
|
||||
const next = normalizeSection("logs");
|
||||
sidebarOpen = false;
|
||||
if (active !== next) {
|
||||
active = next;
|
||||
paymentsStore.closePayment({ skipPush: true });
|
||||
supportStore.closeTicketView({ skipPush: true });
|
||||
onSectionChange(next);
|
||||
}
|
||||
usersStore.setActive(next);
|
||||
usersStore.openUser(uid, { skipPush: true, pathContext: "logs" });
|
||||
}
|
||||
|
||||
function openUserCard(userId) {
|
||||
const uid = Number(userId);
|
||||
if (!Number.isFinite(uid) || uid === 0) return;
|
||||
@@ -801,7 +816,7 @@
|
||||
{/if}
|
||||
|
||||
{#if active === "logs"}
|
||||
<LogsSection {at} {fmtDate} />
|
||||
<LogsSection {at} {fmtDate} onOpenUserCard={openLogsUserCard} />
|
||||
{/if}
|
||||
|
||||
{#if active === "support"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Input } from "$components/ui/index.js";
|
||||
import { Trash2 } from "$components/ui/icons.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
@@ -106,7 +107,7 @@
|
||||
<div class="admin-form" data-dialog-content>
|
||||
<div class="admin-dialog-form-section">
|
||||
<AdminField label={at("ad_label_source", {}, "Источник")}>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="telegram_ads"
|
||||
@@ -118,7 +119,7 @@
|
||||
label={at("ad_label_param", {}, "start-параметр")}
|
||||
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="ads_summer25"
|
||||
@@ -129,7 +130,7 @@
|
||||
</div>
|
||||
<div class="admin-dialog-form-section">
|
||||
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
AdminEmptyState,
|
||||
AdminSelect,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { Checkbox } from "$components/ui/index.js";
|
||||
import { Checkbox, ColorInput, FileInput, Input, RangeInput } from "$components/ui/index.js";
|
||||
import { Switch } from "$components/ui/primitives.js";
|
||||
import { getContext, onDestroy, onMount } from "svelte";
|
||||
|
||||
@@ -444,10 +444,9 @@
|
||||
|
||||
<div class="appearance-controls">
|
||||
<section class="appearance-control-card">
|
||||
<input
|
||||
bind:this={logoFileInput}
|
||||
<FileInput
|
||||
bind:element={logoFileInput}
|
||||
class="appearance-file-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon"
|
||||
onchange={handleLogoFileChange}
|
||||
/>
|
||||
@@ -461,7 +460,7 @@
|
||||
{at("appearance_logo_upload_file", {}, "Загрузить файл")}
|
||||
</AdminButton>
|
||||
<div class="appearance-url-row">
|
||||
<input
|
||||
<Input
|
||||
class="input appearance-control"
|
||||
type="url"
|
||||
placeholder="https://example.com/logo.png"
|
||||
@@ -490,7 +489,7 @@
|
||||
<span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span>
|
||||
</label>
|
||||
<div class="appearance-emoji-grid">
|
||||
<input
|
||||
<Input
|
||||
class="input appearance-control"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
@@ -547,10 +546,9 @@
|
||||
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
bind:this={faviconFileInput}
|
||||
<FileInput
|
||||
bind:element={faviconFileInput}
|
||||
class="appearance-file-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.ico"
|
||||
onchange={handleFaviconFileChange}
|
||||
/>
|
||||
@@ -564,7 +562,7 @@
|
||||
{at("appearance_favicon_upload_file", {}, "Загрузить favicon")}
|
||||
</AdminButton>
|
||||
<div class="appearance-url-row">
|
||||
<input
|
||||
<Input
|
||||
class="input appearance-control"
|
||||
type="url"
|
||||
placeholder="https://example.com/icon.png"
|
||||
@@ -657,18 +655,17 @@
|
||||
</span>
|
||||
<label class="admin-theme-card-option appearance-color-row">
|
||||
<span>{at("appearance_theme_accent", {}, "Accent")}</span>
|
||||
<input
|
||||
class="admin-color"
|
||||
class:is-empty={!isThemeAccentSet(theme)}
|
||||
type="color"
|
||||
<ColorInput
|
||||
class={`admin-color${!isThemeAccentSet(theme) ? " is-empty" : ""}`}
|
||||
value={pickerHex(theme.tokens?.accent)}
|
||||
ariaLabel={at("appearance_theme_accent", {}, "Accent")}
|
||||
title={isThemeAccentSet(theme)
|
||||
? theme.tokens?.accent
|
||||
: at("appearance_theme_accent_empty", {}, "Не задан")}
|
||||
onclick={() => openThemeAccentPicker(theme)}
|
||||
oninput={(event) => setThemeAccent(theme, event.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input appearance-color-text"
|
||||
type="text"
|
||||
placeholder={at("appearance_theme_accent_placeholder", {}, "Не задан")}
|
||||
@@ -693,17 +690,17 @@
|
||||
"Логотип на главной и входе"
|
||||
)}</span
|
||||
>
|
||||
<input
|
||||
<RangeInput
|
||||
class="appearance-logo-scale-range"
|
||||
type="range"
|
||||
min="50"
|
||||
max="300"
|
||||
step="5"
|
||||
ariaLabel={at("appearance_theme_home_logo_scale", {}, "Home logo scale")}
|
||||
value={homeLogoScale(theme)}
|
||||
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
|
||||
onValueChange={(value) => setThemeHomeLogoScale(theme, value)}
|
||||
/>
|
||||
<span class="appearance-logo-scale-value">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="50"
|
||||
@@ -814,7 +811,7 @@
|
||||
background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent);
|
||||
}
|
||||
|
||||
.appearance-file-input {
|
||||
:global(.appearance-file-input) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -949,7 +946,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appearance-color-text {
|
||||
:global(.appearance-color-text) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -959,7 +956,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appearance-logo-scale-range {
|
||||
:global(.appearance-logo-scale-range) {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
@@ -971,14 +968,14 @@
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.appearance-logo-scale-value .input {
|
||||
:global(.appearance-logo-scale-value .input) {
|
||||
width: 70px;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-color.is-empty {
|
||||
:global(.admin-color.is-empty) {
|
||||
opacity: 0.42;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { Checkbox, RadioGroup, RadioGroupItem } from "$components/ui/index.js";
|
||||
import { Checkbox, FileInput, RadioGroup, RadioGroupItem } from "$components/ui/index.js";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Database,
|
||||
@@ -178,10 +178,9 @@
|
||||
? at("backups_uploading", {}, "Загрузка...")
|
||||
: at("backups_upload", {}, "Загрузить архив")}
|
||||
</AdminButton>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
<FileInput
|
||||
bind:element={fileInput}
|
||||
class="backups-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
on:change={uploadSelectedFile}
|
||||
/>
|
||||
@@ -362,7 +361,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backups-file-input {
|
||||
:global(.backups-file-input) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Textarea } from "$components/ui/index.js";
|
||||
import { Send } from "$components/ui/icons.js";
|
||||
import { getContext } from "svelte";
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
@@ -31,12 +32,12 @@
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
|
||||
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
||||
<textarea
|
||||
<Textarea
|
||||
class="admin-textarea"
|
||||
rows="6"
|
||||
value={broadcastText}
|
||||
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
|
||||
></textarea>
|
||||
/>
|
||||
</Label.Root>
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<AdminButton
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Input } from "$components/ui/index.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import {
|
||||
AdminButton,
|
||||
@@ -7,9 +8,11 @@
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { User } from "$components/ui/icons.js";
|
||||
|
||||
export let at;
|
||||
export let fmtDate;
|
||||
export let onOpenUserCard = () => {};
|
||||
|
||||
const logsStore = getContext("logsStore");
|
||||
|
||||
@@ -24,6 +27,25 @@
|
||||
at("content", {}, "Контент"),
|
||||
];
|
||||
|
||||
function userDisplay(entry, kind) {
|
||||
const id = kind === "target" ? entry.target_user_id : entry.user_id;
|
||||
const label = kind === "target" ? entry.target_user_label : entry.user_label;
|
||||
if (label) return label;
|
||||
if (kind !== "target") {
|
||||
if (entry.telegram_first_name) return entry.telegram_first_name;
|
||||
if (entry.telegram_username) {
|
||||
const username = String(entry.telegram_username);
|
||||
return username.startsWith("@") ? username : `@${username}`;
|
||||
}
|
||||
if (entry.email) return entry.email;
|
||||
}
|
||||
return id || "—";
|
||||
}
|
||||
|
||||
function userId(entry, kind) {
|
||||
return kind === "target" ? entry.target_user_id : entry.user_id;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
logsStore.loadLogs();
|
||||
});
|
||||
@@ -31,7 +53,7 @@
|
||||
|
||||
<div class="admin-toolbar admin-toolbar-card">
|
||||
<div class="admin-toolbar-search admin-toolbar-search-actions">
|
||||
<input
|
||||
<Input
|
||||
type="search"
|
||||
class="input"
|
||||
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
|
||||
@@ -64,7 +86,7 @@
|
||||
<AdminTableSkeleton
|
||||
headers={logHeaders}
|
||||
rows={10}
|
||||
widths={["120px", "120px", "58px", "58px", "220px"]}
|
||||
widths={["120px", "120px", "160px", "160px", "220px"]}
|
||||
/>
|
||||
{:else if !logs.length}
|
||||
<AdminEmptyState tone="card"
|
||||
@@ -88,12 +110,50 @@
|
||||
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
|
||||
>{entry.event_type}</td
|
||||
>
|
||||
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")}
|
||||
>{entry.user_id || "—"}</td
|
||||
>
|
||||
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")}
|
||||
>{entry.target_user_id || "—"}</td
|
||||
>
|
||||
<td class="admin-logs-user-cell" data-label={at("user_short", {}, "User")}>
|
||||
{#if userId(entry, "user")}
|
||||
<span class="admin-logs-user">
|
||||
<AdminButton
|
||||
class="admin-logs-user-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={at("payments_open_user", {}, "Open user card")}
|
||||
aria-label={at("payments_open_user", {}, "Open user card")}
|
||||
onclick={() => onOpenUserCard(userId(entry, "user"))}
|
||||
>
|
||||
<User size={14} />
|
||||
</AdminButton>
|
||||
<span class="admin-logs-user-meta">
|
||||
<span class="admin-logs-user-name">{userDisplay(entry, "user")}</span>
|
||||
<span class="admin-logs-user-id">ID {userId(entry, "user")}</span>
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="admin-muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="admin-logs-user-cell" data-label={at("target_short", {}, "Target")}>
|
||||
{#if userId(entry, "target")}
|
||||
<span class="admin-logs-user">
|
||||
<AdminButton
|
||||
class="admin-logs-user-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={at("payments_open_user", {}, "Open user card")}
|
||||
aria-label={at("payments_open_user", {}, "Open user card")}
|
||||
onclick={() => onOpenUserCard(userId(entry, "target"))}
|
||||
>
|
||||
<User size={14} />
|
||||
</AdminButton>
|
||||
<span class="admin-logs-user-meta">
|
||||
<span class="admin-logs-user-name">{userDisplay(entry, "target")}</span>
|
||||
<span class="admin-logs-user-id">ID {userId(entry, "target")}</span>
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="admin-muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
|
||||
>{entry.content || ""}</td
|
||||
>
|
||||
@@ -117,3 +177,53 @@
|
||||
logsStore.setPage(logsPage + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.admin-logs-user-cell {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.admin-logs-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-logs-user-meta {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-logs-user-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--admin-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-logs-user-id {
|
||||
color: var(--admin-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-logs-user-cell :global(.admin-logs-user-btn.admin-btn) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.admin-logs-user-cell :global(.admin-logs-user-btn svg) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Input } from "$components/ui/index.js";
|
||||
import { Trash2 } from "$components/ui/icons.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
@@ -110,7 +111,7 @@
|
||||
<div class="admin-form" data-dialog-content>
|
||||
<div class="admin-dialog-form-section">
|
||||
<AdminField label={at("promo_label_code", {}, "Код")}>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
class="input"
|
||||
value={promoDraft.code}
|
||||
@@ -122,7 +123,7 @@
|
||||
<div class="admin-dialog-form-section">
|
||||
<div class="admin-form-row-2">
|
||||
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
class="input"
|
||||
min="1"
|
||||
@@ -131,7 +132,7 @@
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
class="input"
|
||||
min="1"
|
||||
@@ -141,7 +142,7 @@
|
||||
</AdminField>
|
||||
</div>
|
||||
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
class="input"
|
||||
min="1"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { ColorInput, FileInput, Input, ScrollArea, Textarea } from "$components/ui/index.js";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
@@ -198,6 +199,8 @@
|
||||
url,
|
||||
requiresBaseUrl: Boolean(field.webhook_requires_base_url),
|
||||
baseConfigured: field.webhook_base_url_configured !== false,
|
||||
hintI18nKey: field.webhook_hint_i18n_key || "",
|
||||
hintFallback: field.webhook_hint || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,6 +336,7 @@
|
||||
function sectionTitle(id) {
|
||||
const map = {
|
||||
general: "Общие",
|
||||
remnawave: "Remnawave Panel",
|
||||
appearance: "Внешний вид",
|
||||
pricing: "Тарифы и цены",
|
||||
payments: "Платёжные системы",
|
||||
@@ -415,9 +419,9 @@
|
||||
<small>
|
||||
{webhook.url
|
||||
? at(
|
||||
"settings_provider_webhook_url_hint",
|
||||
adminLocaleKey(webhook.hintI18nKey || "settings_provider_webhook_url_hint"),
|
||||
{},
|
||||
"Use this URL in the provider webhook settings."
|
||||
webhook.hintFallback || "Use this URL in the provider webhook settings."
|
||||
)
|
||||
: at(
|
||||
"settings_provider_webhook_base_missing",
|
||||
@@ -512,13 +516,13 @@
|
||||
>
|
||||
</div>
|
||||
{:else if field.type === "color"}
|
||||
<input
|
||||
<ColorInput
|
||||
class="admin-color"
|
||||
type="color"
|
||||
value={valueFor(field) || "#00fe7a"}
|
||||
ariaLabel={fieldLabelText(field)}
|
||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
value={valueFor(field) || ""}
|
||||
@@ -557,7 +561,7 @@
|
||||
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
|
||||
/>
|
||||
{:else if field.type === "int" || field.type === "float"}
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
step={field.type === "float" ? "0.1" : "1"}
|
||||
@@ -568,19 +572,18 @@
|
||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
{:else if field.type === "text"}
|
||||
<textarea
|
||||
<Textarea
|
||||
class="admin-setting-textarea"
|
||||
rows="4"
|
||||
placeholder={fieldPlaceholderText(field)}
|
||||
value={valueFor(field) ?? ""}
|
||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
></textarea>
|
||||
/>
|
||||
{:else if field.type === "json"}
|
||||
<div class="admin-json-toolbar">
|
||||
<input
|
||||
<FileInput
|
||||
id={"json-file-" + field.key}
|
||||
class="admin-json-file-input"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onchange={(event) => handleJsonFile(field, event)}
|
||||
/>
|
||||
@@ -602,16 +605,16 @@
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
<textarea
|
||||
<Textarea
|
||||
class="admin-setting-textarea admin-setting-json-textarea"
|
||||
rows="10"
|
||||
spellcheck="false"
|
||||
placeholder={fieldPlaceholderText(field)}
|
||||
value={valueFor(field) ?? ""}
|
||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
></textarea>
|
||||
/>
|
||||
{:else if field.secret}
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type={revealed ? "text" : "password"}
|
||||
placeholder={secretPlaceholder(field)}
|
||||
@@ -628,7 +631,7 @@
|
||||
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
|
||||
</AdminButton>
|
||||
{:else}
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={fieldPlaceholderText(field)}
|
||||
@@ -813,7 +816,7 @@
|
||||
<div class="admin-icon-picker-toolbar">
|
||||
<label class="admin-icon-picker-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
<Input
|
||||
bind:value={iconPickerSearch}
|
||||
class="input"
|
||||
type="text"
|
||||
@@ -821,21 +824,23 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-icon-picker-grid">
|
||||
{#each filteredIconOptions as iconName}
|
||||
{@const Icon = iconComponent(iconName)}
|
||||
<button
|
||||
class:active={iconPickerField && iconValue(iconPickerField) === iconName}
|
||||
class="admin-icon-picker-option"
|
||||
type="button"
|
||||
onclick={() => selectIcon(iconName)}
|
||||
>
|
||||
{#if Icon}
|
||||
<svelte:component this={Icon} size={18} />
|
||||
{/if}
|
||||
<span>{iconName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ScrollArea class="admin-icon-picker-scroll" maxHeight="min(52vh, 460px)">
|
||||
<div class="admin-icon-picker-grid">
|
||||
{#each filteredIconOptions as iconName}
|
||||
{@const Icon = iconComponent(iconName)}
|
||||
<button
|
||||
class:active={iconPickerField && iconValue(iconPickerField) === iconName}
|
||||
class="admin-icon-picker-option"
|
||||
type="button"
|
||||
onclick={() => selectIcon(iconName)}
|
||||
>
|
||||
{#if Icon}
|
||||
<svelte:component this={Icon} size={18} />
|
||||
{/if}
|
||||
<span>{iconName}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { fmtTrafficBytes } from "../../lib/admin/format.js";
|
||||
import Badge from "$components/ui/badge.svelte";
|
||||
import { ScrollArea } from "$components/ui/index.js";
|
||||
import * as Card from "$components/ui/card/index.js";
|
||||
import {
|
||||
AdminDashboardGrid,
|
||||
@@ -606,21 +607,23 @@
|
||||
style="width:78%;height:9px;margin-top:6px"
|
||||
></span>
|
||||
</div>
|
||||
<div class="admin-panel-dash-nodes-grid">
|
||||
{#each Array(4) as _, m (m)}
|
||||
<div class="admin-panel-dash-node">
|
||||
<span class="admin-skeleton admin-skeleton-line" style="width:82%"></span>
|
||||
<span
|
||||
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
|
||||
style="width:52%;height:16px;margin-top:6px"
|
||||
></span>
|
||||
<span
|
||||
class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"
|
||||
style="width:44%;margin-top:6px"
|
||||
></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
|
||||
<div class="admin-panel-dash-nodes-grid">
|
||||
{#each Array(4) as _, m (m)}
|
||||
<div class="admin-panel-dash-node">
|
||||
<span class="admin-skeleton admin-skeleton-line" style="width:82%"></span>
|
||||
<span
|
||||
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
|
||||
style="width:52%;height:16px;margin-top:6px"
|
||||
></span>
|
||||
<span
|
||||
class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"
|
||||
style="width:44%;margin-top:6px"
|
||||
></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
@@ -1018,19 +1021,21 @@
|
||||
{at("stats_panel_inner_nodes_hint", {}, "")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-panel-dash-nodes-grid">
|
||||
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
|
||||
<div class="admin-panel-dash-node">
|
||||
<div class="admin-panel-dash-node-name">{node.label}</div>
|
||||
<div class="admin-panel-dash-node-value">{node.value}</div>
|
||||
{#if node.online != null}
|
||||
<div class="admin-panel-dash-node-meta">
|
||||
{at("stats_panel_node_users_online", { count: node.online }, "")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<ScrollArea class="admin-panel-dash-nodes-scroll" maxHeight="240px">
|
||||
<div class="admin-panel-dash-nodes-grid">
|
||||
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
|
||||
<div class="admin-panel-dash-node">
|
||||
<div class="admin-panel-dash-node-name">{node.label}</div>
|
||||
<div class="admin-panel-dash-node-value">{node.value}</div>
|
||||
{#if node.online != null}
|
||||
<div class="admin-panel-dash-node-meta">
|
||||
{at("stats_panel_node_users_online", { count: node.online }, "")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
|
||||
<p class="admin-panel-dash-nodes-more">
|
||||
{at(
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import { Search } from "$components/ui/icons.js";
|
||||
import { ScrollArea, Skeleton } from "$components/ui/index.js";
|
||||
import { Input, ScrollArea, Skeleton } from "$components/ui/index.js";
|
||||
|
||||
export let at = (key) => key;
|
||||
export let initialTicketId = null;
|
||||
@@ -215,7 +215,7 @@
|
||||
<div class="support-admin-toolbar admin-toolbar-card">
|
||||
<label class="support-admin-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="search"
|
||||
placeholder={at("support_search", {}, "Поиск")}
|
||||
@@ -269,16 +269,18 @@
|
||||
{:else if !tickets.length}
|
||||
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
|
||||
{:else}
|
||||
<div class="support-inbox-list">
|
||||
{#each tickets as ticket}
|
||||
<SupportInboxRow
|
||||
{ticket}
|
||||
active={openedTicketId === ticket.ticket_id}
|
||||
{at}
|
||||
onOpen={(item) => supportStore.openTicket(item.ticket_id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<ScrollArea class="support-inbox-list" maxHeight="none">
|
||||
<div class="support-inbox-list-inner">
|
||||
{#each tickets as ticket}
|
||||
<SupportInboxRow
|
||||
{ticket}
|
||||
active={openedTicketId === ticket.ticket_id}
|
||||
{at}
|
||||
onOpen={(item) => supportStore.openTicket(item.ticket_id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Input } from "$components/ui/index.js";
|
||||
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
||||
@@ -71,7 +72,7 @@
|
||||
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="standard"
|
||||
@@ -127,7 +128,7 @@
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
|
||||
@@ -136,7 +137,7 @@
|
||||
</Label.Root>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
|
||||
@@ -148,7 +149,7 @@
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
|
||||
@@ -157,7 +158,7 @@
|
||||
</Label.Root>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
|
||||
@@ -211,7 +212,7 @@
|
||||
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -229,7 +230,7 @@
|
||||
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -248,7 +249,7 @@
|
||||
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -287,7 +288,7 @@
|
||||
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
|
||||
@@ -303,7 +304,7 @@
|
||||
"Опционально для английского интерфейса."
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
|
||||
@@ -359,7 +360,7 @@
|
||||
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -409,7 +410,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.premiumTopupRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
@@ -418,7 +419,7 @@
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -449,7 +450,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.premiumTopupStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
@@ -458,7 +459,7 @@
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -501,7 +502,13 @@
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}
|
||||
tariffsStore.addDraftRow("periodRows", {
|
||||
months: 1,
|
||||
rub: "",
|
||||
stars: "",
|
||||
referral_inviter: "",
|
||||
referral_referee: "",
|
||||
})}
|
||||
>
|
||||
<Plus size={13} />
|
||||
{at("tariff_btn_period", {}, "Период")}
|
||||
@@ -517,15 +524,17 @@
|
||||
</p>
|
||||
{:else}
|
||||
<div class="admin-row-editor">
|
||||
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
|
||||
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
|
||||
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
|
||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
||||
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
|
||||
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
|
||||
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{#each tariffDraft.periodRows as row, index}
|
||||
<div class="admin-row-editor-line admin-row-editor-4">
|
||||
<input
|
||||
<div class="admin-row-editor-line admin-row-editor-6">
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -533,7 +542,7 @@
|
||||
bind:value={row.months}
|
||||
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -542,7 +551,7 @@
|
||||
bind:value={row.rub}
|
||||
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -551,6 +560,24 @@
|
||||
bind:value={row.stars}
|
||||
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
|
||||
/>
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="3"
|
||||
bind:value={row.referral_inviter}
|
||||
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
|
||||
/>
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="1"
|
||||
bind:value={row.referral_referee}
|
||||
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@@ -603,7 +630,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.trafficRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
@@ -612,7 +639,7 @@
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -643,7 +670,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.trafficStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
@@ -652,7 +679,7 @@
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -717,7 +744,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.topupRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
@@ -726,7 +753,7 @@
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -757,7 +784,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.topupStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
@@ -766,7 +793,7 @@
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -841,7 +868,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.hwidRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -854,7 +881,7 @@
|
||||
"Сколько устройств добавляет пакет"
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -885,7 +912,7 @@
|
||||
{/if}
|
||||
{#each tariffDraft.hwidStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -898,7 +925,7 @@
|
||||
"Сколько устройств добавляет пакет"
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Input } from "$components/ui/index.js";
|
||||
import {
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
@@ -36,10 +37,38 @@
|
||||
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
|
||||
const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"];
|
||||
const LEGACY_PERIODS = [
|
||||
["1", "MONTH_1_ENABLED", "RUB_PRICE_1_MONTH", "STARS_PRICE_1_MONTH"],
|
||||
["3", "MONTH_3_ENABLED", "RUB_PRICE_3_MONTHS", "STARS_PRICE_3_MONTHS"],
|
||||
["6", "MONTH_6_ENABLED", "RUB_PRICE_6_MONTHS", "STARS_PRICE_6_MONTHS"],
|
||||
["12", "MONTH_12_ENABLED", "RUB_PRICE_12_MONTHS", "STARS_PRICE_12_MONTHS"],
|
||||
[
|
||||
"1",
|
||||
"MONTH_1_ENABLED",
|
||||
"RUB_PRICE_1_MONTH",
|
||||
"STARS_PRICE_1_MONTH",
|
||||
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
|
||||
],
|
||||
[
|
||||
"3",
|
||||
"MONTH_3_ENABLED",
|
||||
"RUB_PRICE_3_MONTHS",
|
||||
"STARS_PRICE_3_MONTHS",
|
||||
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
|
||||
],
|
||||
[
|
||||
"6",
|
||||
"MONTH_6_ENABLED",
|
||||
"RUB_PRICE_6_MONTHS",
|
||||
"STARS_PRICE_6_MONTHS",
|
||||
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
|
||||
],
|
||||
[
|
||||
"12",
|
||||
"MONTH_12_ENABLED",
|
||||
"RUB_PRICE_12_MONTHS",
|
||||
"STARS_PRICE_12_MONTHS",
|
||||
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
|
||||
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
|
||||
],
|
||||
];
|
||||
const LEGACY_TARIFF_SETTING_KEYS = [
|
||||
...LEGACY_PERIODS.flatMap((row) => row.slice(1)),
|
||||
@@ -375,7 +404,7 @@
|
||||
<code>TRIAL_DURATION_DAYS</code>
|
||||
</div>
|
||||
<div class="admin-setting-control">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -411,7 +440,7 @@
|
||||
<code>TRIAL_TRAFFIC_LIMIT_GB</code>
|
||||
</div>
|
||||
<div class="admin-setting-control">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -563,7 +592,7 @@
|
||||
onValueChange={handleTrialSquadSelect}
|
||||
/>
|
||||
{/key}
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={valueForKey("USER_SQUAD_UUIDS", settingsDirty, settingsFieldMap) ||
|
||||
@@ -794,8 +823,10 @@
|
||||
<span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span>
|
||||
<span>{at("payment_rub", {}, "RUB")}</span>
|
||||
<span>{at("payment_stars", {}, "Stars")}</span>
|
||||
<span>{at("tariffs_legacy_ref_inviter", {}, "Inviter")}</span>
|
||||
<span>{at("tariffs_legacy_ref_referee", {}, "Friend")}</span>
|
||||
</div>
|
||||
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey]}
|
||||
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey, inviterKey, refereeKey]}
|
||||
<div class="admin-legacy-tariff-row">
|
||||
<strong>{months} {at("months_short", {}, "mo")}</strong>
|
||||
<div class="admin-setting-switch">
|
||||
@@ -807,7 +838,7 @@
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -815,7 +846,7 @@
|
||||
value={valueForKey(rubKey, settingsDirty, settingsFieldMap)}
|
||||
oninput={(event) => setSetting(rubKey, event.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -823,6 +854,22 @@
|
||||
value={valueForKey(starsKey, settingsDirty, settingsFieldMap)}
|
||||
oninput={(event) => setSetting(starsKey, event.currentTarget.value)}
|
||||
/>
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={valueForKey(inviterKey, settingsDirty, settingsFieldMap)}
|
||||
oninput={(event) => setSetting(inviterKey, event.currentTarget.value)}
|
||||
/>
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={valueForKey(refereeKey, settingsDirty, settingsFieldMap)}
|
||||
oninput={(event) => setSetting(refereeKey, event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -831,7 +878,7 @@
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_legacy_traffic_packages", {}, "Traffic packages")}</span>
|
||||
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
value={valueForKey("TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
|
||||
@@ -843,7 +890,7 @@
|
||||
>{at("tariffs_legacy_stars_traffic_packages", {}, "Traffic packages, Stars")}</span
|
||||
>
|
||||
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="text"
|
||||
value={valueForKey("STARS_TRAFFIC_PACKAGES", settingsDirty, settingsFieldMap)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Input, Textarea } from "$components/ui/index.js";
|
||||
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||
import { getContext, onDestroy, onMount } from "svelte";
|
||||
@@ -396,7 +397,7 @@
|
||||
|
||||
{#if expanded}
|
||||
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
|
||||
<textarea
|
||||
<Textarea
|
||||
class="admin-setting-textarea admin-translation-textarea"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
@@ -404,7 +405,7 @@
|
||||
value={localeValue(item, lang, dirtyEntry)}
|
||||
oninput={(event) =>
|
||||
translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
|
||||
></textarea>
|
||||
/>
|
||||
<div class="admin-translation-base">
|
||||
<small>{baseKind(item, lang)}</small>
|
||||
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
|
||||
@@ -457,7 +458,7 @@
|
||||
<div class="admin-translations-toolbar">
|
||||
<label class="admin-translations-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
<Input
|
||||
bind:value={search}
|
||||
class="input"
|
||||
type="text"
|
||||
@@ -502,7 +503,7 @@
|
||||
addLanguage();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
bind:value={newLanguageCode}
|
||||
class="input"
|
||||
type="text"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { Label, Separator, Tabs } from "$components/ui/primitives.js";
|
||||
import { Checkbox } from "$components/ui/index.js";
|
||||
import { Checkbox, Input, ScrollArea, Textarea } from "$components/ui/index.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import {
|
||||
AdminBadge,
|
||||
@@ -81,11 +81,7 @@
|
||||
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
||||
$: openedUserTelegramProfileHint =
|
||||
openedUserTelegramProfileLinkKind === "id"
|
||||
? at(
|
||||
"user_open_tg_profile_id_hint",
|
||||
{},
|
||||
"Профиль будет открыт по Telegram ID. Telegram может заблокировать переход из-за настроек приватности пользователя или ограничений клиента."
|
||||
)
|
||||
? at("user_open_tg_profile_id_hint", {}, "Бот отправит кнопку профиля в Telegram")
|
||||
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
|
||||
|
||||
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
|
||||
@@ -189,11 +185,6 @@
|
||||
{at("user_open_tg_profile", {}, "Открыть Telegram")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
{#if openedUserTelegramProfileLinkKind === "id"}
|
||||
<small class="admin-user-telegram-profile-note"
|
||||
>{openedUserTelegramProfileHint}</small
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -552,7 +543,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-user-logs-wrap">
|
||||
<ScrollArea class="admin-user-logs-wrap" maxHeight="min(52vh, 460px)">
|
||||
{#if userLogsLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={[
|
||||
@@ -604,7 +595,7 @@
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
{/if}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{#if userLogsLoaded && userLogsTotal > userLogsPageSize}
|
||||
<AdminPagination
|
||||
@@ -632,7 +623,7 @@
|
||||
<Label.Root class="admin-field-label admin-extend-field">
|
||||
<span>{at("user_label_extend", {}, "Продлить подписку")}</span>
|
||||
<div class="admin-extend-control">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -662,7 +653,7 @@
|
||||
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span
|
||||
>
|
||||
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -741,7 +732,7 @@
|
||||
>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span
|
||||
>
|
||||
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -837,7 +828,7 @@
|
||||
<Label.Root class="admin-field-label admin-extend-field">
|
||||
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
|
||||
<div class="admin-extend-control">
|
||||
<input
|
||||
<Input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -869,12 +860,12 @@
|
||||
"Поддерживается HTML-разметка Telegram"
|
||||
)}</small
|
||||
>
|
||||
<textarea
|
||||
<Textarea
|
||||
class="admin-textarea"
|
||||
rows="3"
|
||||
placeholder={at("user_placeholder_msg", {}, "Текст сообщения")}
|
||||
bind:value={$usersStore.userMessageDraft}
|
||||
></textarea>
|
||||
/>
|
||||
</Label.Root>
|
||||
<div class="admin-message-actions">
|
||||
<AdminButton
|
||||
@@ -976,7 +967,9 @@
|
||||
onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })}
|
||||
class="admin-dialog"
|
||||
>
|
||||
<div class="admin-confirm-message-preview">{userMessageDraft}</div>
|
||||
<ScrollArea class="admin-confirm-message-preview" maxHeight="min(280px, 45vh)">
|
||||
{userMessageDraft}
|
||||
</ScrollArea>
|
||||
<div class="admin-dialog-actions">
|
||||
<AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })}
|
||||
>{at("btn_cancel", {}, "Отмена")}</AdminButton
|
||||
@@ -1166,12 +1159,6 @@
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.admin-user-telegram-profile-note {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--admin-dim);
|
||||
line-height: 1.35;
|
||||
}
|
||||
:global(.admin-avatar-dialog) {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -1230,7 +1217,7 @@
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-user-logs-wrap {
|
||||
:global(.admin-user-logs-wrap) {
|
||||
min-height: 120px;
|
||||
}
|
||||
.admin-user-log-event {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { Input } from "$components/ui/index.js";
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
@@ -107,7 +108,7 @@
|
||||
|
||||
<div class="admin-toolbar admin-toolbar-users">
|
||||
<div class="admin-toolbar-search">
|
||||
<input
|
||||
<Input
|
||||
type="search"
|
||||
class="input"
|
||||
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
|
||||
|
||||
@@ -18,10 +18,10 @@ export function emptyTariffDraft() {
|
||||
hwid_device_limit: "",
|
||||
conversion_rate_rub_per_gb: "",
|
||||
periodRows: [
|
||||
{ months: 1, rub: 150, stars: "" },
|
||||
{ months: 3, rub: 400, stars: "" },
|
||||
{ months: 6, rub: 750, stars: "" },
|
||||
{ months: 12, rub: 1400, stars: "" },
|
||||
{ months: 1, rub: 200, stars: "", referral_inviter: 3, referral_referee: 1 },
|
||||
{ months: 3, rub: 600, stars: "", referral_inviter: 7, referral_referee: 3 },
|
||||
{ months: 6, rub: 1200, stars: "", referral_inviter: 15, referral_referee: 7 },
|
||||
{ months: 12, rub: 2400, stars: "", referral_inviter: 30, referral_referee: 15 },
|
||||
],
|
||||
topupRubRows: [],
|
||||
topupStarsRows: [],
|
||||
@@ -67,6 +67,8 @@ export function draftFromTariff(tariff) {
|
||||
months: month,
|
||||
rub: tariff.prices_rub?.[String(month)] ?? "",
|
||||
stars: tariff.prices_stars?.[String(month)] ?? "",
|
||||
referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "",
|
||||
referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -190,6 +192,8 @@ export function tariffFromDraft(draft) {
|
||||
months: parseIntNumber(row.months),
|
||||
rub: parseNumber(row.rub, 0),
|
||||
stars: parseNumber(row.stars, 0),
|
||||
referral_inviter: parseIntNumber(row.referral_inviter),
|
||||
referral_referee: parseIntNumber(row.referral_referee),
|
||||
}))
|
||||
.filter((row) => row.months > 0)
|
||||
.filter((row) => {
|
||||
@@ -204,6 +208,16 @@ export function tariffFromDraft(draft) {
|
||||
tariff.prices_stars = Object.fromEntries(
|
||||
rows.map((row) => [String(row.months), row.stars || 0])
|
||||
);
|
||||
tariff.referral_bonus_days_inviter = Object.fromEntries(
|
||||
rows
|
||||
.filter((row) => row.referral_inviter !== null)
|
||||
.map((row) => [String(row.months), row.referral_inviter])
|
||||
);
|
||||
tariff.referral_bonus_days_referee = Object.fromEntries(
|
||||
rows
|
||||
.filter((row) => row.referral_referee !== null)
|
||||
.map((row) => [String(row.months), row.referral_referee])
|
||||
);
|
||||
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
|
||||
if (topupPackages) tariff.topup_packages = topupPackages;
|
||||
} else {
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content class="admin-select-content" {sideOffset}>
|
||||
{#each items as item (item.value)}
|
||||
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
||||
<span>{item.label}</span>
|
||||
<Check size={14} class="admin-select-item-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
<Select.Viewport class="admin-select-viewport">
|
||||
{#each items as item (item.value)}
|
||||
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
||||
<span>{item.label}</span>
|
||||
<Check size={14} class="admin-select-item-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let value = "#000000";
|
||||
export let disabled = false;
|
||||
export let ariaLabel = "";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:value
|
||||
class={cn("ui-color-input", className)}
|
||||
type="color"
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
on:input
|
||||
on:change
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -5,6 +5,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import Button from "./button.svelte";
|
||||
import ScrollArea from "./scroll-area.svelte";
|
||||
|
||||
export let open = false;
|
||||
export let title = "";
|
||||
@@ -65,7 +66,9 @@
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<slot />
|
||||
<ScrollArea class="dialog-body-scroll" maxHeight="none">
|
||||
<slot />
|
||||
</ScrollArea>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let element = null;
|
||||
export let accept = undefined;
|
||||
export let disabled = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={element}
|
||||
class={cn("ui-file-input", className)}
|
||||
type="file"
|
||||
{accept}
|
||||
{disabled}
|
||||
on:change
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -2,11 +2,14 @@ export { default as AttentionDot } from "./attention-dot.svelte";
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { default as Button } from "./button.svelte";
|
||||
export { default as Checkbox } from "./checkbox.svelte";
|
||||
export { default as ColorInput } from "./color-input.svelte";
|
||||
export { default as Dialog } from "./dialog.svelte";
|
||||
export { default as FileInput } from "./file-input.svelte";
|
||||
export { default as Input } from "./input.svelte";
|
||||
export { default as LegacyCard } from "./card.svelte";
|
||||
export { default as RadioGroup } from "./radio-group.svelte";
|
||||
export { default as RadioGroupItem } from "./radio-group-item.svelte";
|
||||
export { default as RangeInput } from "./range-input.svelte";
|
||||
export { default as Skeleton } from "./skeleton.svelte";
|
||||
export { default as Spinner } from "./spinner.svelte";
|
||||
export { default as ScrollArea } from "./scroll-area.svelte";
|
||||
|
||||
@@ -1 +1,11 @@
|
||||
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "bits-ui";
|
||||
export {
|
||||
Accordion,
|
||||
Label,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Separator,
|
||||
Slider,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
} from "bits-ui";
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Slider } from "./primitives.js";
|
||||
|
||||
export let value = 0;
|
||||
export let min = undefined;
|
||||
export let max = undefined;
|
||||
export let step = undefined;
|
||||
export let disabled = false;
|
||||
export let ariaLabel = "";
|
||||
export let onValueChange = () => {};
|
||||
export let onValueCommit = () => {};
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
$: sliderValue = Number(value ?? min ?? 0);
|
||||
$: sliderMin = min === undefined ? undefined : Number(min);
|
||||
$: sliderMax = max === undefined ? undefined : Number(max);
|
||||
$: sliderStep = step === undefined ? undefined : Number(step);
|
||||
|
||||
function handleValueChange(next) {
|
||||
value = next;
|
||||
onValueChange(next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Slider.Root
|
||||
class={cn("ui-range-input", className)}
|
||||
type="single"
|
||||
value={sliderValue}
|
||||
min={sliderMin}
|
||||
max={sliderMax}
|
||||
step={sliderStep}
|
||||
{disabled}
|
||||
onValueChange={handleValueChange}
|
||||
{onValueCommit}
|
||||
{...$$restProps}
|
||||
>
|
||||
<Slider.Range class="ui-range-input__range" />
|
||||
<Slider.Thumb class="ui-range-input__thumb" aria-label={ariaLabel} />
|
||||
</Slider.Root>
|
||||
|
||||
<style>
|
||||
:global(.ui-range-input) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:global(.ui-range-input::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--admin-border-strong, var(--border)) 70%, transparent);
|
||||
}
|
||||
|
||||
:global(.ui-range-input__range) {
|
||||
position: absolute;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
:global(.ui-range-input__thumb) {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
background: var(--admin-surface, var(--panel));
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.24);
|
||||
outline: none;
|
||||
transition:
|
||||
box-shadow 0.14s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
:global(.ui-range-input__thumb:hover) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
:global(.ui-range-input__thumb:focus-visible) {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
}
|
||||
|
||||
:global(.ui-range-input[data-disabled]) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,86 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "./primitives.js";
|
||||
|
||||
export let maxHeight = "100%";
|
||||
export let element = null;
|
||||
export let type = "auto";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class={`scroll-area ${className}`.trim()}
|
||||
<ScrollAreaPrimitive.Root
|
||||
class={cn("scroll-area scroll-area--mono", className)}
|
||||
style={`max-height:${maxHeight};`}
|
||||
{type}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<ScrollAreaPrimitive.Viewport bind:ref={element} class="scroll-area__viewport">
|
||||
<slot />
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Scrollbar class="scroll-area__scrollbar" orientation="vertical">
|
||||
<ScrollAreaPrimitive.Thumb class="scroll-area__thumb" />
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
class="scroll-area__scrollbar scroll-area__scrollbar--horizontal"
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb class="scroll-area__thumb" />
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Corner class="scroll-area__corner" />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
<style>
|
||||
:global(.scroll-area) {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.scroll-area__viewport) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: inherit;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
:global(.scroll-area__scrollbar) {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
padding: 2px;
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
|
||||
:global(.scroll-area__scrollbar[data-orientation="vertical"]) {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
:global(.scroll-area__scrollbar[data-orientation="horizontal"]) {
|
||||
flex-direction: column;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
:global(.scroll-area__thumb) {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--admin-muted, var(--muted)) 34%,
|
||||
var(--admin-border, var(--border))
|
||||
);
|
||||
}
|
||||
|
||||
:global(.scroll-area__thumb:hover) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--admin-muted, var(--muted)) 48%,
|
||||
var(--admin-border, var(--border))
|
||||
);
|
||||
}
|
||||
|
||||
:global(.scroll-area__corner) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -101636,22 +101636,18 @@ export const DEMO_DATASET = {
|
||||
audience: "internal",
|
||||
values: {
|
||||
ru: {
|
||||
base: "Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
||||
fallback:
|
||||
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
||||
effective:
|
||||
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
||||
base: "Бот отправит кнопку профиля в Telegram.",
|
||||
fallback: "Бот отправит кнопку профиля в Telegram.",
|
||||
effective: "Бот отправит кнопку профиля в Telegram.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
},
|
||||
en: {
|
||||
base: "This user has no username: the bot will send you a Telegram message with a profile button.",
|
||||
fallback:
|
||||
"Пользователь без username: бот отправит вам в Telegram сообщение с кнопкой открытия профиля.",
|
||||
effective:
|
||||
"This user has no username: the bot will send you a Telegram message with a profile button.",
|
||||
base: "The bot will send a profile button in Telegram.",
|
||||
fallback: "Бот отправит кнопку профиля в Telegram.",
|
||||
effective: "The bot will send a profile button in Telegram.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
|
||||
@@ -103,6 +103,10 @@ function applyDemoEmailAuthUser() {
|
||||
user_id: DEMO_DATASET.currentUser?.user_id || DEMO_DATASET.currentUser?.id || 910001,
|
||||
telegram_id: null,
|
||||
telegram_linked: false,
|
||||
telegram_notifications_status: "unknown",
|
||||
telegram_notifications_enabled: false,
|
||||
telegram_notifications_need_prompt: false,
|
||||
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||
telegram_photo_url: "",
|
||||
avatar_url: "",
|
||||
username: DEMO_DATASET.currentUser?.username || "u3252a8",
|
||||
@@ -181,6 +185,10 @@ function applyDemoTelegramAuthUser(authData = {}) {
|
||||
user_id: adminUser.user_id || adminUser.id || 910001,
|
||||
telegram_id: telegramId,
|
||||
telegram_linked: true,
|
||||
telegram_notifications_status: "needs_start",
|
||||
telegram_notifications_enabled: false,
|
||||
telegram_notifications_need_prompt: true,
|
||||
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||
username,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
@@ -258,6 +266,10 @@ function applyDemoTelegramLink(authData = {}) {
|
||||
user_id: DEV_MOCK.data.user?.user_id || DEV_MOCK.data.user?.id || 910001,
|
||||
telegram_id: telegramId,
|
||||
telegram_linked: true,
|
||||
telegram_notifications_status: "needs_start",
|
||||
telegram_notifications_enabled: false,
|
||||
telegram_notifications_need_prompt: true,
|
||||
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||
username:
|
||||
authData.username ||
|
||||
authDemo.telegram_username ||
|
||||
@@ -1689,17 +1701,25 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
},
|
||||
...[
|
||||
["MONTH_1_ENABLED", "bool", true],
|
||||
["RUB_PRICE_1_MONTH", "float", 150],
|
||||
["RUB_PRICE_1_MONTH", "float", 200],
|
||||
["STARS_PRICE_1_MONTH", "int", 0],
|
||||
["REFERRAL_BONUS_DAYS_INVITER_1_MONTH", "int", 3],
|
||||
["REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", "int", 1],
|
||||
["MONTH_3_ENABLED", "bool", true],
|
||||
["RUB_PRICE_3_MONTHS", "float", 400],
|
||||
["RUB_PRICE_3_MONTHS", "float", 600],
|
||||
["STARS_PRICE_3_MONTHS", "int", 0],
|
||||
["REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", "int", 7],
|
||||
["REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", "int", 3],
|
||||
["MONTH_6_ENABLED", "bool", false],
|
||||
["RUB_PRICE_6_MONTHS", "float", 750],
|
||||
["RUB_PRICE_6_MONTHS", "float", 1200],
|
||||
["STARS_PRICE_6_MONTHS", "int", 0],
|
||||
["REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", "int", 15],
|
||||
["REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", "int", 7],
|
||||
["MONTH_12_ENABLED", "bool", false],
|
||||
["RUB_PRICE_12_MONTHS", "float", 1200],
|
||||
["RUB_PRICE_12_MONTHS", "float", 2400],
|
||||
["STARS_PRICE_12_MONTHS", "int", 0],
|
||||
["REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", "int", 30],
|
||||
["REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", "int", 15],
|
||||
["TRAFFIC_PACKAGES", "string", "10:99,50:399"],
|
||||
["STARS_TRAFFIC_PACKAGES", "string", ""],
|
||||
].map(([key, type, value]) => ({
|
||||
@@ -1983,6 +2003,27 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
applyDemoTelegramLink(body.auth_data || {});
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (
|
||||
path === "/account/telegram/notifications/probe" &&
|
||||
String(options.method || "").toUpperCase() === "POST"
|
||||
) {
|
||||
DEV_MOCK.data.user = {
|
||||
...(DEV_MOCK.data.user || {}),
|
||||
telegram_notifications_status: "enabled",
|
||||
telegram_notifications_enabled: true,
|
||||
telegram_notifications_need_prompt: false,
|
||||
telegram_notifications_start_link: "https://t.me/preview_bot?start=notifications",
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
telegram_notifications: {
|
||||
ok: true,
|
||||
status: "enabled",
|
||||
enabled: true,
|
||||
start_link: "https://t.me/preview_bot?start=notifications",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
|
||||
const body = jsonBody(options);
|
||||
if (isDeviceTopupSaleMode(body.sale_mode)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user